diff --git a/.eslintrc.js b/.eslintrc.js
index 6a2116d7bb..206faa1c7a 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -81,6 +81,15 @@ module.exports = {
       { property: 'substring', message: 'Use .slice instead of .substring.' },
       { property: 'substr', message: 'Use .slice instead of .substr.' },
     ],
+    'no-restricted-syntax': [
+      'error',
+      {
+        // eslint-disable-next-line no-restricted-syntax
+        selector: 'Literal[value=/•/], JSXText[value=/•/]',
+        // eslint-disable-next-line no-restricted-syntax
+        message: "Use '·' (middle dot) instead of '•' (bullet)",
+      },
+    ],
     'no-self-assign': 'off',
     'no-unused-expressions': 'error',
     'no-unused-vars': 'off',
diff --git a/.haml-lint.yml b/.haml-lint.yml
index 12ca463422..d1ed30b260 100644
--- a/.haml-lint.yml
+++ b/.haml-lint.yml
@@ -4,6 +4,11 @@ exclude:
   - 'vendor/**/*'
   - lib/templates/haml/scaffold/_form.html.haml
 
+require:
+  - ./lib/linter/haml_middle_dot.rb
+
 linters:
   AltText:
     enabled: true
+  MiddleDot:
+    enabled: true
diff --git a/.rubocop.yml b/.rubocop.yml
index bd561df1d2..eff89bdaee 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -11,6 +11,7 @@ require:
   - rubocop-rspec
   - rubocop-performance
   - rubocop-capybara
+  - ./lib/linter/rubocop_middle_dot
 
 AllCops:
   TargetRubyVersion: 3.0 # Set to minimum supported version of CI
@@ -205,3 +206,6 @@ Style/TrailingCommaInArrayLiteral:
 # https://docs.rubocop.org/rubocop/cops_style.html#styletrailingcommainhashliteral
 Style/TrailingCommaInHashLiteral:
   EnforcedStyleForMultiline: 'comma'
+
+Style/MiddleDot:
+  Enabled: true
diff --git a/Gemfile b/Gemfile
index 930c5352c3..84f210f481 100644
--- a/Gemfile
+++ b/Gemfile
@@ -20,7 +20,7 @@ gem 'dotenv-rails', '~> 2.8'
 gem 'aws-sdk-s3', '~> 1.123', require: false
 gem 'fog-core', '<= 2.4.0'
 gem 'fog-openstack', '~> 0.3', require: false
-gem 'kt-paperclip', '~> 7.1', github: 'kreeti/kt-paperclip', ref: '11abf222dc31bff71160a1d138b445214f434b2b'
+gem 'kt-paperclip', '~> 7.2'
 gem 'blurhash', '~> 0.1'
 
 gem 'active_model_serializers', '~> 0.10'
@@ -60,7 +60,6 @@ gem 'kaminari', '~> 1.2'
 gem 'link_header', '~> 0.0'
 gem 'mime-types', '~> 3.4.1', require: 'mime/types/columnar'
 gem 'nokogiri', '~> 1.15'
-gem 'nsa', '~> 0.2'
 gem 'oj', '~> 3.14'
 gem 'ox', '~> 2.14'
 gem 'parslet'
diff --git a/Gemfile.lock b/Gemfile.lock
index 7d04d875c5..a9919bd3a2 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -7,18 +7,6 @@ GIT
       hkdf (~> 0.2)
       jwt (~> 2.0)
 
-GIT
-  remote: https://github.com/kreeti/kt-paperclip.git
-  revision: 11abf222dc31bff71160a1d138b445214f434b2b
-  ref: 11abf222dc31bff71160a1d138b445214f434b2b
-  specs:
-    kt-paperclip (7.1.1)
-      activemodel (>= 4.2.0)
-      activesupport (>= 4.2.0)
-      marcel (~> 1.0.1)
-      mime-types
-      terrapin (~> 0.6.0)
-
 GIT
   remote: https://github.com/mastodon/rails-settings-cached.git
   revision: 86328ef0bd04ce21cc0504ff5e334591e8c2ccab
@@ -380,6 +368,12 @@ GEM
       activerecord
       kaminari-core (= 1.2.2)
     kaminari-core (1.2.2)
+    kt-paperclip (7.2.0)
+      activemodel (>= 4.2.0)
+      activesupport (>= 4.2.0)
+      marcel (~> 1.0.1)
+      mime-types
+      terrapin (~> 0.6.0)
     launchy (2.5.2)
       addressable (~> 2.8)
     letter_opener (1.8.1)
@@ -442,11 +436,6 @@ GEM
     nokogiri (1.15.2)
       mini_portile2 (~> 2.8.2)
       racc (~> 1.4)
-    nsa (0.2.8)
-      activesupport (>= 4.2, < 7)
-      concurrent-ruby (~> 1.0, >= 1.0.2)
-      sidekiq (>= 3.5)
-      statsd-ruby (~> 1.4, >= 1.4.0)
     oj (3.14.3)
     omniauth (1.9.2)
       hashie (>= 3.4.6)
@@ -682,7 +671,6 @@ GEM
       net-scp (>= 1.1.2)
       net-ssh (>= 2.8.0)
     stackprof (0.2.25)
-    statsd-ruby (1.5.0)
     stoplight (3.0.1)
       redlock (~> 1.0)
     strong_migrations (0.8.0)
@@ -819,7 +807,7 @@ DEPENDENCIES
   json-ld-preloaded (~> 3.2)
   json-schema (~> 4.0)
   kaminari (~> 1.2)
-  kt-paperclip (~> 7.1)!
+  kt-paperclip (~> 7.2)
   letter_opener (~> 1.8)
   letter_opener_web (~> 2.0)
   link_header (~> 0.0)
@@ -831,7 +819,6 @@ DEPENDENCIES
   net-http (~> 0.3.2)
   net-ldap (~> 0.18)
   nokogiri (~> 1.15)
-  nsa (~> 0.2)
   oj (~> 3.14)
   omniauth (~> 1.9)
   omniauth-cas (~> 2.0)
diff --git a/app/controllers/api/v1/lists_controller.rb b/app/controllers/api/v1/lists_controller.rb
index 843ca2ec2b..4bbbed2673 100644
--- a/app/controllers/api/v1/lists_controller.rb
+++ b/app/controllers/api/v1/lists_controller.rb
@@ -42,6 +42,6 @@ class Api::V1::ListsController < Api::BaseController
   end
 
   def list_params
-    params.permit(:title, :replies_policy)
+    params.permit(:title, :replies_policy, :exclusive)
   end
 end
diff --git a/app/controllers/backups_controller.rb b/app/controllers/backups_controller.rb
index 5891da6f6d..205df48d44 100644
--- a/app/controllers/backups_controller.rb
+++ b/app/controllers/backups_controller.rb
@@ -11,15 +11,15 @@ class BackupsController < ApplicationController
   def download
     case Paperclip::Attachment.default_options[:storage]
     when :s3
-      redirect_to @backup.dump.expiring_url(10)
+      redirect_to @backup.dump.expiring_url(10), allow_other_host: true
     when :fog
       if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present?
-        redirect_to @backup.dump.expiring_url(Time.now.utc + 10)
+        redirect_to @backup.dump.expiring_url(Time.now.utc + 10), allow_other_host: true
       else
-        redirect_to full_asset_url(@backup.dump.url)
+        redirect_to full_asset_url(@backup.dump.url), allow_other_host: true
       end
     when :filesystem
-      redirect_to full_asset_url(@backup.dump.url)
+      redirect_to full_asset_url(@backup.dump.url), allow_other_host: true
     end
   end
 
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index 3232e12a2b..9ed6b583b5 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -138,7 +138,7 @@ export function normalizePollOptionTranslation(translation, poll) {
 
 export function normalizeAnnouncement(announcement) {
   const normalAnnouncement = { ...announcement };
-  const emojiMap = makeEmojiMap.emojis(normalAnnouncement);
+  const emojiMap = makeEmojiMap(normalAnnouncement.emojis);
 
   normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);
 
diff --git a/app/javascript/mastodon/actions/lists.js b/app/javascript/mastodon/actions/lists.js
index 2faa54b955..b0789cd426 100644
--- a/app/javascript/mastodon/actions/lists.js
+++ b/app/javascript/mastodon/actions/lists.js
@@ -151,10 +151,10 @@ export const createListFail = error => ({
   error,
 });
 
-export const updateList = (id, title, shouldReset, replies_policy) => (dispatch, getState) => {
+export const updateList = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch, getState) => {
   dispatch(updateListRequest(id));
 
-  api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy }).then(({ data }) => {
+  api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => {
     dispatch(updateListSuccess(data));
 
     if (shouldReset) {
diff --git a/app/javascript/mastodon/features/explore/index.jsx b/app/javascript/mastodon/features/explore/index.jsx
index dbc0400e8e..185db0732a 100644
--- a/app/javascript/mastodon/features/explore/index.jsx
+++ b/app/javascript/mastodon/features/explore/index.jsx
@@ -67,7 +67,7 @@ class Explore extends PureComponent {
           <Search />
         </div>
 
-        <div className='scrollable scrollable--flex'>
+        <div className='scrollable scrollable--flex' data-nosnippet>
           {isSearching ? (
             <SearchResults />
           ) : (
diff --git a/app/javascript/mastodon/features/list_timeline/index.jsx b/app/javascript/mastodon/features/list_timeline/index.jsx
index f41e8e6f23..f9f3a7c315 100644
--- a/app/javascript/mastodon/features/list_timeline/index.jsx
+++ b/app/javascript/mastodon/features/list_timeline/index.jsx
@@ -8,6 +8,8 @@ import { Helmet } from 'react-helmet';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import { connect } from 'react-redux';
 
+import Toggle from 'react-toggle';
+
 import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
 import { fetchList, deleteList, updateList } from 'mastodon/actions/lists';
 import { openModal } from 'mastodon/actions/modal';
@@ -145,7 +147,13 @@ class ListTimeline extends PureComponent {
   handleRepliesPolicyChange = ({ target }) => {
     const { dispatch } = this.props;
     const { id } = this.props.params;
-    dispatch(updateList(id, undefined, false, target.value));
+    dispatch(updateList(id, undefined, false, undefined, target.value));
+  };
+
+  onExclusiveToggle = ({ target }) => {
+    const { dispatch } = this.props;
+    const { id } = this.props.params;
+    dispatch(updateList(id, undefined, false, target.checked, undefined));
   };
 
   render () {
@@ -154,6 +162,7 @@ class ListTimeline extends PureComponent {
     const pinned = !!columnId;
     const title  = list ? list.get('title') : id;
     const replies_policy = list ? list.get('replies_policy') : undefined;
+    const isExclusive = list ? list.get('exclusive') : undefined;
 
     if (typeof list === 'undefined') {
       return (
@@ -191,6 +200,13 @@ class ListTimeline extends PureComponent {
             </button>
           </div>
 
+          <div className='setting-toggle'>
+            <Toggle id={`list-${id}-exclusive`} defaultChecked={isExclusive} onChange={this.onExclusiveToggle} />
+            <label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'>
+              <FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' />
+            </label>
+          </div>
+
           { replies_policy !== undefined && (
             <div role='group' aria-labelledby={`list-${id}-replies-policy`}>
               <span id={`list-${id}-replies-policy`} className='column-settings__section'>
diff --git a/app/javascript/mastodon/features/onboarding/index.jsx b/app/javascript/mastodon/features/onboarding/index.jsx
index cc0c797900..f1447b771e 100644
--- a/app/javascript/mastodon/features/onboarding/index.jsx
+++ b/app/javascript/mastodon/features/onboarding/index.jsx
@@ -121,7 +121,7 @@ class Onboarding extends ImmutablePureComponent {
 
           <div className='onboarding__steps'>
             <Step onClick={this.handleProfileClick} href='/settings/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} />
-            <Step onClick={this.handleFollowClick} completed={(account.get('following_count') * 1) >= 7} icon='user-plus' label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Follow {count, plural, one {one person} other {# people}}' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own feed. Let's fill it with interesting people." />} />
+            <Step onClick={this.handleFollowClick} completed={(account.get('following_count') * 1) >= 7} icon='user-plus' label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Find at least {count, plural, one {one person} other {# people}} to follow' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own home feed. Let's fill it with interesting people." />} />
             <Step onClick={this.handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' />} />
             <Step onClick={this.handleShareClick} completed={shareClicked} icon='copy' label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} />
           </div>
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.jsx b/app/javascript/mastodon/features/status/components/detailed_status.jsx
index 83a566710d..ddda6eaac6 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.jsx
+++ b/app/javascript/mastodon/features/status/components/detailed_status.jsx
@@ -217,7 +217,7 @@ class DetailedStatus extends ImmutablePureComponent {
     } else if (this.context.router) {
       reblogLink = (
         <>
-           ·
+          {' · '}
           <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'>
             <Icon id={reblogIcon} />
             <span className='detailed-status__reblogs'>
@@ -229,7 +229,7 @@ class DetailedStatus extends ImmutablePureComponent {
     } else {
       reblogLink = (
         <>
-           ·
+          {' · '}
           <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
             <Icon id={reblogIcon} />
             <span className='detailed-status__reblogs'>
@@ -263,7 +263,7 @@ class DetailedStatus extends ImmutablePureComponent {
     if (status.get('edited_at')) {
       edited = (
         <>
-           ·
+          {' · '}
           <EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} />
         </>
       );
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 5ed793cdba..09282de7c8 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -356,6 +356,7 @@
   "lists.delete": "Delete list",
   "lists.edit": "Edit list",
   "lists.edit.submit": "Change title",
+  "lists.exclusive": "Hide these posts from home",
   "lists.new.create": "Add list",
   "lists.new.title_placeholder": "New list title",
   "lists.replies_policy.followed": "Any followed user",
@@ -460,8 +461,8 @@
   "onboarding.start.lead": "Your new Mastodon account is ready to go. Here's how you can make the most of it:",
   "onboarding.start.skip": "Want to skip right ahead?",
   "onboarding.start.title": "You've made it!",
-  "onboarding.steps.follow_people.body": "You curate your own feed. Lets fill it with interesting people.",
-  "onboarding.steps.follow_people.title": "Follow {count, plural, one {one person} other {# people}}",
+  "onboarding.steps.follow_people.body": "You curate your own home feed. Let's fill it with interesting people.",
+  "onboarding.steps.follow_people.title": "Find at least {count, plural, one {one person} other {# people}} to follow",
   "onboarding.steps.publish_status.body": "Say hello to the world.",
   "onboarding.steps.publish_status.title": "Make your first post",
   "onboarding.steps.setup_profile.body": "Others are more likely to interact with you with a filled out profile.",
diff --git a/app/javascript/mastodon/locales/intl_provider.tsx b/app/javascript/mastodon/locales/intl_provider.tsx
index 1ea77c798e..4fa8b2247c 100644
--- a/app/javascript/mastodon/locales/intl_provider.tsx
+++ b/app/javascript/mastodon/locales/intl_provider.tsx
@@ -48,6 +48,7 @@ export const IntlProvider: React.FC<
       locale={locale}
       messages={messages}
       onError={onProviderError}
+      textComponent='span'
       {...props}
     >
       {children}
diff --git a/app/javascript/mastodon/reducers/list_editor.js b/app/javascript/mastodon/reducers/list_editor.js
index ceceb27c7a..d3fd62adec 100644
--- a/app/javascript/mastodon/reducers/list_editor.js
+++ b/app/javascript/mastodon/reducers/list_editor.js
@@ -25,6 +25,7 @@ const initialState = ImmutableMap({
   isSubmitting: false,
   isChanged: false,
   title: '',
+  isExclusive: false,
 
   accounts: ImmutableMap({
     items: ImmutableList(),
@@ -46,6 +47,7 @@ export default function listEditorReducer(state = initialState, action) {
     return state.withMutations(map => {
       map.set('listId', action.list.get('id'));
       map.set('title', action.list.get('title'));
+      map.set('isExclusive', action.list.get('is_exclusive'));
       map.set('isSubmitting', false);
     });
   case LIST_EDITOR_TITLE_CHANGE:
diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss
index 8b7b634071..b50306deda 100644
--- a/app/javascript/styles/mastodon/accounts.scss
+++ b/app/javascript/styles/mastodon/accounts.scss
@@ -3,11 +3,8 @@
     display: block;
     text-decoration: none;
     color: inherit;
-    box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
-
-    @media screen and (max-width: $no-gap-breakpoint) {
-      box-shadow: none;
-    }
+    overflow: hidden;
+    border-radius: 4px;
 
     &:hover,
     &:active,
@@ -22,7 +19,6 @@
     height: 130px;
     position: relative;
     background: darken($ui-base-color, 12%);
-    border-radius: 4px 4px 0 0;
 
     img {
       display: block;
@@ -30,7 +26,6 @@
       height: 100%;
       margin: 0;
       object-fit: cover;
-      border-radius: 4px 4px 0 0;
     }
 
     @media screen and (width <= 600px) {
@@ -45,11 +40,6 @@
     justify-content: flex-start;
     align-items: center;
     background: lighten($ui-base-color, 4%);
-    border-radius: 0 0 4px 4px;
-
-    @media screen and (max-width: $no-gap-breakpoint) {
-      border-radius: 0;
-    }
 
     .avatar {
       flex: 0 0 auto;
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 57f077c4e8..d63a42557f 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -137,6 +137,10 @@ code {
     color: $secondary-text-color;
     margin-bottom: 30px;
 
+    &.invited-by {
+      margin-bottom: 15px;
+    }
+
     a {
       color: $highlight-text-color;
     }
diff --git a/app/lib/admin/metrics/dimension/instance_accounts_dimension.rb b/app/lib/admin/metrics/dimension/instance_accounts_dimension.rb
index 4eac8e611e..f8eb9d7bfb 100644
--- a/app/lib/admin/metrics/dimension/instance_accounts_dimension.rb
+++ b/app/lib/admin/metrics/dimension/instance_accounts_dimension.rb
@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 class Admin::Metrics::Dimension::InstanceAccountsDimension < Admin::Metrics::Dimension::BaseDimension
+  include Admin::Metrics::Dimension::QueryHelper
   include LanguagesHelper
 
   def self.with_params?
@@ -14,19 +15,23 @@ class Admin::Metrics::Dimension::InstanceAccountsDimension < Admin::Metrics::Dim
   protected
 
   def perform_query
-    sql = <<-SQL.squish
+    dimension_data_rows.map { |row| { key: row['username'], human_key: row['username'], value: row['value'].to_s } }
+  end
+
+  def sql_array
+    [sql_query_string, { domain: params[:domain], limit: @limit }]
+  end
+
+  def sql_query_string
+    <<~SQL.squish
       SELECT accounts.username, count(follows.*) AS value
       FROM accounts
       LEFT JOIN follows ON follows.target_account_id = accounts.id
-      WHERE accounts.domain = $1
+      WHERE accounts.domain = :domain
       GROUP BY accounts.id, follows.target_account_id
       ORDER BY value DESC
-      LIMIT $2
+      LIMIT :limit
     SQL
-
-    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:domain]], [nil, @limit]])
-
-    rows.map { |row| { key: row['username'], human_key: row['username'], value: row['value'].to_s } }
   end
 
   def params
diff --git a/app/lib/admin/metrics/dimension/instance_languages_dimension.rb b/app/lib/admin/metrics/dimension/instance_languages_dimension.rb
index 1ede1a56e4..b478213808 100644
--- a/app/lib/admin/metrics/dimension/instance_languages_dimension.rb
+++ b/app/lib/admin/metrics/dimension/instance_languages_dimension.rb
@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 class Admin::Metrics::Dimension::InstanceLanguagesDimension < Admin::Metrics::Dimension::BaseDimension
+  include Admin::Metrics::Dimension::QueryHelper
   include LanguagesHelper
 
   def self.with_params?
@@ -14,21 +15,33 @@ class Admin::Metrics::Dimension::InstanceLanguagesDimension < Admin::Metrics::Di
   protected
 
   def perform_query
-    sql = <<-SQL.squish
+    dimension_data_rows.map { |row| { key: row['language'], human_key: standard_locale_name(row['language']), value: row['value'].to_s } }
+  end
+
+  def sql_array
+    [sql_query_string, { domain: params[:domain], earliest_status_id: earliest_status_id, latest_status_id: latest_status_id, limit: @limit }]
+  end
+
+  def sql_query_string
+    <<~SQL.squish
       SELECT COALESCE(statuses.language, 'und') AS language, count(*) AS value
       FROM statuses
       INNER JOIN accounts ON accounts.id = statuses.account_id
-      WHERE accounts.domain = $1
-        AND statuses.id BETWEEN $2 AND $3
+      WHERE accounts.domain = :domain
+        AND statuses.id BETWEEN :earliest_status_id AND :latest_status_id
         AND statuses.reblog_of_id IS NULL
       GROUP BY COALESCE(statuses.language, 'und')
       ORDER BY count(*) DESC
-      LIMIT $4
+      LIMIT :limit
     SQL
+  end
 
-    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:domain]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]])
+  def earliest_status_id
+    Mastodon::Snowflake.id_at(@start_at, with_random: false)
+  end
 
-    rows.map { |row| { key: row['language'], human_key: standard_locale_name(row['language']), value: row['value'].to_s } }
+  def latest_status_id
+    Mastodon::Snowflake.id_at(@end_at, with_random: false)
   end
 
   def params
diff --git a/app/lib/admin/metrics/dimension/languages_dimension.rb b/app/lib/admin/metrics/dimension/languages_dimension.rb
index f1cf82cf27..100692a17b 100644
--- a/app/lib/admin/metrics/dimension/languages_dimension.rb
+++ b/app/lib/admin/metrics/dimension/languages_dimension.rb
@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 class Admin::Metrics::Dimension::LanguagesDimension < Admin::Metrics::Dimension::BaseDimension
+  include Admin::Metrics::Dimension::QueryHelper
   include LanguagesHelper
 
   def key
@@ -10,18 +11,22 @@ class Admin::Metrics::Dimension::LanguagesDimension < Admin::Metrics::Dimension:
   protected
 
   def perform_query
-    sql = <<-SQL.squish
+    dimension_data_rows.map { |row| { key: row['locale'], human_key: standard_locale_name(row['locale']), value: row['value'].to_s } }
+  end
+
+  def sql_array
+    [sql_query_string, { start_at: @start_at, end_at: @end_at, limit: @limit }]
+  end
+
+  def sql_query_string
+    <<~SQL.squish
       SELECT locale, count(*) AS value
       FROM users
-      WHERE current_sign_in_at BETWEEN $1 AND $2
+      WHERE current_sign_in_at BETWEEN :start_at AND :end_at
         AND locale IS NOT NULL
       GROUP BY locale
       ORDER BY count(*) DESC
-      LIMIT $3
+      LIMIT :limit
     SQL
-
-    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]])
-
-    rows.map { |row| { key: row['locale'], human_key: standard_locale_name(row['locale']), value: row['value'].to_s } }
   end
 end
diff --git a/app/lib/admin/metrics/dimension/query_helper.rb b/app/lib/admin/metrics/dimension/query_helper.rb
new file mode 100644
index 0000000000..9fc953cb3e
--- /dev/null
+++ b/app/lib/admin/metrics/dimension/query_helper.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Admin::Metrics::Dimension::QueryHelper
+  protected
+
+  def dimension_data_rows
+    ActiveRecord::Base.connection.select_all(sanitized_sql_string)
+  end
+
+  def sanitized_sql_string
+    ActiveRecord::Base.sanitize_sql_array(sql_array)
+  end
+end
diff --git a/app/lib/admin/metrics/dimension/servers_dimension.rb b/app/lib/admin/metrics/dimension/servers_dimension.rb
index 91bcce6551..42aba8e213 100644
--- a/app/lib/admin/metrics/dimension/servers_dimension.rb
+++ b/app/lib/admin/metrics/dimension/servers_dimension.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class Admin::Metrics::Dimension::ServersDimension < Admin::Metrics::Dimension::BaseDimension
+  include Admin::Metrics::Dimension::QueryHelper
+
   def key
     'servers'
   end
@@ -8,18 +10,30 @@ class Admin::Metrics::Dimension::ServersDimension < Admin::Metrics::Dimension::B
   protected
 
   def perform_query
-    sql = <<-SQL.squish
+    dimension_data_rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } }
+  end
+
+  def sql_array
+    [sql_query_string, { earliest_status_id: earliest_status_id, latest_status_id: latest_status_id, limit: @limit }]
+  end
+
+  def sql_query_string
+    <<~SQL.squish
       SELECT accounts.domain, count(*) AS value
       FROM statuses
       INNER JOIN accounts ON accounts.id = statuses.account_id
-      WHERE statuses.id BETWEEN $1 AND $2
+      WHERE statuses.id BETWEEN :earliest_status_id AND :latest_status_id
       GROUP BY accounts.domain
       ORDER BY count(*) DESC
-      LIMIT $3
+      LIMIT :limit
     SQL
+  end
 
-    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, Mastodon::Snowflake.id_at(@start_at)], [nil, Mastodon::Snowflake.id_at(@end_at)], [nil, @limit]])
+  def earliest_status_id
+    Mastodon::Snowflake.id_at(@start_at)
+  end
 
-    rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } }
+  def latest_status_id
+    Mastodon::Snowflake.id_at(@end_at)
   end
 end
diff --git a/app/lib/admin/metrics/dimension/sources_dimension.rb b/app/lib/admin/metrics/dimension/sources_dimension.rb
index 122807cdcd..a14c3e7c16 100644
--- a/app/lib/admin/metrics/dimension/sources_dimension.rb
+++ b/app/lib/admin/metrics/dimension/sources_dimension.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class Admin::Metrics::Dimension::SourcesDimension < Admin::Metrics::Dimension::BaseDimension
+  include Admin::Metrics::Dimension::QueryHelper
+
   def key
     'sources'
   end
@@ -8,18 +10,22 @@ class Admin::Metrics::Dimension::SourcesDimension < Admin::Metrics::Dimension::B
   protected
 
   def perform_query
-    sql = <<-SQL.squish
+    dimension_data_rows.map { |row| { key: row['name'] || 'web', human_key: row['name'] || I18n.t('admin.dashboard.website'), value: row['value'].to_s } }
+  end
+
+  def sql_array
+    [sql_query_string, { start_at: @start_at, end_at: @end_at, limit: @limit }]
+  end
+
+  def sql_query_string
+    <<~SQL.squish
       SELECT oauth_applications.name, count(*) AS value
       FROM users
       LEFT JOIN oauth_applications ON oauth_applications.id = users.created_by_application_id
-      WHERE users.created_at BETWEEN $1 AND $2
+      WHERE users.created_at BETWEEN :start_at AND :end_at
       GROUP BY oauth_applications.name
       ORDER BY count(*) DESC
-      LIMIT $3
+      LIMIT :limit
     SQL
-
-    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]])
-
-    rows.map { |row| { key: row['name'] || 'web', human_key: row['name'] || I18n.t('admin.dashboard.website'), value: row['value'].to_s } }
   end
 end
diff --git a/app/lib/admin/metrics/dimension/tag_languages_dimension.rb b/app/lib/admin/metrics/dimension/tag_languages_dimension.rb
index e1349c2294..cd077ff863 100644
--- a/app/lib/admin/metrics/dimension/tag_languages_dimension.rb
+++ b/app/lib/admin/metrics/dimension/tag_languages_dimension.rb
@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 class Admin::Metrics::Dimension::TagLanguagesDimension < Admin::Metrics::Dimension::BaseDimension
+  include Admin::Metrics::Dimension::QueryHelper
   include LanguagesHelper
 
   def self.with_params?
@@ -14,20 +15,36 @@ class Admin::Metrics::Dimension::TagLanguagesDimension < Admin::Metrics::Dimensi
   protected
 
   def perform_query
-    sql = <<-SQL.squish
+    dimension_data_rows.map { |row| { key: row['language'], human_key: standard_locale_name(row['language']), value: row['value'].to_s } }
+  end
+
+  def sql_array
+    [sql_query_string, { tag_id: tag_id, earliest_status_id: earliest_status_id, latest_status_id: latest_status_id, limit: @limit }]
+  end
+
+  def sql_query_string
+    <<~SQL.squish
       SELECT COALESCE(statuses.language, 'und') AS language, count(*) AS value
       FROM statuses
       INNER JOIN statuses_tags ON statuses_tags.status_id = statuses.id
-      WHERE statuses_tags.tag_id = $1
-        AND statuses.id BETWEEN $2 AND $3
+      WHERE statuses_tags.tag_id = :tag_id
+        AND statuses.id BETWEEN :earliest_status_id AND :latest_status_id
       GROUP BY COALESCE(statuses.language, 'und')
       ORDER BY count(*) DESC
-      LIMIT $4
+      LIMIT :limit
     SQL
+  end
 
-    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:id]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]])
+  def tag_id
+    params[:id]
+  end
 
-    rows.map { |row| { key: row['language'], human_key: standard_locale_name(row['language']), value: row['value'].to_s } }
+  def earliest_status_id
+    Mastodon::Snowflake.id_at(@start_at, with_random: false)
+  end
+
+  def latest_status_id
+    Mastodon::Snowflake.id_at(@end_at, with_random: false)
   end
 
   def params
diff --git a/app/lib/admin/metrics/dimension/tag_servers_dimension.rb b/app/lib/admin/metrics/dimension/tag_servers_dimension.rb
index 7ddf3378cd..fc5e49a966 100644
--- a/app/lib/admin/metrics/dimension/tag_servers_dimension.rb
+++ b/app/lib/admin/metrics/dimension/tag_servers_dimension.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class Admin::Metrics::Dimension::TagServersDimension < Admin::Metrics::Dimension::BaseDimension
+  include Admin::Metrics::Dimension::QueryHelper
+
   def self.with_params?
     true
   end
@@ -12,21 +14,37 @@ class Admin::Metrics::Dimension::TagServersDimension < Admin::Metrics::Dimension
   protected
 
   def perform_query
-    sql = <<-SQL.squish
+    dimension_data_rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } }
+  end
+
+  def sql_array
+    [sql_query_string, { tag_id: tag_id, earliest_status_id: earliest_status_id, latest_status_id: latest_status_id, limit: @limit }]
+  end
+
+  def sql_query_string
+    <<-SQL.squish
       SELECT accounts.domain, count(*) AS value
       FROM statuses
       INNER JOIN accounts ON accounts.id = statuses.account_id
       INNER JOIN statuses_tags ON statuses_tags.status_id = statuses.id
-      WHERE statuses_tags.tag_id = $1
-        AND statuses.id BETWEEN $2 AND $3
+      WHERE statuses_tags.tag_id = :tag_id
+        AND statuses.id BETWEEN :earliest_status_id AND :latest_status_id
       GROUP BY accounts.domain
       ORDER BY count(*) DESC
-      LIMIT $4
+      LIMIT :limit
     SQL
+  end
 
-    rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:id]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]])
+  def tag_id
+    params[:id]
+  end
 
-    rows.map { |row| { key: row['domain'] || Rails.configuration.x.local_domain, human_key: row['domain'] || Rails.configuration.x.local_domain, value: row['value'].to_s } }
+  def earliest_status_id
+    Mastodon::Snowflake.id_at(@start_at, with_random: false)
+  end
+
+  def latest_status_id
+    Mastodon::Snowflake.id_at(@end_at, with_random: false)
   end
 
   def params
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 3cf6e21c39..6cca828a25 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -40,9 +40,9 @@ class FeedManager
   def filter?(timeline_type, status, receiver)
     case timeline_type
     when :home
-      filter_from_home?(status, receiver.id, build_crutches(receiver.id, [status]))
+      filter_from_home?(status, receiver.id, build_crutches(receiver.id, [status]), :home)
     when :list
-      filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]))
+      filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]), :list)
     when :mentions
       filter_from_mentions?(status, receiver.id)
     when :direct
@@ -401,10 +401,11 @@ class FeedManager
   # @param [Integer] receiver_id
   # @param [Hash] crutches
   # @return [Boolean]
-  def filter_from_home?(status, receiver_id, crutches)
+  def filter_from_home?(status, receiver_id, crutches, timeline_type = :home)
     return false if receiver_id == status.account_id
     return true  if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
-    return true  if crutches[:languages][status.account_id].present? && status.language.present? && !crutches[:languages][status.account_id].include?(status.language)
+    return true if timeline_type != :list && crutches[:exclusive_list_users][status.account_id].present?
+    return true if crutches[:languages][status.account_id].present? && status.language.present? && !crutches[:languages][status.account_id].include?(status.language)
 
     check_for_blocks = crutches[:active_mentions][status.id] || []
     check_for_blocks.push(status.account_id)
@@ -603,13 +604,16 @@ class FeedManager
       arr
     end
 
-    crutches[:following]       = Follow.where(account_id: receiver_id, target_account_id: statuses.filter_map(&:in_reply_to_account_id)).pluck(:target_account_id).index_with(true)
-    crutches[:languages]       = Follow.where(account_id: receiver_id, target_account_id: statuses.map(&:account_id)).pluck(:target_account_id, :languages).to_h
-    crutches[:hiding_reblogs]  = Follow.where(account_id: receiver_id, target_account_id: statuses.filter_map { |s| s.account_id if s.reblog? }, 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.flat_map { |s| [s.account.domain, 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.account_id, s.reblog&.account_id] }.flatten.compact).pluck(:account_id).index_with(true)
+    lists = List.where(account_id: receiver_id, exclusive: true)
+
+    crutches[:following]            = Follow.where(account_id: receiver_id, target_account_id: statuses.filter_map(&:in_reply_to_account_id)).pluck(:target_account_id).index_with(true)
+    crutches[:languages]            = Follow.where(account_id: receiver_id, target_account_id: statuses.map(&:account_id)).pluck(:target_account_id, :languages).to_h
+    crutches[:hiding_reblogs]       = Follow.where(account_id: receiver_id, target_account_id: statuses.filter_map { |s| s.account_id if s.reblog? }, 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.flat_map { |s| [s.account.domain, 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.account_id, s.reblog&.account_id] }.flatten.compact).pluck(:account_id).index_with(true)
+    crutches[:exclusive_list_users] = ListAccount.where(list: lists, account_id: statuses.map(&:account_id)).pluck(:account_id).index_with(true)
 
     crutches
   end
diff --git a/app/models/list.rb b/app/models/list.rb
index bd1bdbd24d..7dc96f01b3 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -10,6 +10,7 @@
 #  created_at     :datetime         not null
 #  updated_at     :datetime         not null
 #  replies_policy :integer          default("list"), not null
+#  exclusive      :boolean          default(FALSE)
 #
 
 class List < ApplicationRecord
diff --git a/app/serializers/rest/list_serializer.rb b/app/serializers/rest/list_serializer.rb
index 3e87f71196..6a1b6ea3eb 100644
--- a/app/serializers/rest/list_serializer.rb
+++ b/app/serializers/rest/list_serializer.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class REST::ListSerializer < ActiveModel::Serializer
-  attributes :id, :title, :replies_policy
+  attributes :id, :title, :replies_policy, :exclusive
 
   def id
     object.id.to_s
diff --git a/app/views/admin/email_domain_blocks/_email_domain_block.html.haml b/app/views/admin/email_domain_blocks/_email_domain_block.html.haml
index c5a55bc27c..7cb973c4b4 100644
--- a/app/views/admin/email_domain_blocks/_email_domain_block.html.haml
+++ b/app/views/admin/email_domain_blocks/_email_domain_block.html.haml
@@ -9,6 +9,6 @@
 
       - if email_domain_block.parent.present?
         = t('admin.email_domain_blocks.resolved_through_html', domain: content_tag(:samp, email_domain_block.parent.domain))
-        •
+        ·
 
       = t('admin.email_domain_blocks.attempts_over_week', count: email_domain_block.history.reduce(0) { |sum, day| sum + day.accounts })
diff --git a/app/views/admin/export_domain_blocks/_domain_block.html.haml b/app/views/admin/export_domain_blocks/_domain_block.html.haml
index 5d4b6c4d0d..cdce4fd28a 100644
--- a/app/views/admin/export_domain_blocks/_domain_block.html.haml
+++ b/app/views/admin/export_domain_blocks/_domain_block.html.haml
@@ -17,11 +17,11 @@
 
       %br/
 
-      = f.object.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' • ')
+      = f.object.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' · ')
       - if f.object.public_comment.present?
-        •
+        ·
         = f.object.public_comment
       - if existing_relationships
-        •
+        ·
         = fa_icon 'warning fw'
         = t('admin.export_domain_blocks.import.existing_relationships_warning')
diff --git a/app/views/admin/instances/_instance.html.haml b/app/views/admin/instances/_instance.html.haml
index 93f9bd4181..65cf789ce3 100644
--- a/app/views/admin/instances/_instance.html.haml
+++ b/app/views/admin/instances/_instance.html.haml
@@ -6,7 +6,7 @@
 
       %small
         - if instance.domain_block
-          = instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' • ')
+          = instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' · ')
         - elsif instance.domain_allow
           = t('admin.accounts.whitelisted')
         - else
diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml
index 00c1927dfe..1380b9a4d2 100644
--- a/app/views/admin/instances/show.html.haml
+++ b/app/views/admin/instances/show.html.haml
@@ -55,7 +55,7 @@
             %td= @instance.domain_block.public_comment
           %tr
             %th= t('admin.instances.content_policies.policy')
-            %td= @instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' • ')
+            %td= @instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' · ')
 
     = link_to t('admin.domain_blocks.edit'), edit_admin_domain_block_path(@instance.domain_block), class: 'button'
     = link_to t('admin.domain_blocks.undo'), admin_domain_block_path(@instance.domain_block), class: 'button', data: { confirm: t('admin.accounts.are_you_sure'), method: :delete }
diff --git a/app/views/admin/ip_blocks/_ip_block.html.haml b/app/views/admin/ip_blocks/_ip_block.html.haml
index b8d3ac0e86..3dc6f8f8e5 100644
--- a/app/views/admin/ip_blocks/_ip_block.html.haml
+++ b/app/views/admin/ip_blocks/_ip_block.html.haml
@@ -5,7 +5,7 @@
     .pending-account__header
       %samp= link_to "#{ip_block.ip}/#{ip_block.ip.prefix}", admin_accounts_path(ip: "#{ip_block.ip}/#{ip_block.ip.prefix}")
       - if ip_block.comment.present?
-        •
+        ·
         = ip_block.comment
       %br/
       = t("simple_form.labels.ip_block.severities.#{ip_block.severity}")
diff --git a/app/views/admin/roles/_role.html.haml b/app/views/admin/roles/_role.html.haml
index 798d8d8b4f..d6c6b62c81 100644
--- a/app/views/admin/roles/_role.html.haml
+++ b/app/views/admin/roles/_role.html.haml
@@ -24,7 +24,7 @@
         = t('admin.roles.everyone_full_description_html')
       - else
         = link_to t('admin.roles.assigned_users', count: role.users.count), admin_accounts_path(role_ids: role.id)
-        •
+        ·
         %abbr{ title: role.permissions_as_keys.map { |privilege| I18n.t("admin.roles.privileges.#{privilege}") }.join(', ') }= t('admin.roles.permissions_count', count: role.permissions_as_keys.size)
     %div
       = table_link_to 'pencil', t('admin.accounts.edit'), edit_admin_role_path(role) if can?(:update, role)
diff --git a/app/views/admin/settings/content_retention/show.html.haml b/app/views/admin/settings/content_retention/show.html.haml
index b9467572af..5a67016148 100644
--- a/app/views/admin/settings/content_retention/show.html.haml
+++ b/app/views/admin/settings/content_retention/show.html.haml
@@ -12,7 +12,7 @@
 
   .fields-group
     = f.input :media_cache_retention_period, wrapper: :with_block_label, input_html: { pattern: '[0-9]+' }
-    = f.input :content_cache_retention_period, wrapper: :with_block_label, input_html: { pattern: '[0-9]+' }
+    = f.input :content_cache_retention_period, wrapper: :with_block_label, input_html: { pattern: '[0-9]+' }, hint: false, warning_hint: t('simple_form.hints.form_admin_settings.content_cache_retention_period')
     = f.input :backups_retention_period, wrapper: :with_block_label, input_html: { pattern: '[0-9]+' }
 
   .actions
diff --git a/app/views/admin/trends/links/_preview_card.html.haml b/app/views/admin/trends/links/_preview_card.html.haml
index 8812feb316..1ca3483715 100644
--- a/app/views/admin/trends/links/_preview_card.html.haml
+++ b/app/views/admin/trends/links/_preview_card.html.haml
@@ -10,21 +10,21 @@
 
       - if preview_card.provider_name.present?
         = preview_card.provider_name
-        •
+        ·
 
       - if preview_card.language.present?
         = standard_locale_name(preview_card.language)
-        •
+        ·
 
       = t('admin.trends.links.shared_by_over_week', count: preview_card.history.reduce(0) { |sum, day| sum + day.accounts })
 
       - if preview_card.trend.allowed?
-        •
+        ·
         %abbr{ title: t('admin.trends.tags.current_score', score: preview_card.trend.score) }= t('admin.trends.tags.trending_rank', rank: preview_card.trend.rank)
 
         - if preview_card.decaying?
-          •
+          ·
           = t('admin.trends.tags.peaked_on_and_decaying', date: l(preview_card.max_score_at.to_date, format: :short))
       - elsif preview_card.requires_review?
-        •
+        ·
         = t('admin.trends.pending_review')
diff --git a/app/views/admin/trends/statuses/_status.html.haml b/app/views/admin/trends/statuses/_status.html.haml
index f35e13d128..98f2e77090 100644
--- a/app/views/admin/trends/statuses/_status.html.haml
+++ b/app/views/admin/trends/statuses/_status.html.haml
@@ -17,17 +17,17 @@
     = t('admin.trends.statuses.shared_by', count: status.reblogs_count + status.favourites_count, friendly_count: friendly_number_to_human(status.reblogs_count + status.favourites_count))
 
     - if status.account.domain.present?
-      •
+      ·
       = status.account.domain
     - if status.language.present?
-      •
+      ·
       = standard_locale_name(status.language)
     - if status.trendable? && !status.account.discoverable?
-      •
+      ·
       = t('admin.trends.statuses.not_discoverable')
     - if status.trend.allowed?
-      •
+      ·
       %abbr{ title: t('admin.trends.tags.current_score', score: status.trend.score) }= t('admin.trends.tags.trending_rank', rank: status.trend.rank)
     - elsif status.requires_review?
-      •
+      ·
       = t('admin.trends.pending_review')
diff --git a/app/views/admin/trends/tags/_tag.html.haml b/app/views/admin/trends/tags/_tag.html.haml
index a30666a08b..3bbdd08db8 100644
--- a/app/views/admin/trends/tags/_tag.html.haml
+++ b/app/views/admin/trends/tags/_tag.html.haml
@@ -13,12 +13,12 @@
       = t('admin.trends.tags.used_by_over_week', count: tag.history.reduce(0) { |sum, day| sum + day.accounts })
 
       - if tag.trendable? && (rank = Trends.tags.rank(tag.id))
-        •
+        ·
         %abbr{ title: t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id)) }= t('admin.trends.tags.trending_rank', rank: rank + 1)
 
         - if tag.decaying?
-          •
+          ·
           = t('admin.trends.tags.peaked_on_and_decaying', date: l(tag.max_score_at.to_date, format: :short))
       - elsif tag.requires_review?
-        •
+        ·
         = t('admin.trends.pending_review')
diff --git a/app/views/admin/webhooks/_webhook.html.haml b/app/views/admin/webhooks/_webhook.html.haml
index d94a41eb3d..6b3e49eba0 100644
--- a/app/views/admin/webhooks/_webhook.html.haml
+++ b/app/views/admin/webhooks/_webhook.html.haml
@@ -10,7 +10,7 @@
       - else
         %span.negative-hint= t('admin.webhooks.disabled')
 
-      •
+      ·
 
       %abbr{ title: webhook.events.join(', ') }= t('admin.webhooks.enabled_events', count: webhook.events.size)
 
diff --git a/app/views/admin_mailer/_new_trending_links.text.erb b/app/views/admin_mailer/_new_trending_links.text.erb
index 602e12793e..85f3f8039d 100644
--- a/app/views/admin_mailer/_new_trending_links.text.erb
+++ b/app/views/admin_mailer/_new_trending_links.text.erb
@@ -1,8 +1,8 @@
 <%= raw t('admin_mailer.new_trends.new_trending_links.title') %>
 
 <% @links.each do |link| %>
-- <%= link.title %> • <%= link.url %>
-  <%= standard_locale_name(link.language) %> • <%= raw t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: link.trend.score.round(2)) %>
+- <%= link.title %> · <%= link.url %>
+  <%= standard_locale_name(link.language) %> · <%= raw t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> · <%= t('admin.trends.tags.current_score', score: link.trend.score.round(2)) %>
 <% end %>
 
 <%= raw t('application_mailer.view')%> <%= admin_trends_links_url %>
diff --git a/app/views/admin_mailer/_new_trending_statuses.text.erb b/app/views/admin_mailer/_new_trending_statuses.text.erb
index 1ed3ae8573..eedbfff9d9 100644
--- a/app/views/admin_mailer/_new_trending_statuses.text.erb
+++ b/app/views/admin_mailer/_new_trending_statuses.text.erb
@@ -2,7 +2,7 @@
 
 <% @statuses.each do |status| %>
 - <%= ActivityPub::TagManager.instance.url_for(status) %>
-  <%= standard_locale_name(status.language) %> • <%= raw t('admin.trends.tags.current_score', score: status.trend.score.round(2)) %>
+  <%= standard_locale_name(status.language) %> · <%= raw t('admin.trends.tags.current_score', score: status.trend.score.round(2)) %>
 <% end %>
 
 <%= raw t('application_mailer.view')%> <%= admin_trends_statuses_url %>
diff --git a/app/views/admin_mailer/_new_trending_tags.text.erb b/app/views/admin_mailer/_new_trending_tags.text.erb
index 363df369d5..d528ab8eb7 100644
--- a/app/views/admin_mailer/_new_trending_tags.text.erb
+++ b/app/views/admin_mailer/_new_trending_tags.text.erb
@@ -2,7 +2,7 @@
 
 <% @tags.each do |tag| %>
 - #<%= tag.display_name %>
-  <%= raw t('admin.trends.tags.usage_comparison', today: tag.history.get(Time.now.utc).accounts, yesterday: tag.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id).round(2)) %>
+  <%= raw t('admin.trends.tags.usage_comparison', today: tag.history.get(Time.now.utc).accounts, yesterday: tag.history.get(Time.now.utc - 1.day).accounts) %> · <%= t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id).round(2)) %>
 <% end %>
 
 <% if @lowest_trending_tag %>
diff --git a/app/views/application/_card.html.haml b/app/views/application/_card.html.haml
index 719856d495..1b3dd889c1 100644
--- a/app/views/application/_card.html.haml
+++ b/app/views/application/_card.html.haml
@@ -1,9 +1,11 @@
 - account_url = local_assigns[:admin] ? admin_account_path(account.id) : ActivityPub::TagManager.instance.url_for(account)
+- compact ||= false
 
 .card.h-card
   = link_to account_url, target: '_blank', rel: 'noopener noreferrer' do
-    .card__img
-      = image_tag account.header.url, alt: ''
+    - unless compact
+      .card__img
+        = image_tag account.header.url, alt: ''
     .card__bar
       .avatar
         = image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo'
diff --git a/app/views/auth/registrations/rules.html.haml b/app/views/auth/registrations/rules.html.haml
index ab3fa864ab..234f4a601d 100644
--- a/app/views/auth/registrations/rules.html.haml
+++ b/app/views/auth/registrations/rules.html.haml
@@ -7,8 +7,14 @@
 .simple_form
   = render 'auth/shared/progress', stage: 'rules'
 
-  %h1.title= t('auth.rules.title')
-  %p.lead= t('auth.rules.preamble', domain: site_hostname)
+  - if @invite.present? && @invite.autofollow?
+    %h1.title= t('auth.rules.title_invited')
+    %p.lead.invited-by= t('auth.rules.invited_by', domain: site_hostname)
+    = render 'application/card', account: @invite.user.account, compact: true
+    %p.lead= t('auth.rules.preamble_invited', domain: site_hostname)
+  - else
+    %h1.title= t('auth.rules.title')
+    %p.lead= t('auth.rules.preamble', domain: site_hostname)
 
   %ol.rules-list
     - @rules.each do |rule|
diff --git a/app/views/oauth/authorized_applications/index.html.haml b/app/views/oauth/authorized_applications/index.html.haml
index 55d8524dbe..689f051029 100644
--- a/app/views/oauth/authorized_applications/index.html.haml
+++ b/app/views/oauth/authorized_applications/index.html.haml
@@ -23,7 +23,7 @@
           - else
             = t('doorkeeper.authorized_applications.index.never_used')
 
-          •
+          ·
 
           = t('doorkeeper.authorized_applications.index.authorized_at', date: l(application.created_at.to_date))
 
diff --git a/config/initializers/simple_form.rb b/config/initializers/simple_form.rb
index fff4f538ea..01a38879f2 100644
--- a/config/initializers/simple_form.rb
+++ b/config/initializers/simple_form.rb
@@ -19,6 +19,14 @@ module RecommendedComponent
   end
 end
 
+module WarningHintComponent
+  def warning_hint(_wrapper_options = nil)
+    @warning_hint ||= begin
+      options[:warning_hint].to_s.html_safe if options[:warning_hint].present?
+    end
+  end
+end
+
 module GlitchOnlyComponent
   def glitch_only(_wrapper_options = nil)
     return unless options[:glitch_only]
@@ -30,6 +38,7 @@ end
 
 SimpleForm.include_component(AppendComponent)
 SimpleForm.include_component(RecommendedComponent)
+SimpleForm.include_component(WarningHintComponent)
 SimpleForm.include_component(GlitchOnlyComponent)
 
 SimpleForm.setup do |config|
@@ -112,6 +121,7 @@ SimpleForm.setup do |config|
     b.use :html5
     b.use :label
     b.use :hint, wrap_with: { tag: :span, class: :hint }
+    b.use :warning_hint, wrap_with: { tag: :span, class: [:hint, 'warning-hint'] }
     b.use :input, wrap_with: { tag: :div, class: :label_input }
     b.use :error, wrap_with: { tag: :span, class: :error }
   end
diff --git a/config/initializers/statsd.rb b/config/initializers/statsd.rb
deleted file mode 100644
index 93ea1d1e4a..0000000000
--- a/config/initializers/statsd.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-if ENV['STATSD_ADDR'].present?
-  host, port = ENV['STATSD_ADDR'].split(':')
-
-  $statsd = ::Statsd.new(host, port)
-  $statsd.namespace = ENV.fetch('STATSD_NAMESPACE') { ['Mastodon', Rails.env].join('.') }
-
-  ::NSA.inform_statsd($statsd) do |informant|
-    informant.collect(:action_controller, :web)
-    informant.collect(:active_record, :db)
-    informant.collect(:active_support_cache, :cache)
-    informant.collect(:sidekiq, :sidekiq)
-  end
-end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 6a8da6e60d..2c292c42d4 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1031,8 +1031,11 @@ en:
     rules:
       accept: Accept
       back: Back
+      invited_by: 'You can join %{domain} thanks to the invitation you have received from:'
       preamble: These are set and enforced by the %{domain} moderators.
+      preamble_invited: Before you proceed, please consider the ground rules set by the moderators of %{domain}.
       title: Some ground rules.
+      title_invited: You've been invited.
     security: Security
     set_new_password: Set new password
     setup:
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index b646a15e26..9c747e595d 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -78,7 +78,7 @@ en:
         backups_retention_period: Keep generated user archives for the specified number of days.
         bootstrap_timeline_accounts: These accounts will be pinned to the top of new users' follow recommendations.
         closed_registrations_message: Displayed when sign-ups are closed
-        content_cache_retention_period: Posts from other servers will be deleted after the specified number of days when set to a positive value. This may be irreversible.
+        content_cache_retention_period: All posts and boosts from other servers will be deleted after the specified number of days. Some posts may not be recoverable. All related bookmarks, favourites and boosts will also be lost and impossible to undo.
         custom_css: You can apply custom styles on the web version of Mastodon.
         mascot: Overrides the illustration in the advanced web interface.
         media_cache_retention_period: Downloaded media files will be deleted after the specified number of days when set to a positive value, and re-downloaded on demand.
diff --git a/config/webpack/translationRunner.js b/config/webpack/translationRunner.js
deleted file mode 100644
index 77534c9de3..0000000000
--- a/config/webpack/translationRunner.js
+++ /dev/null
@@ -1,3 +0,0 @@
-console.error("The localisation functionality has been refactored, please see the Localisation section in the development documentation (https://docs.joinmastodon.org/dev/code/#localizations)");
-
-process.exit(1);
diff --git a/db/migrate/20230605085710_add_exclusive_to_lists.rb b/db/migrate/20230605085710_add_exclusive_to_lists.rb
new file mode 100644
index 0000000000..cc21a3e315
--- /dev/null
+++ b/db/migrate/20230605085710_add_exclusive_to_lists.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddExclusiveToLists < ActiveRecord::Migration[6.1]
+  def change
+    add_column :lists, :exclusive, :boolean, null: false, default: false
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 11b5fb8d64..063360a45f 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2023_05_31_154811) do
+ActiveRecord::Schema.define(version: 2023_06_05_085710) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -567,6 +567,7 @@ ActiveRecord::Schema.define(version: 2023_05_31_154811) do
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
     t.integer "replies_policy", default: 0, null: false
+    t.boolean "exclusive", default: false
     t.index ["account_id"], name: "index_lists_on_account_id"
   end
 
diff --git a/lib/linter/haml_middle_dot.rb b/lib/linter/haml_middle_dot.rb
new file mode 100644
index 0000000000..3b27711521
--- /dev/null
+++ b/lib/linter/haml_middle_dot.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module HamlLint
+  # Bans the usage of “•” (bullet) in HTML/HAML in favor of “·” (middle dot) in anything that will end up as a text node. (including string literals in Ruby code)
+  class Linter::MiddleDot < Linter
+    include LinterRegistry
+
+    # rubocop:disable Style/MiddleDot
+    BULLET = '•'
+    # rubocop:enable Style/MiddleDot
+    MIDDLE_DOT = '·'
+    MESSAGE = "Use '#{MIDDLE_DOT}' (middle dot) instead of '#{BULLET}' (bullet)".freeze
+
+    def visit_plain(node)
+      return unless node.text.include?(BULLET)
+
+      record_lint(node, MESSAGE)
+    end
+
+    def visit_script(node)
+      return unless node.script.include?(BULLET)
+
+      record_lint(node, MESSAGE)
+    end
+  end
+end
diff --git a/lib/linter/rubocop_middle_dot.rb b/lib/linter/rubocop_middle_dot.rb
new file mode 100644
index 0000000000..3a1d97c0c9
--- /dev/null
+++ b/lib/linter/rubocop_middle_dot.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module RuboCop
+  module Cop
+    module Style
+      # Bans the usage of “•” (bullet) in HTML/HAML in favor of “·” (middle dot) in string literals
+      class MiddleDot < Base
+        extend AutoCorrector
+        extend Util
+
+        # rubocop:disable Style/MiddleDot
+        BULLET = '•'
+        # rubocop:enable Style/MiddleDot
+        MIDDLE_DOT = '·'
+        MESSAGE = "Use '#{MIDDLE_DOT}' (middle dot) instead of '#{BULLET}' (bullet)".freeze
+
+        def on_str(node)
+          # Constants like __FILE__ are handled as strings,
+          # but don't respond to begin.
+          return unless node.loc.respond_to?(:begin) && node.loc.begin
+
+          return unless node.value.include?(BULLET)
+
+          add_offense(node, message: MESSAGE) do |corrector|
+            corrector.replace(node, node.source.gsub(BULLET, MIDDLE_DOT))
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/package.json b/package.json
index fc59df9978..c42ec05fc8 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,6 @@
     "lint:sass": "stylelint \"**/*.{css,scss}\" && prettier --check \"**/*.{css,scss}\"",
     "lint:yml": "prettier --check \"**/*.{yaml,yml}\"",
     "lint": "yarn lint:js && yarn lint:json && yarn lint:sass && yarn lint:yml",
-    "manage:translations": "node ./config/webpack/translationRunner.js",
     "postversion": "git push --tags",
     "prepare": "husky install",
     "start": "node ./streaming/index.js",
diff --git a/spec/lib/admin/metrics/dimension/instance_accounts_dimension_spec.rb b/spec/lib/admin/metrics/dimension/instance_accounts_dimension_spec.rb
new file mode 100644
index 0000000000..106717f97b
--- /dev/null
+++ b/spec/lib/admin/metrics/dimension/instance_accounts_dimension_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Admin::Metrics::Dimension::InstanceAccountsDimension do
+  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
+
+  let(:start_at) { 2.days.ago }
+  let(:end_at) { Time.now.utc }
+  let(:limit) { 10 }
+  let(:params) { ActionController::Parameters.new }
+
+  describe '#data' do
+    it 'runs data query without error' do
+      expect { dimension.data }.to_not raise_error
+    end
+  end
+end
diff --git a/spec/lib/admin/metrics/dimension/instance_languages_dimension_spec.rb b/spec/lib/admin/metrics/dimension/instance_languages_dimension_spec.rb
new file mode 100644
index 0000000000..f9f6430ca0
--- /dev/null
+++ b/spec/lib/admin/metrics/dimension/instance_languages_dimension_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Admin::Metrics::Dimension::InstanceLanguagesDimension do
+  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
+
+  let(:start_at) { 2.days.ago }
+  let(:end_at) { Time.now.utc }
+  let(:limit) { 10 }
+  let(:params) { ActionController::Parameters.new }
+
+  describe '#data' do
+    it 'runs data query without error' do
+      expect { dimension.data }.to_not raise_error
+    end
+  end
+end
diff --git a/spec/lib/admin/metrics/dimension/languages_dimension_spec.rb b/spec/lib/admin/metrics/dimension/languages_dimension_spec.rb
new file mode 100644
index 0000000000..1722c4c616
--- /dev/null
+++ b/spec/lib/admin/metrics/dimension/languages_dimension_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Admin::Metrics::Dimension::LanguagesDimension do
+  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
+
+  let(:start_at) { 2.days.ago }
+  let(:end_at) { Time.now.utc }
+  let(:limit) { 10 }
+  let(:params) { ActionController::Parameters.new }
+
+  describe '#data' do
+    it 'runs data query without error' do
+      expect { dimension.data }.to_not raise_error
+    end
+  end
+end
diff --git a/spec/lib/admin/metrics/dimension/servers_dimension_spec.rb b/spec/lib/admin/metrics/dimension/servers_dimension_spec.rb
new file mode 100644
index 0000000000..7e2bb9ac0b
--- /dev/null
+++ b/spec/lib/admin/metrics/dimension/servers_dimension_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Admin::Metrics::Dimension::ServersDimension do
+  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
+
+  let(:start_at) { 2.days.ago }
+  let(:end_at) { Time.now.utc }
+  let(:limit) { 10 }
+  let(:params) { ActionController::Parameters.new }
+
+  describe '#data' do
+    it 'runs data query without error' do
+      expect { dimension.data }.to_not raise_error
+    end
+  end
+end
diff --git a/spec/lib/admin/metrics/dimension/software_versions_dimension_spec.rb b/spec/lib/admin/metrics/dimension/software_versions_dimension_spec.rb
new file mode 100644
index 0000000000..ee14917330
--- /dev/null
+++ b/spec/lib/admin/metrics/dimension/software_versions_dimension_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Admin::Metrics::Dimension::SoftwareVersionsDimension do
+  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
+
+  let(:start_at) { 2.days.ago }
+  let(:end_at) { Time.now.utc }
+  let(:limit) { 10 }
+  let(:params) { ActionController::Parameters.new }
+
+  describe '#data' do
+    it 'runs data query without error' do
+      expect { dimension.data }.to_not raise_error
+    end
+  end
+end
diff --git a/spec/lib/admin/metrics/dimension/sources_dimension_spec.rb b/spec/lib/admin/metrics/dimension/sources_dimension_spec.rb
new file mode 100644
index 0000000000..d6b581a9bb
--- /dev/null
+++ b/spec/lib/admin/metrics/dimension/sources_dimension_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Admin::Metrics::Dimension::SourcesDimension do
+  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
+
+  let(:start_at) { 2.days.ago }
+  let(:end_at) { Time.now.utc }
+  let(:limit) { 10 }
+  let(:params) { ActionController::Parameters.new }
+
+  describe '#data' do
+    it 'runs data query without error' do
+      expect { dimension.data }.to_not raise_error
+    end
+  end
+end
diff --git a/spec/lib/admin/metrics/dimension/space_usage_dimension_spec.rb b/spec/lib/admin/metrics/dimension/space_usage_dimension_spec.rb
new file mode 100644
index 0000000000..65d04cfedd
--- /dev/null
+++ b/spec/lib/admin/metrics/dimension/space_usage_dimension_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Admin::Metrics::Dimension::SpaceUsageDimension do
+  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
+
+  let(:start_at) { 2.days.ago }
+  let(:end_at) { Time.now.utc }
+  let(:limit) { 10 }
+  let(:params) { ActionController::Parameters.new }
+
+  describe '#data' do
+    it 'runs data query without error' do
+      expect { dimension.data }.to_not raise_error
+    end
+  end
+end
diff --git a/spec/lib/admin/metrics/dimension/tag_languages_dimension_spec.rb b/spec/lib/admin/metrics/dimension/tag_languages_dimension_spec.rb
new file mode 100644
index 0000000000..721d24fa18
--- /dev/null
+++ b/spec/lib/admin/metrics/dimension/tag_languages_dimension_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Admin::Metrics::Dimension::TagLanguagesDimension do
+  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
+
+  let(:start_at) { 2.days.ago }
+  let(:end_at) { Time.now.utc }
+  let(:limit) { 10 }
+  let(:params) { ActionController::Parameters.new }
+
+  describe '#data' do
+    it 'runs data query without error' do
+      expect { dimension.data }.to_not raise_error
+    end
+  end
+end
diff --git a/spec/lib/admin/metrics/dimension/tag_servers_dimension_spec.rb b/spec/lib/admin/metrics/dimension/tag_servers_dimension_spec.rb
new file mode 100644
index 0000000000..3054716816
--- /dev/null
+++ b/spec/lib/admin/metrics/dimension/tag_servers_dimension_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Admin::Metrics::Dimension::TagServersDimension do
+  subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
+
+  let(:start_at) { 2.days.ago }
+  let(:end_at) { Time.now.utc }
+  let(:limit) { 10 }
+  let(:params) { ActionController::Parameters.new }
+
+  describe '#data' do
+    it 'runs data query without error' do
+      expect { dimension.data }.to_not raise_error
+    end
+  end
+end
diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb
index ccaa10dee9..0c2c1d3070 100644
--- a/spec/lib/feed_manager_spec.rb
+++ b/spec/lib/feed_manager_spec.rb
@@ -26,6 +26,7 @@ RSpec.describe FeedManager do
     let(:alice) { Fabricate(:account, username: 'alice') }
     let(:bob)   { Fabricate(:account, username: 'bob', domain: 'example.com') }
     let(:jeff)  { Fabricate(:account, username: 'jeff') }
+    let(:list) { Fabricate(:list, account: alice) }
 
     context 'with home feed' do
       it 'returns false for followee\'s status' do
@@ -160,6 +161,42 @@ RSpec.describe FeedManager do
         status = Fabricate(:status, text: 'Hallo Welt', account: bob, language: 'de')
         expect(FeedManager.instance.filter?(:home, status, alice)).to be false
       end
+
+      it 'returns true for post from followee on exclusive list' do
+        list.exclusive = true
+        alice.follow!(bob)
+        list.accounts << bob
+        allow(List).to receive(:where).and_return(list)
+        status = Fabricate(:status, text: 'I post a lot', account: bob)
+        expect(FeedManager.instance.filter?(:home, status, alice)).to be true
+      end
+
+      it 'returns true for reblog from followee on exclusive list' do
+        list.exclusive = true
+        alice.follow!(jeff)
+        list.accounts << jeff
+        allow(List).to receive(:where).and_return(list)
+        status = Fabricate(:status, text: 'I post a lot', account: bob)
+        reblog = Fabricate(:status, reblog: status, account: jeff)
+        expect(FeedManager.instance.filter?(:home, reblog, alice)).to be true
+      end
+
+      it 'returns false for post from followee on non-exclusive list' do
+        list.exclusive = false
+        alice.follow!(bob)
+        list.accounts << bob
+        status = Fabricate(:status, text: 'I post a lot', account: bob)
+        expect(FeedManager.instance.filter?(:home, status, alice)).to be false
+      end
+
+      it 'returns false for reblog from followee on non-exclusive list' do
+        list.exclusive = false
+        alice.follow!(jeff)
+        list.accounts << jeff
+        status = Fabricate(:status, text: 'I post a lot', account: bob)
+        reblog = Fabricate(:status, reblog: status, account: jeff)
+        expect(FeedManager.instance.filter?(:home, reblog, alice)).to be false
+      end
     end
 
     context 'with mentions feed' do
diff --git a/spec/lib/mastodon/cli/accounts_spec.rb b/spec/lib/mastodon/cli/accounts_spec.rb
index ba49e480ad..cf1d612f3d 100644
--- a/spec/lib/mastodon/cli/accounts_spec.rb
+++ b/spec/lib/mastodon/cli/accounts_spec.rb
@@ -998,4 +998,254 @@ describe Mastodon::CLI::Accounts do
       end
     end
   end
+
+  describe '#merge' do
+    shared_examples 'an account not found' do |acct|
+      it 'exits with an error message indicating that there is no such account' do
+        expect { cli.invoke(:merge, arguments) }.to output(
+          a_string_including("No such account (#{acct})")
+        ).to_stdout
+          .and raise_error(SystemExit)
+      end
+    end
+
+    context 'when "from_account" is not found' do
+      let(:to_account) { Fabricate(:account, domain: 'example.com') }
+      let(:arguments)  { ['non_existent_username@domain.com', "#{to_account.username}@#{to_account.domain}"] }
+
+      it_behaves_like 'an account not found', 'non_existent_username@domain.com'
+    end
+
+    context 'when "from_account" is a local account' do
+      let(:from_account) { Fabricate(:account, domain: nil, username: 'bob') }
+      let(:to_account)   { Fabricate(:account, domain: 'example.com') }
+      let(:arguments)    { [from_account.username, "#{to_account.username}@#{to_account.domain}"] }
+
+      it_behaves_like 'an account not found', 'bob'
+    end
+
+    context 'when "to_account" is not found' do
+      let(:from_account) { Fabricate(:account, domain: 'example.com') }
+      let(:arguments)    { ["#{from_account.username}@#{from_account.domain}", 'non_existent_username'] }
+
+      it_behaves_like 'an account not found', 'non_existent_username'
+    end
+
+    context 'when "to_account" is local' do
+      let(:from_account) { Fabricate(:account, domain: 'example.com') }
+      let(:to_account)   { Fabricate(:account, domain: nil, username: 'bob') }
+      let(:arguments) do
+        ["#{from_account.username}@#{from_account.domain}", "#{to_account.username}@#{to_account.domain}"]
+      end
+
+      it_behaves_like 'an account not found', 'bob@'
+    end
+
+    context 'when "from_account" and "to_account" public keys do not match' do
+      let(:from_account) { instance_double(Account, username: 'bob', domain: 'example1.com', local?: false, public_key: 'from_account') }
+      let(:to_account)   { instance_double(Account, username: 'bob', domain: 'example2.com', local?: false, public_key: 'to_account') }
+      let(:arguments) do
+        ["#{from_account.username}@#{from_account.domain}", "#{to_account.username}@#{to_account.domain}"]
+      end
+
+      before do
+        allow(Account).to receive(:find_remote).with(from_account.username, from_account.domain).and_return(from_account)
+        allow(Account).to receive(:find_remote).with(to_account.username, to_account.domain).and_return(to_account)
+      end
+
+      it 'exits with an error message indicating that the accounts do not have the same pub key' do
+        expect { cli.invoke(:merge, arguments) }.to output(
+          a_string_including("Accounts don't have the same public key, might not be duplicates!\nOverride with --force")
+        ).to_stdout
+          .and raise_error(SystemExit)
+      end
+
+      context 'with --force option' do
+        let(:options) { { force: true } }
+
+        before do
+          allow(to_account).to receive(:merge_with!)
+          allow(from_account).to receive(:destroy)
+        end
+
+        it 'merges "from_account" into "to_account"' do
+          cli.invoke(:merge, arguments, options)
+
+          expect(to_account).to have_received(:merge_with!).with(from_account).once
+        end
+
+        it 'deletes "from_account"' do
+          cli.invoke(:merge, arguments, options)
+
+          expect(from_account).to have_received(:destroy).once
+        end
+      end
+    end
+
+    context 'when "from_account" and "to_account" public keys match' do
+      let(:from_account) { instance_double(Account, username: 'bob', domain: 'example1.com', local?: false, public_key: 'pub_key') }
+      let(:to_account)   { instance_double(Account, username: 'bob', domain: 'example2.com', local?: false, public_key: 'pub_key') }
+      let(:arguments) do
+        ["#{from_account.username}@#{from_account.domain}", "#{to_account.username}@#{to_account.domain}"]
+      end
+
+      before do
+        allow(Account).to receive(:find_remote).with(from_account.username, from_account.domain).and_return(from_account)
+        allow(Account).to receive(:find_remote).with(to_account.username, to_account.domain).and_return(to_account)
+        allow(to_account).to receive(:merge_with!)
+        allow(from_account).to receive(:destroy)
+      end
+
+      it 'merges "from_account" into "to_account"' do
+        cli.invoke(:merge, arguments)
+
+        expect(to_account).to have_received(:merge_with!).with(from_account).once
+      end
+
+      it 'deletes "from_account"' do
+        cli.invoke(:merge, arguments)
+
+        expect(from_account).to have_received(:destroy)
+      end
+    end
+  end
+
+  describe '#cull' do
+    let(:delete_account_service) { instance_double(DeleteAccountService, call: nil) }
+    let!(:tom)                   { Fabricate(:account, updated_at: 30.days.ago, username: 'tom', uri: 'https://example.com/users/tom', domain: 'example.com') }
+    let!(:bob)                   { Fabricate(:account, updated_at: 30.days.ago, last_webfingered_at: nil, username: 'bob', uri: 'https://example.org/users/bob', domain: 'example.org') }
+    let!(:gon)                   { Fabricate(:account, updated_at: 15.days.ago, last_webfingered_at: 15.days.ago, username: 'gon', uri: 'https://example.net/users/gon', domain: 'example.net') }
+    let!(:ana)                   { Fabricate(:account, username: 'ana', uri: 'https://example.com/users/ana', domain: 'example.com') }
+    let!(:tales)                 { Fabricate(:account, updated_at: 10.days.ago, last_webfingered_at: nil, username: 'tales', uri: 'https://example.net/users/tales', domain: 'example.net') }
+
+    before do
+      allow(DeleteAccountService).to receive(:new).and_return(delete_account_service)
+    end
+
+    context 'when no domain is specified' do
+      let(:scope) { Account.remote.where(protocol: :activitypub).partitioned }
+
+      before do
+        allow(cli).to receive(:parallelize_with_progress).and_yield(tom)
+                                                         .and_yield(bob)
+                                                         .and_yield(gon)
+                                                         .and_yield(ana)
+                                                         .and_yield(tales)
+                                                         .and_return([5, 3])
+        stub_request(:head, 'https://example.org/users/bob').to_return(status: 404)
+        stub_request(:head, 'https://example.net/users/gon').to_return(status: 410)
+        stub_request(:head, 'https://example.net/users/tales').to_return(status: 200)
+      end
+
+      it 'deletes all inactive remote accounts that longer exist in the origin server' do
+        cli.cull
+
+        expect(cli).to have_received(:parallelize_with_progress).with(scope).once
+        expect(delete_account_service).to have_received(:call).with(bob, reserve_username: false).once
+        expect(delete_account_service).to have_received(:call).with(gon, reserve_username: false).once
+      end
+
+      it 'does not delete any active remote account that still exists in the origin server' do
+        cli.cull
+
+        expect(cli).to have_received(:parallelize_with_progress).with(scope).once
+        expect(delete_account_service).to_not have_received(:call).with(tom, reserve_username: false)
+        expect(delete_account_service).to_not have_received(:call).with(ana, reserve_username: false)
+        expect(delete_account_service).to_not have_received(:call).with(tales, reserve_username: false)
+      end
+
+      it 'touches inactive remote accounts that have not been deleted' do
+        allow(tales).to receive(:touch)
+
+        cli.cull
+
+        expect(tales).to have_received(:touch).once
+      end
+
+      it 'displays the summary correctly' do
+        expect { cli.cull }.to output(
+          a_string_including('Visited 5 accounts, removed 3')
+        ).to_stdout
+      end
+    end
+
+    context 'when a domain is specified' do
+      let(:domain) { 'example.net' }
+      let(:scope)  { Account.remote.where(protocol: :activitypub, domain: domain).partitioned }
+
+      before do
+        allow(cli).to receive(:parallelize_with_progress).and_yield(gon)
+                                                         .and_yield(tales)
+                                                         .and_return([2, 2])
+        stub_request(:head, 'https://example.net/users/gon').to_return(status: 410)
+        stub_request(:head, 'https://example.net/users/tales').to_return(status: 404)
+      end
+
+      it 'deletes inactive remote accounts that longer exist in the specified domain' do
+        cli.cull(domain)
+
+        expect(cli).to have_received(:parallelize_with_progress).with(scope).once
+        expect(delete_account_service).to have_received(:call).with(gon, reserve_username: false).once
+        expect(delete_account_service).to have_received(:call).with(tales, reserve_username: false).once
+      end
+
+      it 'displays the summary correctly' do
+        expect { cli.cull }.to output(
+          a_string_including('Visited 2 accounts, removed 2')
+        ).to_stdout
+      end
+    end
+
+    context 'when a domain is unavailable' do
+      shared_examples 'an unavailable domain' do
+        before do
+          allow(cli).to receive(:parallelize_with_progress).and_yield(tales).and_return([1, 0])
+        end
+
+        it 'skips accounts from the unavailable domain' do
+          cli.cull
+
+          expect(delete_account_service).to_not have_received(:call).with(tales, reserve_username: false)
+        end
+
+        it 'displays the summary correctly' do
+          expect { cli.cull }.to output(
+            a_string_including("Visited 1 accounts, removed 0\nThe following domains were not available during the check:\n    example.net")
+          ).to_stdout
+        end
+      end
+
+      context 'when a connection timeout occurs' do
+        before do
+          stub_request(:head, 'https://example.net/users/tales').to_timeout
+        end
+
+        it_behaves_like 'an unavailable domain'
+      end
+
+      context 'when a connection error occurs' do
+        before do
+          stub_request(:head, 'https://example.net/users/tales').to_raise(HTTP::ConnectionError)
+        end
+
+        it_behaves_like 'an unavailable domain'
+      end
+
+      context 'when an ssl error occurs' do
+        before do
+          stub_request(:head, 'https://example.net/users/tales').to_raise(OpenSSL::SSL::SSLError)
+        end
+
+        it_behaves_like 'an unavailable domain'
+      end
+
+      context 'when a private network address error occurs' do
+        before do
+          stub_request(:head, 'https://example.net/users/tales').to_raise(Mastodon::PrivateNetworkAddressError)
+        end
+
+        it_behaves_like 'an unavailable domain'
+      end
+    end
+  end
 end
diff --git a/spec/lib/mastodon/cli/canonical_email_blocks_spec.rb b/spec/lib/mastodon/cli/canonical_email_blocks_spec.rb
index fb481e8a82..eb57a3cd15 100644
--- a/spec/lib/mastodon/cli/canonical_email_blocks_spec.rb
+++ b/spec/lib/mastodon/cli/canonical_email_blocks_spec.rb
@@ -4,9 +4,57 @@ require 'rails_helper'
 require 'mastodon/cli/canonical_email_blocks'
 
 describe Mastodon::CLI::CanonicalEmailBlocks do
+  let(:cli) { described_class.new }
+
   describe '.exit_on_failure?' do
     it 'returns true' do
       expect(described_class.exit_on_failure?).to be true
     end
   end
+
+  describe '#find' do
+    let(:arguments) { ['user@example.com'] }
+
+    context 'when a block is present' do
+      before { Fabricate(:canonical_email_block, email: 'user@example.com') }
+
+      it 'announces the presence of the block' do
+        expect { cli.invoke(:find, arguments) }.to output(
+          a_string_including('user@example.com is blocked')
+        ).to_stdout
+      end
+    end
+
+    context 'when a block is not present' do
+      it 'announces the absence of the block' do
+        expect { cli.invoke(:find, arguments) }.to output(
+          a_string_including('user@example.com is not blocked')
+        ).to_stdout
+      end
+    end
+  end
+
+  describe '#remove' do
+    let(:arguments) { ['user@example.com'] }
+
+    context 'when a block is present' do
+      before { Fabricate(:canonical_email_block, email: 'user@example.com') }
+
+      it 'removes the block' do
+        expect { cli.invoke(:remove, arguments) }.to output(
+          a_string_including('Unblocked user@example.com')
+        ).to_stdout
+
+        expect(CanonicalEmailBlock.matching_email('user@example.com')).to be_empty
+      end
+    end
+
+    context 'when a block is not present' do
+      it 'announces the absence of the block' do
+        expect { cli.invoke(:remove, arguments) }.to output(
+          a_string_including('user@example.com is not blocked')
+        ).to_stdout
+      end
+    end
+  end
 end