From fcc4c9b34a6ab771c9cef6673e817866773e12d0 Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 16:20:52 +0100
Subject: [PATCH 01/24] Change domain block CSV parsing to be more robust and
 handle more lists (#21470)

* Change domain block CSV parsing to be more robust and handle more lists

* Add some tests

* Improve domain block import validation and reporting
---
 .../admin/export_domain_allows_controller.rb  |  4 +-
 .../admin/export_domain_blocks_controller.rb  | 24 ++++++----
 .../admin_export_controller_concern.rb        | 10 ----
 app/models/admin/import.rb                    | 47 +++++++++++++++----
 config/locales/en.yml                         |  1 +
 .../export_domain_blocks_controller_spec.rb   | 34 +++++++++++---
 spec/fixtures/files/domain_blocks.csv         |  6 +--
 spec/fixtures/files/domain_blocks_list.txt    |  3 ++
 8 files changed, 90 insertions(+), 39 deletions(-)
 create mode 100644 spec/fixtures/files/domain_blocks_list.txt

diff --git a/app/controllers/admin/export_domain_allows_controller.rb b/app/controllers/admin/export_domain_allows_controller.rb
index 57fb12c620..adfc39da21 100644
--- a/app/controllers/admin/export_domain_allows_controller.rb
+++ b/app/controllers/admin/export_domain_allows_controller.rb
@@ -23,9 +23,7 @@ module Admin
         @import = Admin::Import.new(import_params)
         return render :new unless @import.validate
 
-        parse_import_data!(export_headers)
-
-        @data.take(Admin::Import::ROWS_PROCESSING_LIMIT).each do |row|
+        @import.csv_rows.each do |row|
           domain = row['#domain'].strip
           next if DomainAllow.allowed?(domain)
 
diff --git a/app/controllers/admin/export_domain_blocks_controller.rb b/app/controllers/admin/export_domain_blocks_controller.rb
index fb0cd05d29..816422d4ff 100644
--- a/app/controllers/admin/export_domain_blocks_controller.rb
+++ b/app/controllers/admin/export_domain_blocks_controller.rb
@@ -23,24 +23,30 @@ module Admin
       @import = Admin::Import.new(import_params)
       return render :new unless @import.validate
 
-      parse_import_data!(export_headers)
-
       @global_private_comment = I18n.t('admin.export_domain_blocks.import.private_comment_template', source: @import.data_file_name, date: I18n.l(Time.now.utc))
 
       @form = Form::DomainBlockBatch.new
-      @domain_blocks = @data.take(Admin::Import::ROWS_PROCESSING_LIMIT).filter_map do |row|
+      @domain_blocks = @import.csv_rows.filter_map do |row|
         domain = row['#domain'].strip
         next if DomainBlock.rule_for(domain).present?
 
         domain_block = DomainBlock.new(domain: domain,
-                                       severity: row['#severity'].strip,
-                                       reject_media: row['#reject_media'].strip,
-                                       reject_reports: row['#reject_reports'].strip,
+                                       severity: row.fetch('#severity', :suspend),
+                                       reject_media: row.fetch('#reject_media', false),
+                                       reject_reports: row.fetch('#reject_reports', false),
                                        private_comment: @global_private_comment,
-                                       public_comment: row['#public_comment']&.strip,
-                                       obfuscate: row['#obfuscate'].strip)
+                                       public_comment: row['#public_comment'],
+                                       obfuscate: row.fetch('#obfuscate', false))
 
-        domain_block if domain_block.valid?
+        if domain_block.invalid?
+          flash.now[:alert] = I18n.t('admin.export_domain_blocks.invalid_domain_block', error: domain_block.errors.full_messages.join(', '))
+          next
+        end
+
+        domain_block
+      rescue ArgumentError => e
+        flash.now[:alert] = I18n.t('admin.export_domain_blocks.invalid_domain_block', error: e.message)
+        next
       end
 
       @warning_domains = Instance.where(domain: @domain_blocks.map(&:domain)).where('EXISTS (SELECT 1 FROM follows JOIN accounts ON follows.account_id = accounts.id OR follows.target_account_id = accounts.id WHERE accounts.domain = instances.domain)').pluck(:domain)
diff --git a/app/controllers/concerns/admin_export_controller_concern.rb b/app/controllers/concerns/admin_export_controller_concern.rb
index b40c76557f..4ac48a04b7 100644
--- a/app/controllers/concerns/admin_export_controller_concern.rb
+++ b/app/controllers/concerns/admin_export_controller_concern.rb
@@ -26,14 +26,4 @@ module AdminExportControllerConcern
   def import_params
     params.require(:admin_import).permit(:data)
   end
-
-  def import_data_path
-    params[:admin_import][:data].path
-  end
-
-  def parse_import_data!(default_headers)
-    data = CSV.read(import_data_path, headers: true, encoding: 'UTF-8')
-    data = CSV.read(import_data_path, headers: default_headers, encoding: 'UTF-8') unless data.headers&.first&.strip&.include?(default_headers[0])
-    @data = data.reject(&:blank?)
-  end
 end
diff --git a/app/models/admin/import.rb b/app/models/admin/import.rb
index 79c0722d53..fecde4878b 100644
--- a/app/models/admin/import.rb
+++ b/app/models/admin/import.rb
@@ -1,5 +1,7 @@
 # frozen_string_literal: true
 
+require 'csv'
+
 # A non-activerecord helper class for csv upload
 class Admin::Import
   include ActiveModel::Model
@@ -15,17 +17,46 @@ class Admin::Import
     data.original_filename
   end
 
+  def csv_rows
+    csv_data.rewind
+
+    csv_data.take(ROWS_PROCESSING_LIMIT + 1)
+  end
+
   private
 
+  def csv_data
+    return @csv_data if defined?(@csv_data)
+
+    csv_converter = lambda do |field, field_info|
+      case field_info.header
+      when '#domain', '#public_comment'
+        field&.strip
+      when '#severity'
+        field&.strip&.to_sym
+      when '#reject_media', '#reject_reports', '#obfuscate'
+        ActiveModel::Type::Boolean.new.cast(field)
+      else
+        field
+      end
+    end
+
+    @csv_data = CSV.open(data.path, encoding: 'UTF-8', skip_blanks: true, headers: true, converters: csv_converter)
+    @csv_data.take(1) # Ensure the headers are read
+    @csv_data = CSV.open(data.path, encoding: 'UTF-8', skip_blanks: true, headers: ['#domain'], converters: csv_converter) unless @csv_data.headers&.first == '#domain'
+    @csv_data
+  end
+
+  def csv_row_count
+    return @csv_row_count if defined?(@csv_row_count)
+
+    csv_data.rewind
+    @csv_row_count = csv_data.take(ROWS_PROCESSING_LIMIT + 2).count
+  end
+
   def validate_data
-    return if data.blank?
-
-    csv_data = CSV.read(data.path, encoding: 'UTF-8')
-
-    row_count  = csv_data.size
-    row_count -= 1 if csv_data.first&.first == '#domain'
-
-    errors.add(:data, I18n.t('imports.errors.over_rows_processing_limit', count: ROWS_PROCESSING_LIMIT)) if row_count > ROWS_PROCESSING_LIMIT
+    return if data.nil?
+    errors.add(:data, I18n.t('imports.errors.over_rows_processing_limit', count: ROWS_PROCESSING_LIMIT)) if csv_row_count > ROWS_PROCESSING_LIMIT
   rescue CSV::MalformedCSVError => e
     errors.add(:data, I18n.t('imports.errors.invalid_csv_file', error: e.message))
   end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 763110c770..4143aab045 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -441,6 +441,7 @@ en:
         private_comment_description_html: 'To help you track where imported blocks come from, imported blocks will be created with the following private comment: <q>%{comment}</q>'
         private_comment_template: Imported from %{source} on %{date}
         title: Import domain blocks
+      invalid_domain_block: 'One or more domain blocks were skipped because of the following error(s): %{error}'
       new:
         title: Import domain blocks
       no_file: No file selected
diff --git a/spec/controllers/admin/export_domain_blocks_controller_spec.rb b/spec/controllers/admin/export_domain_blocks_controller_spec.rb
index 8697e0c215..2766102c89 100644
--- a/spec/controllers/admin/export_domain_blocks_controller_spec.rb
+++ b/spec/controllers/admin/export_domain_blocks_controller_spec.rb
@@ -9,9 +9,9 @@ RSpec.describe Admin::ExportDomainBlocksController, type: :controller do
 
   describe 'GET #export' do
     it 'renders instances' do
-      Fabricate(:domain_block, domain: 'bad.domain', severity: 'silence', public_comment: 'bad')
-      Fabricate(:domain_block, domain: 'worse.domain', severity: 'suspend', reject_media: true, reject_reports: true, public_comment: 'worse', obfuscate: true)
-      Fabricate(:domain_block, domain: 'reject.media', severity: 'noop', reject_media: true, public_comment: 'reject media')
+      Fabricate(:domain_block, domain: 'bad.domain', severity: 'silence', public_comment: 'bad server')
+      Fabricate(:domain_block, domain: 'worse.domain', severity: 'suspend', reject_media: true, reject_reports: true, public_comment: 'worse server', obfuscate: true)
+      Fabricate(:domain_block, domain: 'reject.media', severity: 'noop', reject_media: true, public_comment: 'reject media and test unicode characters ♥')
       Fabricate(:domain_block, domain: 'no.op', severity: 'noop', public_comment: 'noop')
 
       get :export, params: { format: :csv }
@@ -21,10 +21,32 @@ RSpec.describe Admin::ExportDomainBlocksController, type: :controller do
   end
 
   describe 'POST #import' do
-    it 'blocks imported domains' do
-      post :import, params: { admin_import: { data: fixture_file_upload('domain_blocks.csv') } }
+    context 'with complete domain blocks CSV' do
+      before do
+        post :import, params: { admin_import: { data: fixture_file_upload('domain_blocks.csv') } }
+      end
 
-      expect(assigns(:domain_blocks).map(&:domain)).to match_array ['bad.domain', 'worse.domain', 'reject.media']
+      it 'renders page with expected domain blocks' do
+        expect(assigns(:domain_blocks).map { |block| [block.domain, block.severity.to_sym] }).to match_array [['bad.domain', :silence], ['worse.domain', :suspend], ['reject.media', :noop]]
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+    end
+
+    context 'with a list of only domains' do
+      before do
+        post :import, params: { admin_import: { data: fixture_file_upload('domain_blocks_list.txt') } }
+      end
+
+      it 'renders page with expected domain blocks' do
+        expect(assigns(:domain_blocks).map { |block| [block.domain, block.severity.to_sym] }).to match_array [['bad.domain', :suspend], ['worse.domain', :suspend], ['reject.media', :suspend]]
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
     end
   end
 
diff --git a/spec/fixtures/files/domain_blocks.csv b/spec/fixtures/files/domain_blocks.csv
index 28ffb91751..9dbfb4eaf7 100644
--- a/spec/fixtures/files/domain_blocks.csv
+++ b/spec/fixtures/files/domain_blocks.csv
@@ -1,4 +1,4 @@
 #domain,#severity,#reject_media,#reject_reports,#public_comment,#obfuscate
-bad.domain,silence,false,false,bad,false
-worse.domain,suspend,true,true,worse,true
-reject.media,noop,true,false,reject media,false
+bad.domain,silence,false,false,bad server,false
+worse.domain,suspend,true,true,worse server,true
+reject.media,noop,true,false,reject media and test unicode characters ♥,false
diff --git a/spec/fixtures/files/domain_blocks_list.txt b/spec/fixtures/files/domain_blocks_list.txt
new file mode 100644
index 0000000000..7b6b242533
--- /dev/null
+++ b/spec/fixtures/files/domain_blocks_list.txt
@@ -0,0 +1,3 @@
+bad.domain
+worse.domain
+reject.media

From 41517a484506796f09610b79c59f91723e2fd662 Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 16:21:48 +0100
Subject: [PATCH 02/24] Fix spurious admin dashboard warning when using
 ElasticSearch 7.x (#23064)

Some 7.x ElasticSearch versions support some 6.x nodes, thus the version check
is inadequate. I am not sure there is a good way to check if a server
implements all the 7.x APIs, so check server version and minimum wire version
instead.
---
 app/lib/admin/system_check/elasticsearch_check.rb | 11 ++++++++---
 1 file changed, 8 insertions(+), 3 deletions(-)

diff --git a/app/lib/admin/system_check/elasticsearch_check.rb b/app/lib/admin/system_check/elasticsearch_check.rb
index 7f922978f5..5b4c12399b 100644
--- a/app/lib/admin/system_check/elasticsearch_check.rb
+++ b/app/lib/admin/system_check/elasticsearch_check.rb
@@ -30,19 +30,24 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
 
   def running_version
     @running_version ||= begin
-      Chewy.client.info['version']['minimum_wire_compatibility_version'] ||
-        Chewy.client.info['version']['number']
+      Chewy.client.info['version']['number']
     rescue Faraday::ConnectionFailed
       nil
     end
   end
 
+  def compatible_wire_version
+    Chewy.client.info['version']['minimum_wire_compatibility_version']
+  end
+
   def required_version
     '7.x'
   end
 
   def compatible_version?
     return false if running_version.nil?
-    Gem::Version.new(running_version) >= Gem::Version.new(required_version)
+
+    Gem::Version.new(running_version) >= Gem::Version.new(required_version) ||
+      Gem::Version.new(compatible_wire_version) >= Gem::Version.new(required_version)
   end
 end

From d4f590d6bba173bb0861e9babc7830bdc57d55d6 Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 16:23:39 +0100
Subject: [PATCH 03/24] Fix scheduled_at input not using datetime-local when
 editing announcements (#21896)

---
 app/views/admin/announcements/edit.html.haml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/views/admin/announcements/edit.html.haml b/app/views/admin/announcements/edit.html.haml
index 66c8d31a79..c6c47586a0 100644
--- a/app/views/admin/announcements/edit.html.haml
+++ b/app/views/admin/announcements/edit.html.haml
@@ -19,7 +19,7 @@
 
   - unless @announcement.published?
     .fields-group
-      = f.input :scheduled_at, include_blank: true, wrapper: :with_block_label
+      = f.input :scheduled_at, include_blank: true, wrapper: :with_block_label, html5: true, input_html: { pattern: '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}(:[0-9]{2}){1,2}', placeholder: Time.now.strftime('%FT%R') }
 
   .actions
     = f.button :button, t('generic.save_changes'), type: :submit

From 0405be69d265b81a41be9c253e4b50aa6c8e1ee9 Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 16:25:31 +0100
Subject: [PATCH 04/24] Fix REST API serializer for Account not including
 `moved` when the moved account has itself moved (#22483)

Instead of cutting immediately, cut after one recursion.
---
 app/serializers/rest/account_serializer.rb | 14 ++++++++++++--
 1 file changed, 12 insertions(+), 2 deletions(-)

diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index e521dacaaa..6582b5bcf6 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -16,6 +16,16 @@ class REST::AccountSerializer < ActiveModel::Serializer
   attribute :silenced, key: :limited, if: :silenced?
   attribute :noindex, if: :local?
 
+  class AccountDecorator < SimpleDelegator
+    def self.model_name
+      Account.model_name
+    end
+
+    def moved?
+      false
+    end
+  end
+
   class FieldSerializer < ActiveModel::Serializer
     include FormattingHelper
 
@@ -85,7 +95,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
   end
 
   def moved_to_account
-    object.suspended? ? nil : object.moved_to_account
+    object.suspended? ? nil : AccountDecorator.new(object.moved_to_account)
   end
 
   def emojis
@@ -111,6 +121,6 @@ class REST::AccountSerializer < ActiveModel::Serializer
   delegate :suspended?, :silenced?, :local?, to: :object
 
   def moved_and_not_nested?
-    object.moved? && object.moved_to_account.moved_to_account_id.nil?
+    object.moved?
   end
 end

From b034dc42be2250f9b754fb88c9163b62d41f78f5 Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 16:28:18 +0100
Subject: [PATCH 05/24] Fix /api/v1/admin/trends/tags using wrong serializer
 (#18943)

* Fix /api/v1/admin/trends/tags using wrong serializer

Fix regression from #18641

* Only use `REST::Admin::TagSerializer` when the user can `manage_taxonomies`

* Fix admin trending hashtag component to not link if `id` is unknown
---
 app/controllers/api/v1/admin/trends/tags_controller.rb | 8 ++++++++
 app/javascript/mastodon/components/admin/Trends.js     | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/app/controllers/api/v1/admin/trends/tags_controller.rb b/app/controllers/api/v1/admin/trends/tags_controller.rb
index f3c0c4b6b4..e77df30216 100644
--- a/app/controllers/api/v1/admin/trends/tags_controller.rb
+++ b/app/controllers/api/v1/admin/trends/tags_controller.rb
@@ -3,6 +3,14 @@
 class Api::V1::Admin::Trends::TagsController < Api::V1::Trends::TagsController
   before_action -> { authorize_if_got_token! :'admin:read' }
 
+  def index
+    if current_user&.can?(:manage_taxonomies)
+      render json: @tags, each_serializer: REST::Admin::TagSerializer
+    else
+      super
+    end
+  end
+
   private
 
   def enabled?
diff --git a/app/javascript/mastodon/components/admin/Trends.js b/app/javascript/mastodon/components/admin/Trends.js
index 9530c2a5be..d01b8437ed 100644
--- a/app/javascript/mastodon/components/admin/Trends.js
+++ b/app/javascript/mastodon/components/admin/Trends.js
@@ -50,7 +50,7 @@ export default class Trends extends React.PureComponent {
             <Hashtag
               key={hashtag.name}
               name={hashtag.name}
-              to={`/admin/tags/${hashtag.id}`}
+              to={hashtag.id === undefined ? undefined : `/admin/tags/${hashtag.id}`}
               people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
               uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
               history={hashtag.history.reverse().map(day => day.uses)}

From 1b2ef60cec381e69384c208589c3dcf0ca2661db Mon Sep 17 00:00:00 2001
From: Jeong Arm <kjwonmail@gmail.com>
Date: Thu, 19 Jan 2023 00:29:07 +0900
Subject: [PATCH 06/24] Make visible change for new post notification setting
 icon (#22541)

---
 app/javascript/mastodon/features/account/components/header.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 2481e4783e..f6004d1c4b 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -193,7 +193,7 @@ class Header extends ImmutablePureComponent {
     }
 
     if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
-      bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
+      bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
     }
 
     if (me !== account.get('id')) {

From 7e6ffa085f97dc4688e2655fe2447743ab807e44 Mon Sep 17 00:00:00 2001
From: Peter Simonsson <peter@simonsson.com>
Date: Wed, 18 Jan 2023 15:30:46 +0000
Subject: [PATCH 07/24] Add checkmark symbol to checkbox (#22795)

---
 app/javascript/styles/mastodon/components.scss | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index ff368faaaa..6a2fe4c0b8 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -423,7 +423,7 @@ body > [data-popper-placement] {
 
       &.active {
         border-color: $highlight-text-color;
-        background: $highlight-text-color;
+        background: $highlight-text-color url("data:image/svg+xml;utf8,<svg width='18' height='18' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M4.5 8.5L8 12l6-6' stroke='white' stroke-width='1.5'/></svg>") center center no-repeat;
       }
     }
   }

From 9b3e22c40d5a24ddfa0df42d8fe6e96a273e8afd Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 16:32:23 +0100
Subject: [PATCH 08/24] Change account moderation notes to make links clickable
 (#22553)

* Change account moderation notes to make links clickable

Fixes #22539

* Fix styling of account moderation note links
---
 app/javascript/styles/mastodon/admin.scss           | 9 +++++++++
 app/views/admin/report_notes/_report_note.html.haml | 2 +-
 2 files changed, 10 insertions(+), 1 deletion(-)

diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index 9c06e7a255..674fafbe95 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -1572,6 +1572,15 @@ a.sparkline {
           margin-bottom: 0;
         }
       }
+
+      a {
+        color: $highlight-text-color;
+        text-decoration: none;
+
+        &:hover {
+          text-decoration: underline;
+        }
+      }
     }
 
     &__actions {
diff --git a/app/views/admin/report_notes/_report_note.html.haml b/app/views/admin/report_notes/_report_note.html.haml
index 54c252ee89..64628989a6 100644
--- a/app/views/admin/report_notes/_report_note.html.haml
+++ b/app/views/admin/report_notes/_report_note.html.haml
@@ -8,7 +8,7 @@
       = l report_note.created_at.to_date
 
   .report-notes__item__content
-    = simple_format(h(report_note.content))
+    = linkify(report_note.content)
 
   - if can?(:destroy, report_note)
     .report-notes__item__actions

From d1387579b904542245646fc12eca99c97feccc63 Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 16:33:03 +0100
Subject: [PATCH 09/24] Fix situations in which instance actor can be set to a
 Mastodon-incompatible name (#22307)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* Validate internal actor

* Use “internal.actor” by default for the server actor username

* Fix instance actor username on the fly if it includes ':'

* Change actor name from internal.actor to mastodon.internal
---
 app/models/account.rb                         | 4 ++--
 app/models/concerns/account_finder_concern.rb | 6 ++++--
 db/seeds/02_instance_actor.rb                 | 2 +-
 3 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/app/models/account.rb b/app/models/account.rb
index b27fc748f9..262285a09e 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -84,8 +84,8 @@ class Account < ApplicationRecord
   validates :username, presence: true
   validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }
 
-  # Remote user validations
-  validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { !local? && will_save_change_to_username? }
+  # Remote user validations, also applies to internal actors
+  validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { (!local? || actor_type == 'Application') && will_save_change_to_username? }
 
   # Local user validations
   validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
diff --git a/app/models/concerns/account_finder_concern.rb b/app/models/concerns/account_finder_concern.rb
index e8b804934a..37c3b88959 100644
--- a/app/models/concerns/account_finder_concern.rb
+++ b/app/models/concerns/account_finder_concern.rb
@@ -13,9 +13,11 @@ module AccountFinderConcern
     end
 
     def representative
-      Account.find(-99).tap(&:ensure_keys!)
+      actor = Account.find(-99).tap(&:ensure_keys!)
+      actor.update!(username: 'mastodon.internal') if actor.username.include?(':')
+      actor
     rescue ActiveRecord::RecordNotFound
-      Account.create!(id: -99, actor_type: 'Application', locked: true, username: Rails.configuration.x.local_domain)
+      Account.create!(id: -99, actor_type: 'Application', locked: true, username: 'mastodon.internal')
     end
 
     def find_local(username)
diff --git a/db/seeds/02_instance_actor.rb b/db/seeds/02_instance_actor.rb
index 39186b2734..f9aa372f1c 100644
--- a/db/seeds/02_instance_actor.rb
+++ b/db/seeds/02_instance_actor.rb
@@ -1 +1 @@
-Account.create_with(actor_type: 'Application', locked: true, username: ENV['LOCAL_DOMAIN'] || Rails.configuration.x.local_domain).find_or_create_by(id: -99)
+Account.create_with(actor_type: 'Application', locked: true, username: 'mastodon.internal').find_or_create_by(id: -99)

From 4b92e59f4fea4486ee6e5af7421e7945d5f7f998 Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 16:33:55 +0100
Subject: [PATCH 10/24] Add support for editing media description and focus
 point of already-posted statuses (#20878)

* Add backend support for editing media attachments of existing posts

* Allow editing media attachments of already-posted toots

* Add tests
---
 app/controllers/api/v1/statuses_controller.rb |  7 +++
 app/javascript/mastodon/actions/compose.js    | 46 ++++++++++++++++---
 .../features/compose/components/upload.js     |  4 +-
 .../ui/components/focal_point_modal.js        |  2 +-
 app/javascript/mastodon/reducers/compose.js   |  2 +-
 app/services/update_status_service.rb         | 11 ++++-
 spec/services/update_status_service_spec.rb   | 22 +++++++++
 7 files changed, 83 insertions(+), 11 deletions(-)

diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index 6290a1746a..9a8c0c1619 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -79,6 +79,7 @@ class Api::V1::StatusesController < Api::BaseController
       current_account.id,
       text: status_params[:status],
       media_ids: status_params[:media_ids],
+      media_attributes: status_params[:media_attributes],
       sensitive: status_params[:sensitive],
       language: status_params[:language],
       spoiler_text: status_params[:spoiler_text],
@@ -128,6 +129,12 @@ class Api::V1::StatusesController < Api::BaseController
       :language,
       :scheduled_at,
       media_ids: [],
+      media_attributes: [
+        :id,
+        :thumbnail,
+        :description,
+        :focus,
+      ],
       poll: [
         :multiple,
         :hide_totals,
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 531a5eb2b0..72e5929358 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -160,6 +160,18 @@ export function submitCompose(routerHistory) {
 
     dispatch(submitComposeRequest());
 
+    // If we're editing a post with media attachments, those have not
+    // necessarily been changed on the server. Do it now in the same
+    // API call.
+    let media_attributes;
+    if (statusId !== null) {
+      media_attributes = media.map(item => ({
+        id: item.get('id'),
+        description: item.get('description'),
+        focus: item.get('focus'),
+      }));
+    }
+
     api(getState).request({
       url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
       method: statusId === null ? 'post' : 'put',
@@ -167,6 +179,7 @@ export function submitCompose(routerHistory) {
         status,
         in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
         media_ids: media.map(item => item.get('id')),
+        media_attributes,
         sensitive: getState().getIn(['compose', 'sensitive']),
         spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
         visibility: getState().getIn(['compose', 'privacy']),
@@ -375,11 +388,31 @@ export function changeUploadCompose(id, params) {
   return (dispatch, getState) => {
     dispatch(changeUploadComposeRequest());
 
-    api(getState).put(`/api/v1/media/${id}`, params).then(response => {
-      dispatch(changeUploadComposeSuccess(response.data));
-    }).catch(error => {
-      dispatch(changeUploadComposeFail(id, error));
-    });
+    let media = getState().getIn(['compose', 'media_attachments']).find((item) => item.get('id') === id);
+
+    // Editing already-attached media is deferred to editing the post itself.
+    // For simplicity's sake, fake an API reply.
+    if (media && !media.get('unattached')) {
+      let { description, focus } = params;
+      const data = media.toJS();
+
+      if (description) {
+        data.description = description;
+      }
+
+      if (focus) {
+        focus = focus.split(',');
+        data.meta = { focus: { x: parseFloat(focus[0]), y: parseFloat(focus[1]) } };
+      }
+
+      dispatch(changeUploadComposeSuccess(data, true));
+    } else {
+      api(getState).put(`/api/v1/media/${id}`, params).then(response => {
+        dispatch(changeUploadComposeSuccess(response.data, false));
+      }).catch(error => {
+        dispatch(changeUploadComposeFail(id, error));
+      });
+    }
   };
 }
 
@@ -390,10 +423,11 @@ export function changeUploadComposeRequest() {
   };
 }
 
-export function changeUploadComposeSuccess(media) {
+export function changeUploadComposeSuccess(media, attached) {
   return {
     type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
     media: media,
+    attached: attached,
     skipLoading: true,
   };
 }
diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js
index b08307adee..af06ce1bf5 100644
--- a/app/javascript/mastodon/features/compose/components/upload.js
+++ b/app/javascript/mastodon/features/compose/components/upload.js
@@ -43,10 +43,10 @@ export default class Upload extends ImmutablePureComponent {
             <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
               <div className='compose-form__upload__actions'>
                 <button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
-                {!!media.get('unattached') && (<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>)}
+                <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
               </div>
 
-              {(media.get('description') || '').length === 0 && !!media.get('unattached') && (
+              {(media.get('description') || '').length === 0 && (
                 <div className='compose-form__upload__warning'>
                   <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
                 </div>
diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
index 479f4abd21..b9dbd93900 100644
--- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js
+++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
@@ -320,7 +320,7 @@ class FocalPointModal extends ImmutablePureComponent {
               <React.Fragment>
                 <label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
 
-                <Button disabled={isUploadingThumbnail} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
+                <Button disabled={isUploadingThumbnail || !media.get('unattached')} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
 
                 <label>
                   <span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 9ce7e97ed8..1760c7c891 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -444,7 +444,7 @@ export default function compose(state = initialState, action) {
       .setIn(['media_modal', 'dirty'], false)
       .update('media_attachments', list => list.map(item => {
         if (item.get('id') === action.media.id) {
-          return fromJS(action.media).set('unattached', true);
+          return fromJS(action.media).set('unattached', !action.attached);
         }
 
         return item;
diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb
index c4c9349761..f75fdf55d9 100644
--- a/app/services/update_status_service.rb
+++ b/app/services/update_status_service.rb
@@ -10,6 +10,7 @@ class UpdateStatusService < BaseService
   # @param [Integer] account_id
   # @param [Hash] options
   # @option options [Array<Integer>] :media_ids
+  # @option options [Array<Hash>] :media_attributes
   # @option options [Hash] :poll
   # @option options [String] :text
   # @option options [String] :spoiler_text
@@ -50,10 +51,18 @@ class UpdateStatusService < BaseService
     next_media_attachments     = validate_media!
     added_media_attachments    = next_media_attachments - previous_media_attachments
 
+    (@options[:media_attributes] || []).each do |attributes|
+      media = next_media_attachments.find { |attachment| attachment.id == attributes[:id].to_i }
+      next if media.nil?
+
+      media.update!(attributes.slice(:thumbnail, :description, :focus))
+      @media_attachments_changed ||= media.significantly_changed?
+    end
+
     MediaAttachment.where(id: added_media_attachments.map(&:id)).update_all(status_id: @status.id)
 
     @status.ordered_media_attachment_ids = (@options[:media_ids] || []).map(&:to_i) & next_media_attachments.map(&:id)
-    @media_attachments_changed = previous_media_attachments.map(&:id) != @status.ordered_media_attachment_ids
+    @media_attachments_changed ||= previous_media_attachments.map(&:id) != @status.ordered_media_attachment_ids
     @status.media_attachments.reload
   end
 
diff --git a/spec/services/update_status_service_spec.rb b/spec/services/update_status_service_spec.rb
index 71a73be5b0..16e981d2be 100644
--- a/spec/services/update_status_service_spec.rb
+++ b/spec/services/update_status_service_spec.rb
@@ -87,6 +87,28 @@ RSpec.describe UpdateStatusService, type: :service do
     end
   end
 
+  context 'when already-attached media changes' do
+    let!(:status) { Fabricate(:status, text: 'Foo') }
+    let!(:media_attachment) { Fabricate(:media_attachment, account: status.account, description: 'Old description') }
+
+    before do
+      status.media_attachments << media_attachment
+      subject.call(status, status.account_id, text: 'Foo', media_ids: [media_attachment.id], media_attributes: [{ id: media_attachment.id, description: 'New description' }])
+    end
+
+    it 'does not detach media attachment' do
+      expect(media_attachment.reload.status_id).to eq status.id
+    end
+
+    it 'updates the media attachment description' do
+      expect(media_attachment.reload.description).to eq 'New description'
+    end
+
+    it 'saves edit history' do
+      expect(status.edits.map { |edit| edit.ordered_media_attachments.map(&:description) }).to eq [['Old description'], ['New description']]
+    end
+  end
+
   context 'when poll changes' do
     let(:account) { Fabricate(:account) }
     let!(:status) { Fabricate(:status, text: 'Foo', account: account, poll_attributes: {options: %w(Foo Bar), account: account, multiple: false, hide_totals: false, expires_at: 7.days.from_now }) }

From 343e1fe8e9ce94ea4f86d3a3df71f22f5fb2319d Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 16:40:09 +0100
Subject: [PATCH 11/24] Add confirmation screen when handling reports (#22375)

* Add confirmation screen on moderation actions

* Add flash notice when a report has been processed

* Refactor tests

* Add tests
---
 .../admin/account_actions_controller.rb       |   2 +-
 .../admin/reports/actions_controller.rb       |  17 ++-
 app/models/admin/status_batch_action.rb       |   9 +-
 app/views/admin/reports/_actions.html.haml    |   2 +-
 .../admin/reports/actions/preview.html.haml   |  78 +++++++++++
 config/i18n-tasks.yml                         |   2 +
 config/locales/en.yml                         |  21 +++
 config/routes.rb                              |   6 +-
 .../admin/reports/actions_controller_spec.rb  | 132 +++++++++++++++---
 9 files changed, 240 insertions(+), 29 deletions(-)
 create mode 100644 app/views/admin/reports/actions/preview.html.haml

diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb
index 3f2e28b6ae..e89404b609 100644
--- a/app/controllers/admin/account_actions_controller.rb
+++ b/app/controllers/admin/account_actions_controller.rb
@@ -21,7 +21,7 @@ module Admin
       account_action.save!
 
       if account_action.with_report?
-        redirect_to admin_reports_path
+        redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: params[:report_id])
       else
         redirect_to admin_account_path(@account.id)
       end
diff --git a/app/controllers/admin/reports/actions_controller.rb b/app/controllers/admin/reports/actions_controller.rb
index 5cb5c744f2..554f7906f8 100644
--- a/app/controllers/admin/reports/actions_controller.rb
+++ b/app/controllers/admin/reports/actions_controller.rb
@@ -3,6 +3,11 @@
 class Admin::Reports::ActionsController < Admin::BaseController
   before_action :set_report
 
+  def preview
+    authorize @report, :show?
+    @moderation_action = action_from_button
+  end
+
   def create
     authorize @report, :show?
 
@@ -13,7 +18,8 @@ class Admin::Reports::ActionsController < Admin::BaseController
         status_ids: @report.status_ids,
         current_account: current_account,
         report_id: @report.id,
-        send_email_notification: !@report.spam?
+        send_email_notification: !@report.spam?,
+        text: params[:text]
       )
 
       status_batch_action.save!
@@ -23,13 +29,16 @@ class Admin::Reports::ActionsController < Admin::BaseController
         report_id: @report.id,
         target_account: @report.target_account,
         current_account: current_account,
-        send_email_notification: !@report.spam?
+        send_email_notification: !@report.spam?,
+        text: params[:text]
       )
 
       account_action.save!
+    else
+      return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
     end
 
-    redirect_to admin_reports_path
+    redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: @report.id)
   end
 
   private
@@ -47,6 +56,8 @@ class Admin::Reports::ActionsController < Admin::BaseController
       'silence'
     elsif params[:suspend]
       'suspend'
+    elsif params[:moderation_action]
+      params[:moderation_action]
     end
   end
 end
diff --git a/app/models/admin/status_batch_action.rb b/app/models/admin/status_batch_action.rb
index 39cd7d0eb8..b8bdec7223 100644
--- a/app/models/admin/status_batch_action.rb
+++ b/app/models/admin/status_batch_action.rb
@@ -6,7 +6,8 @@ class Admin::StatusBatchAction
   include Authorization
 
   attr_accessor :current_account, :type,
-                :status_ids, :report_id
+                :status_ids, :report_id,
+                :text
 
   attr_reader :send_email_notification
 
@@ -57,7 +58,8 @@ class Admin::StatusBatchAction
         action: :delete_statuses,
         account: current_account,
         report: report,
-        status_ids: status_ids
+        status_ids: status_ids,
+        text: text
       )
 
       statuses.each { |status| Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) } unless target_account.local?
@@ -95,7 +97,8 @@ class Admin::StatusBatchAction
       action: :mark_statuses_as_sensitive,
       account: current_account,
       report: report,
-      status_ids: status_ids
+      status_ids: status_ids,
+      text: text
     )
 
     UserMailer.warning(target_account.user, @warning).deliver_later! if warnable?
diff --git a/app/views/admin/reports/_actions.html.haml b/app/views/admin/reports/_actions.html.haml
index 486eb486c7..aad4416257 100644
--- a/app/views/admin/reports/_actions.html.haml
+++ b/app/views/admin/reports/_actions.html.haml
@@ -1,4 +1,4 @@
-= form_tag admin_report_actions_path(@report), method: :post do
+= form_tag preview_admin_report_actions_path(@report), method: :post do
   .report-actions
     .report-actions__item
       .report-actions__item__button
diff --git a/app/views/admin/reports/actions/preview.html.haml b/app/views/admin/reports/actions/preview.html.haml
new file mode 100644
index 0000000000..58745319c8
--- /dev/null
+++ b/app/views/admin/reports/actions/preview.html.haml
@@ -0,0 +1,78 @@
+- target_acct = @report.target_account.acct
+- warning_action = { 'delete' => 'delete_statuses', 'mark_as_sensitive' => 'mark_statuses_as_sensitive' }.fetch(@moderation_action, @moderation_action)
+
+- content_for :page_title do
+  = t('admin.reports.confirm_action', acct: target_acct)
+
+= form_tag admin_report_actions_path(@report), class: 'simple_form', method: :post do
+  = hidden_field_tag :moderation_action, @moderation_action
+
+  %p.hint= t("admin.reports.summary.action_preambles.#{@moderation_action}_html", acct: target_acct)
+  %ul.hint
+    %li.warning-hint= t("admin.reports.summary.actions.#{@moderation_action}_html", acct: target_acct)
+    - if @moderation_action == 'suspend'
+      %li.warning-hint= t('admin.reports.summary.delete_data_html', acct: target_acct)
+    - if %w(silence suspend).include?(@moderation_action)
+      %li.warning-hint= t('admin.reports.summary.close_reports_html', acct: target_acct)
+    - else
+      %li= t('admin.reports.summary.close_report', id: @report.id)
+    %li= t('admin.reports.summary.record_strike_html', acct: target_acct)
+    - if @report.target_account.local? && !@report.spam?
+      %li= t('admin.reports.summary.send_email_html', acct: target_acct)
+
+  %hr.spacer/
+
+  - if @report.target_account.local?
+    %p.hint= t('admin.reports.summary.preview_preamble_html', acct: target_acct)
+
+    .strike-card
+      - unless warning_action == 'none'
+        %p= t "user_mailer.warning.explanation.#{warning_action}", instance: Rails.configuration.x.local_domain
+
+      .fields-group
+        = text_area_tag :text, nil, placeholder: t('admin.reports.summary.warning_placeholder')
+
+      - if !@report.other?
+        %p
+          %strong= t('user_mailer.warning.reason')
+          = t("user_mailer.warning.categories.#{@report.category}")
+
+        - if @report.violation? && @report.rule_ids.present?
+          %ul.strike-card__rules
+            - @report.rules.each do |rule|
+              %li
+                %span.strike-card__rules__text= rule.text
+
+      - if @report.status_ids.present? && !@report.status_ids.empty?
+        %p
+          %strong= t('user_mailer.warning.statuses')
+
+        .strike-card__statuses-list
+          - status_map = @report.statuses.includes(:application, :media_attachments).index_by(&:id)
+
+          - @report.status_ids.each do |status_id|
+            .strike-card__statuses-list__item
+              - if (status = status_map[status_id.to_i])
+                .one-liner
+                  = link_to short_account_status_url(@report.target_account, status_id), class: 'emojify' do
+                    = one_line_preview(status)
+
+                    - status.ordered_media_attachments.each do |media_attachment|
+                      %abbr{ title: media_attachment.description }
+                        = fa_icon 'link'
+                        = media_attachment.file_file_name
+                .strike-card__statuses-list__item__meta
+                  %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
+                  - unless status.application.nil?
+                    ·
+                    = status.application.name
+              - else
+                .one-liner= t('disputes.strikes.status', id: status_id)
+                .strike-card__statuses-list__item__meta
+                  = t('disputes.strikes.status_removed')
+
+    %hr.spacer/
+
+  .actions
+    = link_to t('admin.reports.cancel'), admin_report_path(@report), class: 'button button-tertiary'
+    = button_tag t('admin.reports.confirm'), name: :confirm, class: 'button', type: :submit
diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml
index c1da42bd87..46dd3124bf 100644
--- a/config/i18n-tasks.yml
+++ b/config/i18n-tasks.yml
@@ -58,6 +58,8 @@ ignore_unused:
   - 'errors.429'
   - 'admin.accounts.roles.*'
   - 'admin.action_logs.actions.*'
+  - 'admin.reports.summary.action_preambles.*'
+  - 'admin.reports.summary.actions.*'
   - 'admin_mailer.new_appeal.actions.*'
   - 'statuses.attached.*'
   - 'move_handler.carry_{mutes,blocks}_over_text'
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 4143aab045..3de2f2772c 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -590,6 +590,7 @@ en:
       comment:
         none: None
       comment_description_html: 'To provide more information, %{name} wrote:'
+      confirm_action: Confirm moderation action against @%{acct}
       created_at: Reported
       delete_and_resolve: Delete posts
       forwarded: Forwarded
@@ -606,6 +607,7 @@ en:
         placeholder: Describe what actions have been taken, or any other related updates...
         title: Notes
       notes_description_html: View and leave notes to other moderators and your future self
+      processed_msg: 'Report #%{id} successfully processed'
       quick_actions_description_html: 'Take a quick action or scroll down to see reported content:'
       remote_user_placeholder: the remote user from %{instance}
       reopen: Reopen report
@@ -618,9 +620,28 @@ en:
       status: Status
       statuses: Reported content
       statuses_description_html: Offending content will be cited in communication with the reported account
+      summary:
+        action_preambles:
+          delete_html: 'You are about to <strong>remove</strong> some of <strong>@%{acct}</strong>''s posts. This will:'
+          mark_as_sensitive_html: 'You are about to <strong>mark</strong> some of <strong>@%{acct}</strong>''s posts as <strong>sensitive</strong>. This will:'
+          silence_html: 'You are about to <strong>limit</strong> <strong>@%{acct}</strong>''s account. This will:'
+          suspend_html: 'You are about to <strong>suspend</strong> <strong>@%{acct}</strong>''s account. This will:'
+        actions:
+          delete_html: Remove the offending posts
+          mark_as_sensitive_html: Mark the offending posts' media as sensitive
+          silence_html: Severely limit <strong>@%{acct}</strong>'s reach by making their profile and contents only visible to people already following them or manually looking it profile up
+          suspend_html: Suspend <strong>@%{acct}</strong>, making their profile and contents inaccessible and impossible to interact with
+        close_report: 'Mark report #%{id} as resolved'
+        close_reports_html: Mark <strong>all</strong> reports against <strong>@%{acct}</strong> as resolved
+        delete_data_html: Delete <strong>@%{acct}</strong>'s profile and contents 30 days from now unless they get unsuspended in the meantime
+        preview_preamble_html: "<strong>@%{acct}</strong> will receive a warning with the following contents:"
+        record_strike_html: Record a strike against <strong>@%{acct}</strong> to help you escalate on future violations from this account
+        send_email_html: Send <strong>@%{acct}</strong> a warning e-mail
+        warning_placeholder: Optional additional reasoning for the moderation action.
       target_origin: Origin of reported account
       title: Reports
       unassign: Unassign
+      unknown_action_msg: 'Unknown action: %{action}'
       unresolved: Unresolved
       updated_at: Updated
       view_profile: View profile
diff --git a/config/routes.rb b/config/routes.rb
index 98e19667cf..0bee2f639b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -310,7 +310,11 @@ Rails.application.routes.draw do
     end
 
     resources :reports, only: [:index, :show] do
-      resources :actions, only: [:create], controller: 'reports/actions'
+      resources :actions, only: [:create], controller: 'reports/actions' do
+        collection do
+          post :preview
+        end
+      end
 
       member do
         post :assign_to_self
diff --git a/spec/controllers/admin/reports/actions_controller_spec.rb b/spec/controllers/admin/reports/actions_controller_spec.rb
index 6609798dc0..9890ac9ce8 100644
--- a/spec/controllers/admin/reports/actions_controller_spec.rb
+++ b/spec/controllers/admin/reports/actions_controller_spec.rb
@@ -4,39 +4,131 @@ describe Admin::Reports::ActionsController do
   render_views
 
   let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
-  let(:account) { Fabricate(:account) }
-  let!(:status) { Fabricate(:status, account: account) }
-  let(:media_attached_status) { Fabricate(:status, account: account) }
-  let!(:media_attachment) { Fabricate(:media_attachment, account: account, status: media_attached_status) }
-  let(:media_attached_deleted_status) { Fabricate(:status, account: account, deleted_at: 1.day.ago) }
-  let!(:media_attachment2) { Fabricate(:media_attachment, account: account, status: media_attached_deleted_status) }
-  let(:last_media_attached_status) { Fabricate(:status, account: account) }
-  let!(:last_media_attachment) { Fabricate(:media_attachment, account: account, status: last_media_attached_status) }
-  let!(:last_status) { Fabricate(:status, account: account) }
 
   before do
     sign_in user, scope: :user
   end
 
-  describe 'POST #create' do
-    let(:report) { Fabricate(:report, status_ids: status_ids, account: user.account, target_account: account) }
-    let(:status_ids) { [media_attached_status.id, media_attached_deleted_status.id] }
+  describe 'POST #preview' do
+    let(:report) { Fabricate(:report) }
 
     before do
-      post :create, params: { report_id: report.id, action => '' }
+      post :preview, params: { report_id: report.id, action => '' }
     end
 
-    context 'when action is mark_as_sensitive' do
+    context 'when the action is "suspend"' do
+      let(:action) { 'suspend' }
 
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+    end
+
+    context 'when the action is "silence"' do
+      let(:action) { 'silence' }
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+    end
+
+    context 'when the action is "delete"' do
+      let(:action) { 'delete' }
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+    end
+
+    context 'when the action is "mark_as_sensitive"' do
       let(:action) { 'mark_as_sensitive' }
 
-      it 'resolves the report' do
-        expect(report.reload.action_taken_at).to_not be_nil
-      end
-
-      it 'marks the non-deleted as sensitive' do
-        expect(media_attached_status.reload.sensitive).to eq true
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
       end
     end
   end
+
+  describe 'POST #create' do
+    let(:target_account) { Fabricate(:account) }
+    let(:statuses)       { [Fabricate(:status, account: target_account), Fabricate(:status, account: target_account)] }
+    let!(:media)         { Fabricate(:media_attachment, account: target_account, status: statuses[0]) }
+    let(:report)         { Fabricate(:report, target_account: target_account, status_ids: statuses.map(&:id)) }
+    let(:text)           { 'hello' }
+
+    shared_examples 'common behavior' do
+      it 'closes the report' do
+        expect { subject }.to change { report.reload.action_taken? }.from(false).to(true)
+      end
+
+      it 'creates a strike with the expected text' do
+        expect { subject }.to change { report.target_account.strikes.count }.by(1)
+        expect(report.target_account.strikes.last.text).to eq text
+      end
+
+      it 'redirects' do
+        subject
+        expect(response).to redirect_to(admin_reports_path)
+      end
+    end
+
+    shared_examples 'all action types' do
+      context 'when the action is "suspend"' do
+        let(:action) { 'suspend' }
+
+        it_behaves_like 'common behavior'
+
+        it 'suspends the target account' do
+          expect { subject }.to change { report.target_account.reload.suspended? }.from(false).to(true)
+        end
+      end
+
+      context 'when the action is "silence"' do
+        let(:action) { 'silence' }
+
+        it_behaves_like 'common behavior'
+
+        it 'suspends the target account' do
+          expect { subject }.to change { report.target_account.reload.silenced? }.from(false).to(true)
+        end
+      end
+
+      context 'when the action is "delete"' do
+        let(:action) { 'delete' }
+
+        it_behaves_like 'common behavior'
+      end
+
+      context 'when the action is "mark_as_sensitive"' do
+        let(:action) { 'mark_as_sensitive' }
+        let(:statuses) { [media_attached_status, media_attached_deleted_status] }
+
+        let!(:status) { Fabricate(:status, account: target_account) }
+        let(:media_attached_status) { Fabricate(:status, account: target_account) }
+        let!(:media_attachment) { Fabricate(:media_attachment, account: target_account, status: media_attached_status) }
+        let(:media_attached_deleted_status) { Fabricate(:status, account: target_account, deleted_at: 1.day.ago) }
+        let!(:media_attachment2) { Fabricate(:media_attachment, account: target_account, status: media_attached_deleted_status) }
+        let(:last_media_attached_status) { Fabricate(:status, account: target_account) }
+        let!(:last_media_attachment) { Fabricate(:media_attachment, account: target_account, status: last_media_attached_status) }
+        let!(:last_status) { Fabricate(:status, account: target_account) }
+
+        it_behaves_like 'common behavior'
+
+        it 'marks the non-deleted as sensitive' do
+          subject
+          expect(media_attached_status.reload.sensitive).to eq true
+        end
+      end
+    end
+
+    context 'action as submit button' do
+      subject { post :create, params: { report_id: report.id, text: text, action => '' } }
+      it_behaves_like 'all action types'
+    end
+
+    context 'action as submit button' do
+      subject { post :create, params: { report_id: report.id, text: text, moderation_action: action } }
+      it_behaves_like 'all action types'
+    end
+  end
 end

From 3970a6f433ad3cf79ef41d84baf6d788d25bd246 Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 16:43:58 +0100
Subject: [PATCH 12/24] Add option to make the landing page be /about even when
 trends are enabled (#20808)

* Add option to make the landing page be /about even when trends are enabled

* Restablish /explore as landing page by default
---
 app/javascript/mastodon/features/ui/index.js      | 4 ++--
 app/javascript/mastodon/initial_state.js          | 2 ++
 app/models/form/admin_settings.rb                 | 2 ++
 app/serializers/initial_state_serializer.rb       | 1 +
 app/views/admin/settings/discovery/show.html.haml | 3 +++
 config/locales/simple_form.en.yml                 | 2 ++
 config/settings.yml                               | 1 +
 7 files changed, 13 insertions(+), 2 deletions(-)

diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index b059566061..3fbe03fdf5 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -54,7 +54,7 @@ import {
   About,
   PrivacyPolicy,
 } from './util/async-components';
-import initialState, { me, owner, singleUserMode, showTrends } from '../../initial_state';
+import initialState, { me, owner, singleUserMode, showTrends, trendsAsLanding } from '../../initial_state';
 import { closeOnboarding, INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
 import Header from './components/header';
 
@@ -163,7 +163,7 @@ class SwitchingColumnsArea extends React.PureComponent {
       }
     } else if (singleUserMode && owner && initialState?.accounts[owner]) {
       redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
-    } else if (showTrends) {
+    } else if (showTrends && trendsAsLanding) {
       redirect = <Redirect from='/' to='/explore' exact />;
     } else {
       redirect = <Redirect from='/' to='/about' exact />;
diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js
index 62fd4ac720..5bb8546eb4 100644
--- a/app/javascript/mastodon/initial_state.js
+++ b/app/javascript/mastodon/initial_state.js
@@ -75,6 +75,7 @@
  * @property {boolean} timeline_preview
  * @property {string} title
  * @property {boolean} trends
+ * @property {boolean} trends_as_landing_page
  * @property {boolean} unfollow_modal
  * @property {boolean} use_blurhash
  * @property {boolean=} use_pending_items
@@ -126,6 +127,7 @@ export const singleUserMode = getMeta('single_user_mode');
 export const source_url = getMeta('source_url');
 export const timelinePreview = getMeta('timeline_preview');
 export const title = getMeta('title');
+export const trendsAsLanding = getMeta('trends_as_landing_page');
 export const unfollowModal = getMeta('unfollow_modal');
 export const useBlurhash = getMeta('use_blurhash');
 export const usePendingItems = getMeta('use_pending_items');
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index dc6cc5ed34..132b57b04a 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -23,6 +23,7 @@ class Form::AdminSettings
     thumbnail
     mascot
     trends
+    trends_as_landing_page
     trendable_by_default
     show_domain_blocks
     show_domain_blocks_rationale
@@ -46,6 +47,7 @@ class Form::AdminSettings
     preview_sensitive_media
     profile_directory
     trends
+    trends_as_landing_page
     trendable_by_default
     noindex
     require_invite_text
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 70f40088dc..1bd62c26f6 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -32,6 +32,7 @@ class InitialStateSerializer < ActiveModel::Serializer
       activity_api_enabled: Setting.activity_api_enabled,
       single_user_mode: Rails.configuration.x.single_user_mode,
       translation_enabled: TranslationService.configured?,
+      trends_as_landing_page: Setting.trends_as_landing_page,
     }
 
     if object.current_account
diff --git a/app/views/admin/settings/discovery/show.html.haml b/app/views/admin/settings/discovery/show.html.haml
index 59188833bd..759bbdcebe 100644
--- a/app/views/admin/settings/discovery/show.html.haml
+++ b/app/views/admin/settings/discovery/show.html.haml
@@ -18,6 +18,9 @@
   .fields-group
     = f.input :trends, as: :boolean, wrapper: :with_label
 
+  .fields-group
+    = f.input :trends_as_landing_page, as: :boolean, wrapper: :with_label
+
   .fields-group
     = f.input :trendable_by_default, as: :boolean, wrapper: :with_label, recommended: :not_recommended
 
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index f66e12c4c1..d01f0ae753 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -96,6 +96,7 @@ en:
         timeline_preview: Logged out visitors will be able to browse the most recent public posts available on the server.
         trendable_by_default: Skip manual review of trending content. Individual items can still be removed from trends after the fact.
         trends: Trends show which posts, hashtags and news stories are gaining traction on your server.
+        trends_as_landing_page: Show trending content to logged-out users and visitors instead of a description of this server. Requires trends to be enabled.
       form_challenge:
         current_password: You are entering a secure area
       imports:
@@ -256,6 +257,7 @@ en:
         timeline_preview: Allow unauthenticated access to public timelines
         trendable_by_default: Allow trends without prior review
         trends: Enable trends
+        trends_as_landing_page: Use trends as the landing page
       interactions:
         must_be_follower: Block notifications from non-followers
         must_be_following: Block notifications from people you don't follow
diff --git a/config/settings.yml b/config/settings.yml
index ec8fead0f5..f0b09dd5c8 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -35,6 +35,7 @@ defaults: &defaults
   use_blurhash: true
   use_pending_items: false
   trends: true
+  trends_as_landing_page: true
   trendable_by_default: false
   crop_images: true
   notification_emails:

From 30e895299ceff095da3e4c8ee50b3d003225f021 Mon Sep 17 00:00:00 2001
From: Connor Shea <connor.james.shea@gmail.com>
Date: Wed, 18 Jan 2023 08:44:33 -0700
Subject: [PATCH 13/24] Add listing of followed hashtags (#21773)

* Add followed_tags route.

This at least gets us to the point where the page can actually be
rendered, although it doesn't display any hashtags (yet?).

Attempting to implement #20763.

* Fix minor issues.

* I've got the followed tags data partially working

But the Hashtag component errors for some reason. Something about the
value of the history attribute being invalid.

* Fix a mistake in the code

* Minor change.

* Get the followed hashtags list fully working.

Still need to add the Follow/Unfollow buttons, though.

* Resolve JS linter issues.

* Add pagination logic to followed tags list view.

However, it currently loads further pages immediately on page load, so
that's not ideal. Need to figure that one out.

* Appease the linter.

* Apply suggestions from code review

Co-authored-by: Claire <claire.github-309c@sitedethib.com>

* Fixes and resolve some other feedback.

* Use set/update instead of setIn/updateIn.

Co-authored-by: Claire <claire.github-309c@sitedethib.com>
---
 app/javascript/mastodon/actions/tags.js       | 82 ++++++++++++++++-
 .../features/account/components/header.js     |  2 +
 .../features/compose/components/action_bar.js |  2 +
 .../mastodon/features/followed_tags/index.js  | 89 +++++++++++++++++++
 app/javascript/mastodon/features/ui/index.js  |  2 +
 .../features/ui/util/async-components.js      |  4 +
 .../mastodon/locales/defaultMessages.json     |  6 +-
 app/javascript/mastodon/locales/en.json       |  1 +
 .../mastodon/reducers/followed_tags.js        | 42 +++++++++
 app/javascript/mastodon/reducers/index.js     |  2 +
 config/routes.rb                              |  1 +
 11 files changed, 231 insertions(+), 2 deletions(-)
 create mode 100644 app/javascript/mastodon/features/followed_tags/index.js
 create mode 100644 app/javascript/mastodon/reducers/followed_tags.js

diff --git a/app/javascript/mastodon/actions/tags.js b/app/javascript/mastodon/actions/tags.js
index 37e79d4cba..08a08cda31 100644
--- a/app/javascript/mastodon/actions/tags.js
+++ b/app/javascript/mastodon/actions/tags.js
@@ -1,9 +1,17 @@
-import api from '../api';
+import api, { getLinks } from '../api';
 
 export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
 export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
 export const HASHTAG_FETCH_FAIL    = 'HASHTAG_FETCH_FAIL';
 
+export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
+export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
+export const FOLLOWED_HASHTAGS_FETCH_FAIL    = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
+
+export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
+export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
+export const FOLLOWED_HASHTAGS_EXPAND_FAIL    = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
+
 export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
 export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
 export const HASHTAG_FOLLOW_FAIL    = 'HASHTAG_FOLLOW_FAIL';
@@ -37,6 +45,78 @@ export const fetchHashtagFail = error => ({
   error,
 });
 
+export const fetchFollowedHashtags = () => (dispatch, getState) => {
+  dispatch(fetchFollowedHashtagsRequest());
+
+  api(getState).get('/api/v1/followed_tags').then(response => {
+    const next = getLinks(response).refs.find(link => link.rel === 'next');
+    dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
+  }).catch(err => {
+    dispatch(fetchFollowedHashtagsFail(err));
+  });
+};
+
+export function fetchFollowedHashtagsRequest() {
+  return {
+    type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
+  };
+};
+
+export function fetchFollowedHashtagsSuccess(followed_tags, next) {
+  return {
+    type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
+    followed_tags,
+    next,
+  };
+};
+
+export function fetchFollowedHashtagsFail(error) {
+  return {
+    type: FOLLOWED_HASHTAGS_FETCH_FAIL,
+    error,
+  };
+};
+
+export function expandFollowedHashtags() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['followed_tags', 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFollowedHashtagsRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(expandFollowedHashtagsFail(error));
+    });
+  };
+};
+
+export function expandFollowedHashtagsRequest() {
+  return {
+    type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
+  };
+};
+
+export function expandFollowedHashtagsSuccess(followed_tags, next) {
+  return {
+    type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
+    followed_tags,
+    next,
+  };
+};
+
+export function expandFollowedHashtagsFail(error) {
+  return {
+    type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
+    error,
+  };
+};
+
 export const followHashtag = name => (dispatch, getState) => {
   dispatch(followHashtagRequest(name));
 
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index f6004d1c4b..46fb89f2fa 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -46,6 +46,7 @@ const messages = defineMessages({
   follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
   favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
   lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
   blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
   domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
   mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
@@ -242,6 +243,7 @@ class Header extends ImmutablePureComponent {
       menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
       menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
       menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
+      menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
       menu.push(null);
       menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
       menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
diff --git a/app/javascript/mastodon/features/compose/components/action_bar.js b/app/javascript/mastodon/features/compose/components/action_bar.js
index ceed928bf5..90c85321e5 100644
--- a/app/javascript/mastodon/features/compose/components/action_bar.js
+++ b/app/javascript/mastodon/features/compose/components/action_bar.js
@@ -11,6 +11,7 @@ const messages = defineMessages({
   follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
   favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
   lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
   blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
   domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
   mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
@@ -45,6 +46,7 @@ class ActionBar extends React.PureComponent {
     menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
     menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
     menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
+    menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
     menu.push(null);
     menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
     menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
diff --git a/app/javascript/mastodon/features/followed_tags/index.js b/app/javascript/mastodon/features/followed_tags/index.js
new file mode 100644
index 0000000000..0a62ca76d0
--- /dev/null
+++ b/app/javascript/mastodon/features/followed_tags/index.js
@@ -0,0 +1,89 @@
+import { debounce } from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import ColumnHeader from 'mastodon/components/column_header';
+import ScrollableList from 'mastodon/components/scrollable_list';
+import Column from 'mastodon/features/ui/components/column';
+import { Helmet } from 'react-helmet';
+import Hashtag from 'mastodon/components/hashtag';
+import { expandFollowedHashtags, fetchFollowedHashtags } from 'mastodon/actions/tags';
+
+const messages = defineMessages({
+  heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
+});
+
+const mapStateToProps = state => ({
+  hashtags: state.getIn(['followed_tags', 'items']),
+  isLoading: state.getIn(['followed_tags', 'isLoading'], true),
+  hasMore: !!state.getIn(['followed_tags', 'next']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class FollowedTags extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    hashtags: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentDidMount() {
+    this.props.dispatch(fetchFollowedHashtags());
+  };
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandFollowedHashtags());
+  }, 300, { leading: true });
+
+  render () {
+    const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props;
+
+    const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.' />;
+
+    return (
+      <Column bindToDocument={!multiColumn}>
+        <ColumnHeader
+          icon='hashtag'
+          title={intl.formatMessage(messages.heading)}
+          showBackButton
+          multiColumn={multiColumn}
+        />
+
+        <ScrollableList
+          scrollKey='followed_tags'
+          emptyMessage={emptyMessage}
+          hasMore={hasMore}
+          isLoading={isLoading}
+          onLoadMore={this.handleLoadMore}
+          bindToDocument={!multiColumn}
+        >
+          {hashtags.map((hashtag) => (
+            <Hashtag
+              key={hashtag.get('name')}
+              name={hashtag.get('name')}
+              to={`/tags/${hashtag.get('name')}`}
+              withGraph={false}
+              // Taken from ImmutableHashtag. Should maybe refactor ImmutableHashtag to accept more options?
+              people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
+              history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
+            />
+          ))}
+        </ScrollableList>
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 3fbe03fdf5..78dc9ea40b 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -42,6 +42,7 @@ import {
   FollowRequests,
   FavouritedStatuses,
   BookmarkedStatuses,
+  FollowedTags,
   ListTimeline,
   Blocks,
   DomainBlocks,
@@ -216,6 +217,7 @@ class SwitchingColumnsArea extends React.PureComponent {
           <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
           <WrappedRoute path='/blocks' component={Blocks} content={children} />
           <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
+          <WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
           <WrappedRoute path='/mutes' component={Mutes} content={children} />
           <WrappedRoute path='/lists' component={Lists} content={children} />
 
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 6046578de4..1cf07f6453 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -90,6 +90,10 @@ export function FavouritedStatuses () {
   return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses');
 }
 
+export function FollowedTags () {
+  return import(/* webpackChunkName: "features/followed_tags" */'../../followed_tags');
+}
+
 export function BookmarkedStatuses () {
   return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses');
 }
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 3ed438fb81..210c91ad4b 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -1391,6 +1391,10 @@
         "defaultMessage": "Lists",
         "id": "navigation_bar.lists"
       },
+      {
+        "defaultMessage": "Followed hashtags",
+        "id": "navigation_bar.followed_tags"
+      },
       {
         "defaultMessage": "Blocked users",
         "id": "navigation_bar.blocks"
@@ -4310,4 +4314,4 @@
     ],
     "path": "app/javascript/mastodon/features/video/index.json"
   }
-]
\ No newline at end of file
+]
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 0240bf2e6d..992996dfb0 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -379,6 +379,7 @@
   "navigation_bar.favourites": "Favourites",
   "navigation_bar.filters": "Muted words",
   "navigation_bar.follow_requests": "Follow requests",
+  "navigation_bar.followed_tags": "Followed hashtags",
   "navigation_bar.follows_and_followers": "Follows and followers",
   "navigation_bar.lists": "Lists",
   "navigation_bar.logout": "Logout",
diff --git a/app/javascript/mastodon/reducers/followed_tags.js b/app/javascript/mastodon/reducers/followed_tags.js
new file mode 100644
index 0000000000..f50ee6aa33
--- /dev/null
+++ b/app/javascript/mastodon/reducers/followed_tags.js
@@ -0,0 +1,42 @@
+import {
+  FOLLOWED_HASHTAGS_FETCH_REQUEST,
+  FOLLOWED_HASHTAGS_FETCH_SUCCESS,
+  FOLLOWED_HASHTAGS_FETCH_FAIL,
+  FOLLOWED_HASHTAGS_EXPAND_REQUEST,
+  FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
+  FOLLOWED_HASHTAGS_EXPAND_FAIL,
+} from 'mastodon/actions/tags';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  isLoading: false,
+  next: null,
+});
+
+export default function followed_tags(state = initialState, action) {
+  switch(action.type) {
+  case FOLLOWED_HASHTAGS_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case FOLLOWED_HASHTAGS_FETCH_SUCCESS:
+    return state.withMutations(map => {
+      map.set('items', fromJS(action.followed_tags));
+      map.set('isLoading', false);
+      map.set('next', action.next);
+    });
+  case FOLLOWED_HASHTAGS_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case FOLLOWED_HASHTAGS_EXPAND_REQUEST:
+    return state.set('isLoading', true);
+  case FOLLOWED_HASHTAGS_EXPAND_SUCCESS:
+    return state.withMutations(map => {
+      map.update('items', set => set.concat(fromJS(action.followed_tags)));
+      map.set('isLoading', false);
+      map.set('next', action.next);
+    });
+  case FOLLOWED_HASHTAGS_EXPAND_FAIL:
+    return state.set('isLoading', false);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index bccdc18655..69771ad1b3 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -40,6 +40,7 @@ import picture_in_picture from './picture_in_picture';
 import accounts_map from './accounts_map';
 import history from './history';
 import tags from './tags';
+import followed_tags from './followed_tags';
 
 const reducers = {
   announcements,
@@ -83,6 +84,7 @@ const reducers = {
   picture_in_picture,
   history,
   tags,
+  followed_tags,
 };
 
 export default combineReducers(reducers);
diff --git a/config/routes.rb b/config/routes.rb
index 0bee2f639b..319f0c7d15 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -27,6 +27,7 @@ Rails.application.routes.draw do
     /blocks
     /domain_blocks
     /mutes
+    /followed_tags
     /statuses/(*any)
   ).freeze
 

From 68dcbcb7bf2cceb181a9b82d604d2c2829c0c78b Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 16:47:56 +0100
Subject: [PATCH 14/24] Add more specific error messages to HTTP signature
 verification (#21617)

* Return specific error on failure to parse Date header

* Add error message when preferredUsername is not set

* Change error report to be JSON and include more details

* Change error report to differentiate unknown account and failed refresh

* Add tests
---
 .../concerns/signature_verification.rb        |  16 +--
 .../activitypub/fetch_remote_actor_service.rb |   1 +
 .../concerns/signature_verification_spec.rb   | 107 ++++++++++++++++--
 3 files changed, 109 insertions(+), 15 deletions(-)

diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index 4502da698c..a9950d21fb 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -46,11 +46,11 @@ module SignatureVerification
   end
 
   def require_account_signature!
-    render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
+    render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
   end
 
   def require_actor_signature!
-    render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_actor
+    render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_actor
   end
 
   def signed_request?
@@ -97,11 +97,11 @@ module SignatureVerification
 
     actor = stoplight_wrap_request { actor_refresh_key!(actor) }
 
-    raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
+    raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
 
     return actor unless verify_signature(actor, signature, compare_signed_string).nil?
 
-    fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)"
+    fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature']
   rescue SignatureVerificationError => e
     fail_with! e.message
   rescue HTTP::Error, OpenSSL::SSL::SSLError => e
@@ -118,8 +118,8 @@ module SignatureVerification
 
   private
 
-  def fail_with!(message)
-    @signature_verification_failure_reason = message
+  def fail_with!(message, **options)
+    @signature_verification_failure_reason = { error: message }.merge(options)
     @signed_request_actor = nil
   end
 
@@ -209,8 +209,8 @@ module SignatureVerification
       end
 
       expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
-    rescue ArgumentError
-      return false
+    rescue ArgumentError => e
+      raise SignatureVerificationError, "Invalid Date header: #{e.message}"
     end
 
     expires_time ||= created_time + 5.minutes unless created_time.nil?
diff --git a/app/services/activitypub/fetch_remote_actor_service.rb b/app/services/activitypub/fetch_remote_actor_service.rb
index a25fa54c43..4f60ea5e8e 100644
--- a/app/services/activitypub/fetch_remote_actor_service.rb
+++ b/app/services/activitypub/fetch_remote_actor_service.rb
@@ -28,6 +28,7 @@ class ActivityPub::FetchRemoteActorService < BaseService
     raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?
     raise Error, "Unexpected object type for actor #{uri} (expected any of: #{SUPPORTED_TYPES})" unless expected_type?
     raise Error, "Actor #{uri} has moved to #{@json['movedTo']}" if break_on_redirect && @json['movedTo'].present?
+    raise Error, "Actor #{uri} has no 'preferredUsername', which is a requirement for Mastodon compatibility" unless @json['preferredUsername'].present?
 
     @uri      = @json['id']
     @username = @json['preferredUsername']
diff --git a/spec/controllers/concerns/signature_verification_spec.rb b/spec/controllers/concerns/signature_verification_spec.rb
index 6e73643b4d..13655f3133 100644
--- a/spec/controllers/concerns/signature_verification_spec.rb
+++ b/spec/controllers/concerns/signature_verification_spec.rb
@@ -16,6 +16,8 @@ describe ApplicationController, type: :controller do
   controller do
     include SignatureVerification
 
+    before_action :require_actor_signature!, only: [:signature_required]
+
     def success
       head 200
     end
@@ -23,10 +25,17 @@ describe ApplicationController, type: :controller do
     def alternative_success
       head 200
     end
+
+    def signature_required
+      head 200
+    end
   end
 
   before do
-    routes.draw { match via: [:get, :post], 'success' => 'anonymous#success' }
+    routes.draw do
+      match via: [:get, :post], 'success' => 'anonymous#success'
+      match via: [:get, :post], 'signature_required' => 'anonymous#signature_required'
+    end
   end
 
   context 'without signature header' do
@@ -118,6 +127,37 @@ describe ApplicationController, type: :controller do
       end
     end
 
+    context 'with request with unparseable Date header' do
+      before do
+        get :success
+
+        fake_request = Request.new(:get, request.url)
+        fake_request.add_headers({ 'Date' => 'wrong date' })
+        fake_request.on_behalf_of(author)
+
+        request.headers.merge!(fake_request.headers)
+      end
+
+      describe '#signed_request?' do
+        it 'returns true' do
+          expect(controller.signed_request?).to be true
+        end
+      end
+
+      describe '#signed_request_account' do
+        it 'returns nil' do
+          expect(controller.signed_request_account).to be_nil
+        end
+      end
+
+      describe '#signature_verification_failure_reason' do
+        it 'contains an error description' do
+          controller.signed_request_account
+          expect(controller.signature_verification_failure_reason[:error]).to eq 'Invalid Date header: not RFC 2616 compliant date: "wrong date"'
+        end
+      end
+    end
+
     context 'with request older than a day' do
       before do
         get :success
@@ -140,6 +180,13 @@ describe ApplicationController, type: :controller do
           expect(controller.signed_request_account).to be_nil
         end
       end
+
+      describe '#signature_verification_failure_reason' do
+        it 'contains an error description' do
+          controller.signed_request_account
+          expect(controller.signature_verification_failure_reason[:error]).to eq 'Signed request date outside acceptable time window'
+        end
+      end
     end
 
     context 'with inaccessible key' do
@@ -171,6 +218,7 @@ describe ApplicationController, type: :controller do
 
     context 'with body' do
       before do
+        allow(controller).to receive(:actor_refresh_key!).and_return(author)
         post :success, body: 'Hello world'
 
         fake_request = Request.new(:post, request.url, body: 'Hello world')
@@ -189,22 +237,67 @@ describe ApplicationController, type: :controller do
         it 'returns an account' do
           expect(controller.signed_request_account).to eq author
         end
+      end
 
-        it 'returns nil when path does not match' do
+      context 'when path does not match' do
+        before do
           request.path = '/alternative-path'
-          expect(controller.signed_request_account).to be_nil
         end
 
-        it 'returns nil when method does not match' do
+        describe '#signed_request_account' do
+          it 'returns nil' do
+            expect(controller.signed_request_account).to be_nil
+          end
+        end
+
+        describe '#signature_verification_failure_reason' do
+          it 'contains an error description' do
+            controller.signed_request_account
+            expect(controller.signature_verification_failure_reason[:error]).to include('using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)')
+            expect(controller.signature_verification_failure_reason[:signed_string]).to include("(request-target): post /alternative-path\n")
+          end
+        end
+      end
+
+      context 'when method does not match' do
+        before do
           get :success
-          expect(controller.signed_request_account).to be_nil
         end
 
-        it 'returns nil when body has been tampered' do
+        describe '#signed_request_account' do
+          it 'returns nil' do
+            expect(controller.signed_request_account).to be_nil
+          end
+        end
+      end
+
+      context 'when body has been tampered' do
+        before do
           post :success, body: 'doo doo doo'
-          expect(controller.signed_request_account).to be_nil
+        end
+
+        describe '#signed_request_account' do
+          it 'returns nil when body has been tampered' do
+            expect(controller.signed_request_account).to be_nil
+          end
         end
       end
     end
   end
+
+  context 'when a signature is required' do
+    before do
+      get :signature_required
+    end
+
+    context 'without signature header' do
+      it 'returns HTTP 401' do
+        expect(response).to have_http_status(401)
+      end
+
+      it 'returns an error' do
+        expect(Oj.load(response.body)['error']).to eq 'Request not signed'
+      end
+    end
+  end
 end

From cb4e28f405735e9e4c5438a93320c2584cd386f3 Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 16:50:50 +0100
Subject: [PATCH 15/24] Add `tootctl domains purge` options to select
 subdomains and keep domain blocks (#22063)

* Add --include-subdomains option to tootctl domains purge

* Add support for '*.' subdomain wildcard patterns in `tootctl domains purge`

* Fix custom emojis deletion not following subdomain and URI options

* Change `tootctl domains purge` to not purge domain blocks unless --purge-domain-blocks is passed

* Refactor `tootctl domains purge`

* Add feedback on deleted domain blocks
---
 lib/mastodon/domains_cli.rb | 75 +++++++++++++++++++++++++++----------
 1 file changed, 56 insertions(+), 19 deletions(-)

diff --git a/lib/mastodon/domains_cli.rb b/lib/mastodon/domains_cli.rb
index 77364ffbbe..81ee53c18e 100644
--- a/lib/mastodon/domains_cli.rb
+++ b/lib/mastodon/domains_cli.rb
@@ -18,6 +18,8 @@ module Mastodon
     option :dry_run, type: :boolean
     option :limited_federation_mode, type: :boolean
     option :by_uri, type: :boolean
+    option :include_subdomains, type: :boolean
+    option :purge_domain_blocks, type: :boolean
     desc 'purge [DOMAIN...]', 'Remove accounts from a DOMAIN without a trace'
     long_desc <<-LONG_DESC
       Remove all accounts from a given DOMAIN without leaving behind any
@@ -33,40 +35,75 @@ module Mastodon
       that has the handle `foo@bar.com` but whose profile is at the URL
       `https://mastodon-bar.com/users/foo`, would be purged by either
       `tootctl domains purge bar.com` or `tootctl domains purge --by-uri mastodon-bar.com`.
+
+      When the --include-subdomains option is given, not only DOMAIN is deleted, but all
+      subdomains as well. Note that this may be considerably slower.
+
+      When the --purge-domain-blocks option is given, also purge matching domain blocks.
     LONG_DESC
     def purge(*domains)
-      dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
+      dry_run            = options[:dry_run] ? ' (DRY RUN)' : ''
+      domains            = domains.map { |domain| TagManager.instance.normalize_domain(domain) }
+      account_scope      = Account.none
+      domain_block_scope = DomainBlock.none
+      emoji_scope        = CustomEmoji.none
 
-      scope = begin
-        if options[:limited_federation_mode]
-          Account.remote.where.not(domain: DomainAllow.pluck(:domain))
-        elsif !domains.empty?
-          if options[:by_uri]
-            domains.map { |domain| Account.remote.where(Account.arel_table[:uri].matches("https://#{domain}/%", false, true)) }.reduce(:or)
-          else
-            Account.remote.where(domain: domains)
-          end
+      # Sanity check on command arguments
+      if options[:limited_federation_mode] && !domains.empty?
+        say('DOMAIN parameter not supported with --limited-federation-mode', :red)
+        exit(1)
+      elsif domains.empty? && !options[:limited_federation_mode]
+        say('No domain(s) given', :red)
+        exit(1)
+      end
+
+      # Build scopes from command arguments
+      if options[:limited_federation_mode]
+        account_scope = Account.remote.where.not(domain: DomainAllow.select(:domain))
+        emoji_scope   = CustomEmoji.remote.where.not(domain: DomainAllow.select(:domain))
+      else
+        # Handle wildcard subdomains
+        subdomain_patterns = domains.filter_map { |domain| "%.#{Account.sanitize_sql_like(domain[2..])}" if domain.start_with?('*.') }
+        domains = domains.filter { |domain| !domain.start_with?('*.') }
+        # Handle --include-subdomains
+        subdomain_patterns += domains.map { |domain| "%.#{Account.sanitize_sql_like(domain)}" } if options[:include_subdomains]
+        uri_patterns = (domains.map { |domain| Account.sanitize_sql_like(domain) } + subdomain_patterns).map { |pattern| "https://#{pattern}/%" }
+
+        if options[:purge_domain_blocks]
+          domain_block_scope = DomainBlock.where(domain: domains)
+          domain_block_scope = domain_block_scope.or(DomainBlock.where(DomainBlock.arel_table[:domain].matches_any(subdomain_patterns))) unless subdomain_patterns.empty?
+        end
+
+        if options[:by_uri]
+          account_scope = Account.remote.where(Account.arel_table[:uri].matches_any(uri_patterns, false, true))
+          emoji_scope   = CustomEmoji.remote.where(CustomEmoji.arel_table[:uri].matches_any(uri_patterns, false, true))
         else
-          say('No domain(s) given', :red)
-          exit(1)
+          account_scope = Account.remote.where(domain: domains)
+          account_scope = account_scope.or(Account.remote.where(Account.arel_table[:domain].matches_any(subdomain_patterns))) unless subdomain_patterns.empty?
+          emoji_scope   = CustomEmoji.where(domain: domains)
+          emoji_scope   = emoji_scope.or(CustomEmoji.remote.where(CustomEmoji.arel_table[:uri].matches_any(subdomain_patterns))) unless subdomain_patterns.empty?
         end
       end
 
-      processed, = parallelize_with_progress(scope) do |account|
+      # Actually perform the deletions
+      processed, = parallelize_with_progress(account_scope) do |account|
         DeleteAccountService.new.call(account, reserve_username: false, skip_side_effects: true) unless options[:dry_run]
       end
 
-      DomainBlock.where(domain: domains).destroy_all unless options[:dry_run]
-
       say("Removed #{processed} accounts#{dry_run}", :green)
 
-      custom_emojis = CustomEmoji.where(domain: domains)
-      custom_emojis_count = custom_emojis.count
-      custom_emojis.destroy_all unless options[:dry_run]
+      if options[:purge_domain_blocks]
+        domain_block_count = domain_block_scope.count
+        domain_block_scope.in_batches.destroy_all unless options[:dry_run]
+        say("Removed #{domain_block_count} domain blocks#{dry_run}", :green)
+      end
+
+      custom_emojis_count = emoji_scope.count
+      emoji_scope.in_batches.destroy_all unless options[:dry_run]
 
       Instance.refresh unless options[:dry_run]
 
-      say("Removed #{custom_emojis_count} custom emojis", :green)
+      say("Removed #{custom_emojis_count} custom emojis#{dry_run}", :green)
     end
 
     option :concurrency, type: :numeric, default: 50, aliases: [:c]

From 3588fbc76641311ab97ef530e2df4df4934805c5 Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 17:15:23 +0100
Subject: [PATCH 16/24] Fix confusing wording in the sign in banner (#22490)

* Fix confusing wording in the sign in banner

* Split into two sentences
---
 .../mastodon/features/ui/components/sign_in_banner.js           | 2 +-
 app/javascript/mastodon/locales/defaultMessages.json            | 2 +-
 app/javascript/mastodon/locales/en.json                         | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/app/javascript/mastodon/features/ui/components/sign_in_banner.js b/app/javascript/mastodon/features/ui/components/sign_in_banner.js
index 8bd32edf90..86fcc11b56 100644
--- a/app/javascript/mastodon/features/ui/components/sign_in_banner.js
+++ b/app/javascript/mastodon/features/ui/components/sign_in_banner.js
@@ -30,7 +30,7 @@ const SignInBanner = () => {
 
   return (
     <div className='sign-in-banner'>
-      <p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.' /></p>
+      <p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.' /></p>
       <a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
       {signupButton}
     </div>
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 210c91ad4b..249ad07704 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -4224,7 +4224,7 @@
         "id": "sign_in_banner.create_account"
       },
       {
-        "defaultMessage": "Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.",
+        "defaultMessage": "Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.",
         "id": "sign_in_banner.text"
       },
       {
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 992996dfb0..79146c462c 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -541,7 +541,7 @@
   "server_banner.server_stats": "Server stats:",
   "sign_in_banner.create_account": "Create account",
   "sign_in_banner.sign_in": "Sign in",
-  "sign_in_banner.text": "Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.",
+  "sign_in_banner.text": "Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.",
   "status.admin_account": "Open moderation interface for @{name}",
   "status.admin_domain": "Open moderation interface for {domain}",
   "status.admin_status": "Open this post in the moderation interface",

From 473fed2cdf67031f12cad7b2355cbafcd281022d Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 16:28:18 +0100
Subject: [PATCH 17/24] [Glitch] Fix /api/v1/admin/trends/tags using wrong
 serializer

Port b034dc42be2250f9b754fb88c9163b62d41f78f5 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
---
 app/javascript/flavours/glitch/components/admin/Trends.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/javascript/flavours/glitch/components/admin/Trends.js b/app/javascript/flavours/glitch/components/admin/Trends.js
index 4c17b69a08..774bf36e6e 100644
--- a/app/javascript/flavours/glitch/components/admin/Trends.js
+++ b/app/javascript/flavours/glitch/components/admin/Trends.js
@@ -50,7 +50,7 @@ export default class Trends extends React.PureComponent {
             <Hashtag
               key={hashtag.name}
               name={hashtag.name}
-              href={`/admin/tags/${hashtag.id}`}
+              href={hashtag.id === undefined ? undefined : `/admin/tags/${hashtag.id}`}
               people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
               uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
               history={hashtag.history.reverse().map(day => day.uses)}

From c87b1a20c750bb9166a5ede3ad9ab8d01f97913e Mon Sep 17 00:00:00 2001
From: Jeong Arm <kjwonmail@gmail.com>
Date: Thu, 19 Jan 2023 00:29:07 +0900
Subject: [PATCH 18/24] [Glitch] Make visible change for new post notification
 setting icon

Port 1b2ef60cec381e69384c208589c3dcf0ca2661db to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
---
 .../flavours/glitch/features/account/components/header.js       | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js
index ec4a192bc8..2f18002592 100644
--- a/app/javascript/flavours/glitch/features/account/components/header.js
+++ b/app/javascript/flavours/glitch/features/account/components/header.js
@@ -188,7 +188,7 @@ class Header extends ImmutablePureComponent {
     }
 
     if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
-      bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
+      bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
     }
 
     if (me !== account.get('id')) {

From 9205b4e32f2fdeca8f3d8076f5e60542aed1665f Mon Sep 17 00:00:00 2001
From: Peter Simonsson <peter@simonsson.com>
Date: Wed, 18 Jan 2023 15:30:46 +0000
Subject: [PATCH 19/24] [Glitch] Add checkmark symbol to checkbox

Port 7e6ffa085f97dc4688e2655fe2447743ab807e44 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
---
 .../flavours/glitch/styles/components/compose_form.scss         | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/javascript/flavours/glitch/styles/components/compose_form.scss b/app/javascript/flavours/glitch/styles/components/compose_form.scss
index aa2d52ed04..40adf28c9d 100644
--- a/app/javascript/flavours/glitch/styles/components/compose_form.scss
+++ b/app/javascript/flavours/glitch/styles/components/compose_form.scss
@@ -118,7 +118,7 @@
 
     &.active {
       border-color: $highlight-text-color;
-      background: $highlight-text-color;
+      background: $highlight-text-color url("data:image/svg+xml;utf8,<svg width='18' height='18' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M4.5 8.5L8 12l6-6' stroke='white' stroke-width='1.5'/></svg>") center center no-repeat;
     }
   }
 }

From 9b4afb320a55761259a6e467cb1b4b6b5fb447f0 Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 16:32:23 +0100
Subject: [PATCH 20/24] [Glitch] Change account moderation notes to make links
 clickable

Port 9b3e22c40d5a24ddfa0df42d8fe6e96a273e8afd to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
---
 app/javascript/flavours/glitch/styles/admin.scss | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss
index 8ddf815c30..9aa2318ce5 100644
--- a/app/javascript/flavours/glitch/styles/admin.scss
+++ b/app/javascript/flavours/glitch/styles/admin.scss
@@ -1588,6 +1588,15 @@ a.sparkline {
           margin-bottom: 0;
         }
       }
+
+      a {
+        color: $highlight-text-color;
+        text-decoration: none;
+
+        &:hover {
+          text-decoration: underline;
+        }
+      }
     }
 
     &__actions {

From b5c6a116a765ba2d336ff1c3ec4acd66bb41a3fb Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 16:33:55 +0100
Subject: [PATCH 21/24] [Glitch] Add support for editing media description and
 focus point of already-posted statuses

Port 4b92e59f4fea4486ee6e5af7421e7945d5f7f998 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
---
 .../flavours/glitch/actions/compose.js        | 46 ++++++++++++++++---
 .../features/compose/components/upload.js     |  8 ++--
 .../ui/components/focal_point_modal.js        |  2 +-
 .../flavours/glitch/reducers/compose.js       |  2 +-
 4 files changed, 46 insertions(+), 12 deletions(-)

diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index 7a4af4cda7..267cff563c 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -181,6 +181,18 @@ export function submitCompose(routerHistory) {
 
     dispatch(submitComposeRequest());
 
+    // If we're editing a post with media attachments, those have not
+    // necessarily been changed on the server. Do it now in the same
+    // API call.
+    let media_attributes;
+    if (statusId !== null) {
+      media_attributes = media.map(item => ({
+        id: item.get('id'),
+        description: item.get('description'),
+        focus: item.get('focus'),
+      }));
+    }
+
     api(getState).request({
       url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
       method: statusId === null ? 'post' : 'put',
@@ -189,6 +201,7 @@ export function submitCompose(routerHistory) {
         content_type: getState().getIn(['compose', 'content_type']),
         in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
         media_ids: media.map(item => item.get('id')),
+        media_attributes,
         sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
         spoiler_text: spoilerText,
         visibility: getState().getIn(['compose', 'privacy']),
@@ -415,11 +428,31 @@ export function changeUploadCompose(id, params) {
   return (dispatch, getState) => {
     dispatch(changeUploadComposeRequest());
 
-    api(getState).put(`/api/v1/media/${id}`, params).then(response => {
-      dispatch(changeUploadComposeSuccess(response.data));
-    }).catch(error => {
-      dispatch(changeUploadComposeFail(id, error));
-    });
+    let media = getState().getIn(['compose', 'media_attachments']).find((item) => item.get('id') === id);
+
+    // Editing already-attached media is deferred to editing the post itself.
+    // For simplicity's sake, fake an API reply.
+    if (media && !media.get('unattached')) {
+      let { description, focus } = params;
+      const data = media.toJS();
+
+      if (description) {
+        data.description = description;
+      }
+
+      if (focus) {
+        focus = focus.split(',');
+        data.meta = { focus: { x: parseFloat(focus[0]), y: parseFloat(focus[1]) } };
+      }
+
+      dispatch(changeUploadComposeSuccess(data, true));
+    } else {
+      api(getState).put(`/api/v1/media/${id}`, params).then(response => {
+        dispatch(changeUploadComposeSuccess(response.data, false));
+      }).catch(error => {
+        dispatch(changeUploadComposeFail(id, error));
+      });
+    }
   };
 };
 
@@ -430,10 +463,11 @@ export function changeUploadComposeRequest() {
   };
 };
 
-export function changeUploadComposeSuccess(media) {
+export function changeUploadComposeSuccess(media, attached) {
   return {
     type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
     media: media,
+    attached: attached,
     skipLoading: true,
   };
 };
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js
index 6528bbc84c..cd45245409 100644
--- a/app/javascript/flavours/glitch/features/compose/components/upload.js
+++ b/app/javascript/flavours/glitch/features/compose/components/upload.js
@@ -43,13 +43,13 @@ export default class Upload extends ImmutablePureComponent {
           {({ scale }) => (
             <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
               <div className='compose-form__upload__actions'>
-                <button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
-                {!!media.get('unattached') && (<button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>)}
+                <button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
+                <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
               </div>
 
-              {(media.get('description') || '').length === 0 && !!media.get('unattached') && (
+              {(media.get('description') || '').length === 0 && (
                 <div className='compose-form__upload__warning'>
-                  <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
+                  <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
                 </div>
               )}
             </div>
diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
index 0dd07fb76b..fb432cf9ca 100644
--- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
@@ -320,7 +320,7 @@ class FocalPointModal extends ImmutablePureComponent {
               <React.Fragment>
                 <label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
 
-                <Button disabled={isUploadingThumbnail} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
+                <Button disabled={isUploadingThumbnail || !media.get('unattached')} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
 
                 <label>
                   <span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index 814b6a1a72..a69c0f7f2d 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -551,7 +551,7 @@ export default function compose(state = initialState, action) {
       .setIn(['media_modal', 'dirty'], false)
       .update('media_attachments', list => list.map(item => {
         if (item.get('id') === action.media.id) {
-          return fromJS(action.media).set('unattached', true);
+          return fromJS(action.media).set('unattached', !action.attached);
         }
 
         return item;

From 55e368c02f3882774a95b69d6ed4903d724177d4 Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 16:43:58 +0100
Subject: [PATCH 22/24] [Glitch] Add option to make the landing page be /about
 even when trends are enabled

Port 3970a6f433ad3cf79ef41d84baf6d788d25bd246 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
---
 app/javascript/flavours/glitch/features/ui/index.js | 4 ++--
 app/javascript/flavours/glitch/initial_state.js     | 2 ++
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 72e13d9d68..04b124a8d0 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -56,7 +56,7 @@ import {
   PrivacyPolicy,
 } from './util/async-components';
 import { HotKeys } from 'react-hotkeys';
-import initialState, { me, owner, singleUserMode, showTrends } from '../../initial_state';
+import initialState, { me, owner, singleUserMode, showTrends, trendsAsLanding } from '../../initial_state';
 import { closeOnboarding, INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
 import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
 import { Helmet } from 'react-helmet';
@@ -177,7 +177,7 @@ class SwitchingColumnsArea extends React.PureComponent {
       }
     } else if (singleUserMode && owner && initialState?.accounts[owner]) {
       redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
-    } else if (showTrends) {
+    } else if (showTrends && trendsAsLanding) {
       redirect = <Redirect from='/' to='/explore' exact />;
     } else {
       redirect = <Redirect from='/' to='/about' exact />;
diff --git a/app/javascript/flavours/glitch/initial_state.js b/app/javascript/flavours/glitch/initial_state.js
index bbf25c8a85..eefbdca804 100644
--- a/app/javascript/flavours/glitch/initial_state.js
+++ b/app/javascript/flavours/glitch/initial_state.js
@@ -75,6 +75,7 @@
  * @property {boolean} timeline_preview
  * @property {string} title
  * @property {boolean} trends
+ * @property {boolean} trends_as_landing_page
  * @property {boolean} unfollow_modal
  * @property {boolean} use_blurhash
  * @property {boolean=} use_pending_items
@@ -134,6 +135,7 @@ export const singleUserMode = getMeta('single_user_mode');
 export const source_url = getMeta('source_url');
 export const timelinePreview = getMeta('timeline_preview');
 export const title = getMeta('title');
+export const trendsAsLanding = getMeta('trends_as_landing_page');
 export const unfollowModal = getMeta('unfollow_modal');
 export const useBlurhash = getMeta('use_blurhash');
 export const usePendingItems = getMeta('use_pending_items');

From 00cc1536f2851a23806c0200673781479e8ba648 Mon Sep 17 00:00:00 2001
From: Connor Shea <connor.james.shea@gmail.com>
Date: Wed, 18 Jan 2023 08:44:33 -0700
Subject: [PATCH 23/24] [Glitch] Add listing of followed hashtags

Port 30e895299ceff095da3e4c8ee50b3d003225f021 to glitch-soc

Co-authored-by: Claire <claire.github-309c@sitedethib.com>
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
---
 .../flavours/glitch/actions/tags.js           | 82 ++++++++++++++++-
 .../features/account/components/header.js     |  2 +
 .../features/compose/components/action_bar.js |  2 +
 .../glitch/features/followed_tags/index.js    | 89 +++++++++++++++++++
 .../flavours/glitch/features/ui/index.js      |  2 +
 .../features/ui/util/async-components.js      |  4 +
 .../flavours/glitch/reducers/followed_tags.js | 42 +++++++++
 .../flavours/glitch/reducers/index.js         |  2 +
 8 files changed, 224 insertions(+), 1 deletion(-)
 create mode 100644 app/javascript/flavours/glitch/features/followed_tags/index.js
 create mode 100644 app/javascript/flavours/glitch/reducers/followed_tags.js

diff --git a/app/javascript/flavours/glitch/actions/tags.js b/app/javascript/flavours/glitch/actions/tags.js
index 37e79d4cba..08a08cda31 100644
--- a/app/javascript/flavours/glitch/actions/tags.js
+++ b/app/javascript/flavours/glitch/actions/tags.js
@@ -1,9 +1,17 @@
-import api from '../api';
+import api, { getLinks } from '../api';
 
 export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
 export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
 export const HASHTAG_FETCH_FAIL    = 'HASHTAG_FETCH_FAIL';
 
+export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
+export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
+export const FOLLOWED_HASHTAGS_FETCH_FAIL    = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
+
+export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
+export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
+export const FOLLOWED_HASHTAGS_EXPAND_FAIL    = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
+
 export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
 export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
 export const HASHTAG_FOLLOW_FAIL    = 'HASHTAG_FOLLOW_FAIL';
@@ -37,6 +45,78 @@ export const fetchHashtagFail = error => ({
   error,
 });
 
+export const fetchFollowedHashtags = () => (dispatch, getState) => {
+  dispatch(fetchFollowedHashtagsRequest());
+
+  api(getState).get('/api/v1/followed_tags').then(response => {
+    const next = getLinks(response).refs.find(link => link.rel === 'next');
+    dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
+  }).catch(err => {
+    dispatch(fetchFollowedHashtagsFail(err));
+  });
+};
+
+export function fetchFollowedHashtagsRequest() {
+  return {
+    type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
+  };
+};
+
+export function fetchFollowedHashtagsSuccess(followed_tags, next) {
+  return {
+    type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
+    followed_tags,
+    next,
+  };
+};
+
+export function fetchFollowedHashtagsFail(error) {
+  return {
+    type: FOLLOWED_HASHTAGS_FETCH_FAIL,
+    error,
+  };
+};
+
+export function expandFollowedHashtags() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['followed_tags', 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFollowedHashtagsRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(expandFollowedHashtagsFail(error));
+    });
+  };
+};
+
+export function expandFollowedHashtagsRequest() {
+  return {
+    type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
+  };
+};
+
+export function expandFollowedHashtagsSuccess(followed_tags, next) {
+  return {
+    type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
+    followed_tags,
+    next,
+  };
+};
+
+export function expandFollowedHashtagsFail(error) {
+  return {
+    type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
+    error,
+  };
+};
+
 export const followHashtag = name => (dispatch, getState) => {
   dispatch(followHashtagRequest(name));
 
diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js
index 2f18002592..071d00bb4c 100644
--- a/app/javascript/flavours/glitch/features/account/components/header.js
+++ b/app/javascript/flavours/glitch/features/account/components/header.js
@@ -45,6 +45,7 @@ const messages = defineMessages({
   follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
   favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
   lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
   blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
   domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
   mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
@@ -245,6 +246,7 @@ class Header extends ImmutablePureComponent {
       menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
       menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
       menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
+      menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
       menu.push(null);
       menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
       menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
diff --git a/app/javascript/flavours/glitch/features/compose/components/action_bar.js b/app/javascript/flavours/glitch/features/compose/components/action_bar.js
index 267c0ba699..838ef09ea9 100644
--- a/app/javascript/flavours/glitch/features/compose/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/compose/components/action_bar.js
@@ -12,6 +12,7 @@ const messages = defineMessages({
   follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
   favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
   lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
   blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
   domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
   mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
@@ -46,6 +47,7 @@ class ActionBar extends React.PureComponent {
     menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
     menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
     menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
+    menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
     menu.push(null);
     menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
     menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
diff --git a/app/javascript/flavours/glitch/features/followed_tags/index.js b/app/javascript/flavours/glitch/features/followed_tags/index.js
new file mode 100644
index 0000000000..4a23afc2d4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/followed_tags/index.js
@@ -0,0 +1,89 @@
+import { debounce } from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import Column from 'flavours/glitch/features/ui/components/column';
+import { Helmet } from 'react-helmet';
+import Hashtag from 'flavours/glitch/components/hashtag';
+import { expandFollowedHashtags, fetchFollowedHashtags } from 'flavours/glitch/actions/tags';
+
+const messages = defineMessages({
+  heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
+});
+
+const mapStateToProps = state => ({
+  hashtags: state.getIn(['followed_tags', 'items']),
+  isLoading: state.getIn(['followed_tags', 'isLoading'], true),
+  hasMore: !!state.getIn(['followed_tags', 'next']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class FollowedTags extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    hashtags: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentDidMount() {
+    this.props.dispatch(fetchFollowedHashtags());
+  };
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandFollowedHashtags());
+  }, 300, { leading: true });
+
+  render () {
+    const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props;
+
+    const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.' />;
+
+    return (
+      <Column bindToDocument={!multiColumn}>
+        <ColumnHeader
+          icon='hashtag'
+          title={intl.formatMessage(messages.heading)}
+          showBackButton
+          multiColumn={multiColumn}
+        />
+
+        <ScrollableList
+          scrollKey='followed_tags'
+          emptyMessage={emptyMessage}
+          hasMore={hasMore}
+          isLoading={isLoading}
+          onLoadMore={this.handleLoadMore}
+          bindToDocument={!multiColumn}
+        >
+          {hashtags.map((hashtag) => (
+            <Hashtag
+              key={hashtag.get('name')}
+              name={hashtag.get('name')}
+              to={`/tags/${hashtag.get('name')}`}
+              withGraph={false}
+              // Taken from ImmutableHashtag. Should maybe refactor ImmutableHashtag to accept more options?
+              people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
+              history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
+            />
+          ))}
+        </ScrollableList>
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 04b124a8d0..d8889f9f9a 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -42,6 +42,7 @@ import {
   FollowRequests,
   FavouritedStatuses,
   BookmarkedStatuses,
+  FollowedTags,
   ListTimeline,
   Blocks,
   DomainBlocks,
@@ -230,6 +231,7 @@ class SwitchingColumnsArea extends React.PureComponent {
           <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
           <WrappedRoute path='/blocks' component={Blocks} content={children} />
           <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
+          <WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
           <WrappedRoute path='/mutes' component={Mutes} content={children} />
           <WrappedRoute path='/lists' component={Lists} content={children} />
           <WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} />
diff --git a/app/javascript/flavours/glitch/features/ui/util/async-components.js b/app/javascript/flavours/glitch/features/ui/util/async-components.js
index 025b22e618..03e5016286 100644
--- a/app/javascript/flavours/glitch/features/ui/util/async-components.js
+++ b/app/javascript/flavours/glitch/features/ui/util/async-components.js
@@ -98,6 +98,10 @@ export function FavouritedStatuses () {
   return import(/* webpackChunkName: "flavours/glitch/async/favourited_statuses" */'flavours/glitch/features/favourited_statuses');
 }
 
+export function FollowedTags () {
+  return import(/* webpackChunkName: "flavours/glitch/async/followed_tags" */'flavours/glitch/features/followed_tags');
+}
+
 export function BookmarkedStatuses () {
   return import(/* webpackChunkName: "flavours/glitch/async/bookmarked_statuses" */'flavours/glitch/features/bookmarked_statuses');
 }
diff --git a/app/javascript/flavours/glitch/reducers/followed_tags.js b/app/javascript/flavours/glitch/reducers/followed_tags.js
new file mode 100644
index 0000000000..4109b0b10d
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/followed_tags.js
@@ -0,0 +1,42 @@
+import {
+  FOLLOWED_HASHTAGS_FETCH_REQUEST,
+  FOLLOWED_HASHTAGS_FETCH_SUCCESS,
+  FOLLOWED_HASHTAGS_FETCH_FAIL,
+  FOLLOWED_HASHTAGS_EXPAND_REQUEST,
+  FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
+  FOLLOWED_HASHTAGS_EXPAND_FAIL,
+} from 'flavours/glitch/actions/tags';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  isLoading: false,
+  next: null,
+});
+
+export default function followed_tags(state = initialState, action) {
+  switch(action.type) {
+  case FOLLOWED_HASHTAGS_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case FOLLOWED_HASHTAGS_FETCH_SUCCESS:
+    return state.withMutations(map => {
+      map.set('items', fromJS(action.followed_tags));
+      map.set('isLoading', false);
+      map.set('next', action.next);
+    });
+  case FOLLOWED_HASHTAGS_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case FOLLOWED_HASHTAGS_EXPAND_REQUEST:
+    return state.set('isLoading', true);
+  case FOLLOWED_HASHTAGS_EXPAND_SUCCESS:
+    return state.withMutations(map => {
+      map.update('items', set => set.concat(fromJS(action.followed_tags)));
+      map.set('isLoading', false);
+      map.set('next', action.next);
+    });
+  case FOLLOWED_HASHTAGS_EXPAND_FAIL:
+    return state.set('isLoading', false);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js
index 09c08a3629..5b7bdbf699 100644
--- a/app/javascript/flavours/glitch/reducers/index.js
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -42,6 +42,7 @@ import picture_in_picture from './picture_in_picture';
 import accounts_map from './accounts_map';
 import history from './history';
 import tags from './tags';
+import followed_tags from './followed_tags';
 
 const reducers = {
   announcements,
@@ -87,6 +88,7 @@ const reducers = {
   picture_in_picture,
   history,
   tags,
+  followed_tags,
 };
 
 export default combineReducers(reducers);

From 3f74235ac5b6ce6f13c531506f30c466da2a7aa1 Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
Date: Wed, 18 Jan 2023 17:15:23 +0100
Subject: [PATCH 24/24] [Glitch] Fix confusing wording in the sign in banner

Port 3588fbc76641311ab97ef530e2df4df4934805c5 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
---
 .../flavours/glitch/features/ui/components/sign_in_banner.js    | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js b/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js
index e8023803f8..c0d62aca00 100644
--- a/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js
+++ b/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js
@@ -30,7 +30,7 @@ const SignInBanner = () => {
 
   return (
     <div className='sign-in-banner'>
-      <p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.' /></p>
+      <p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.' /></p>
       <a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
       {signupButton}
     </div>