about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/javascript/mastodon/features/compose/components/language_dropdown.js11
-rw-r--r--app/javascript/mastodon/reducers/compose.js1
-rw-r--r--app/javascript/styles/mastodon-light/diff.scss3
-rw-r--r--app/lib/request.rb10
-rw-r--r--app/models/account_statuses_cleanup_policy.rb7
-rw-r--r--app/models/featured_tag.rb2
-rw-r--r--app/services/activitypub/fetch_featured_tags_collection_service.rb22
-rw-r--r--app/views/auth/challenges/new.html.haml2
-rw-r--r--app/views/auth/passwords/edit.html.haml4
-rw-r--r--app/views/auth/registrations/edit.html.haml6
-rw-r--r--app/views/auth/sessions/new.html.haml2
-rw-r--r--app/views/settings/deletes/show.html.haml2
-rw-r--r--app/views/settings/migration/redirects/new.html.haml2
-rw-r--r--app/views/settings/migrations/show.html.haml2
-rw-r--r--spec/services/activitypub/fetch_featured_tags_collection_service_spec.rb95
15 files changed, 143 insertions, 28 deletions
diff --git a/app/javascript/mastodon/features/compose/components/language_dropdown.js b/app/javascript/mastodon/features/compose/components/language_dropdown.js
index e48fa60ff..bf56fd0fa 100644
--- a/app/javascript/mastodon/features/compose/components/language_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/language_dropdown.js
@@ -51,6 +51,15 @@ class LanguageDropdownMenu extends React.PureComponent {
     document.addEventListener('click', this.handleDocumentClick, false);
     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
     this.setState({ mounted: true });
+
+    // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
+    // to wait for a frame before focusing
+    requestAnimationFrame(() => {
+      if (this.node) {
+        const element = this.node.querySelector('input[type="search"]');
+        if (element) element.focus();
+      }
+    });
   }
 
   componentWillUnmount () {
@@ -226,7 +235,7 @@ class LanguageDropdownMenu extends React.PureComponent {
           // react-overlays
           <div className={`language-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
             <div className='emoji-mart-search'>
-              <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
+              <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} />
               <button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
             </div>
 
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index e4601e471..ad384bd0b 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -452,6 +452,7 @@ export default function compose(state = initialState, action) {
       map.set('idempotencyKey', uuid());
       map.set('sensitive', action.status.get('sensitive'));
       map.set('language', action.status.get('language'));
+      map.set('id', null);
 
       if (action.status.get('spoiler_text').length > 0) {
         map.set('spoiler', true);
diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss
index d928a55ed..d960070d6 100644
--- a/app/javascript/styles/mastodon-light/diff.scss
+++ b/app/javascript/styles/mastodon-light/diff.scss
@@ -268,7 +268,8 @@ html {
 .status__content .status__content__spoiler-link {
   background: $ui-base-color;
 
-  &:hover {
+  &:hover,
+  &:focus {
     background: lighten($ui-base-color, 4%);
   }
 }
diff --git a/app/lib/request.rb b/app/lib/request.rb
index 648aa3085..1ea86862d 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -62,8 +62,6 @@ class Request
     end
 
     begin
-      response = response.extend(ClientLimit)
-
       # If we are using a persistent connection, we have to
       # read every response to be able to move forward at all.
       # However, simply calling #to_s or #flush may not be safe,
@@ -181,6 +179,14 @@ class Request
     end
   end
 
+  if ::HTTP::Response.methods.include?(:body_with_limit) && !Rails.env.production?
+    abort 'HTTP::Response#body_with_limit is already defined, the monkey patch will not be applied'
+  else
+    class ::HTTP::Response
+      include Request::ClientLimit
+    end
+  end
+
   class Socket < TCPSocket
     class << self
       def open(host, *args)
diff --git a/app/models/account_statuses_cleanup_policy.rb b/app/models/account_statuses_cleanup_policy.rb
index 365123653..49adc6ad0 100644
--- a/app/models/account_statuses_cleanup_policy.rb
+++ b/app/models/account_statuses_cleanup_policy.rb
@@ -139,7 +139,12 @@ class AccountStatusesCleanupPolicy < ApplicationRecord
     # Filtering on `id` rather than `min_status_age` ago will treat
     # non-snowflake statuses as older than they really are, but Mastodon
     # has switched to snowflake IDs significantly over 2 years ago anyway.
-    max_id = [max_id, Mastodon::Snowflake.id_at(min_status_age.seconds.ago, with_random: false)].compact.min
+    snowflake_id = Mastodon::Snowflake.id_at(min_status_age.seconds.ago, with_random: false)
+
+    if max_id.nil? || snowflake_id < max_id
+      max_id = snowflake_id
+    end
+
     Status.where(Status.arel_table[:id].lteq(max_id))
   end
 
diff --git a/app/models/featured_tag.rb b/app/models/featured_tag.rb
index 78185b2a9..debae2212 100644
--- a/app/models/featured_tag.rb
+++ b/app/models/featured_tag.rb
@@ -63,6 +63,8 @@ class FeaturedTag < ApplicationRecord
   end
 
   def validate_featured_tags_limit
+    return unless account.local?
+
     errors.add(:base, I18n.t('featured_tags.errors.limit')) if account.featured_tags.count >= LIMIT
   end
 
diff --git a/app/services/activitypub/fetch_featured_tags_collection_service.rb b/app/services/activitypub/fetch_featured_tags_collection_service.rb
index 555919938..ab047a0f8 100644
--- a/app/services/activitypub/fetch_featured_tags_collection_service.rb
+++ b/app/services/activitypub/fetch_featured_tags_collection_service.rb
@@ -51,21 +51,17 @@ class ActivityPub::FetchFeaturedTagsCollectionService < BaseService
   end
 
   def process_items(items)
-    names     = items.filter_map { |item| item['type'] == 'Hashtag' && item['name']&.delete_prefix('#') }.map { |name| HashtagNormalizer.new.normalize(name) }
-    to_remove = []
-    to_add    = names
-
-    FeaturedTag.where(account: @account).map(&:name).each do |name|
-      if names.include?(name)
-        to_add.delete(name)
-      else
-        to_remove << name
-      end
-    end
+    names            = items.filter_map { |item| item['type'] == 'Hashtag' && item['name']&.delete_prefix('#') }.take(FeaturedTag::LIMIT)
+    tags             = names.index_by { |name| HashtagNormalizer.new.normalize(name) }
+    normalized_names = tags.keys
 
-    FeaturedTag.includes(:tag).where(account: @account, tags: { name: to_remove }).delete_all unless to_remove.empty?
+    FeaturedTag.includes(:tag).references(:tag).where(account: @account).where.not(tag: { name: normalized_names }).delete_all
+
+    FeaturedTag.includes(:tag).references(:tag).where(account: @account, tag: { name: normalized_names }).each do |featured_tag|
+      featured_tag.update(name: tags.delete(featured_tag.tag.name))
+    end
 
-    to_add.each do |name|
+    tags.each_value do |name|
       FeaturedTag.create!(account: @account, name: name)
     end
   end
diff --git a/app/views/auth/challenges/new.html.haml b/app/views/auth/challenges/new.html.haml
index 9aef2c35d..ff4b7a506 100644
--- a/app/views/auth/challenges/new.html.haml
+++ b/app/views/auth/challenges/new.html.haml
@@ -5,7 +5,7 @@
   = f.input :return_to, as: :hidden
 
   .field-group
-    = f.input :current_password, wrapper: :with_block_label, input_html: { :autocomplete => 'off', :autofocus => true }, label: t('challenge.prompt'), required: true
+    = f.input :current_password, wrapper: :with_block_label, input_html: { :autocomplete => 'current-password', :autofocus => true }, label: t('challenge.prompt'), required: true
 
   .actions
     = f.button :button, t('challenge.confirm'), type: :submit
diff --git a/app/views/auth/passwords/edit.html.haml b/app/views/auth/passwords/edit.html.haml
index 114a74454..c7dbebe75 100644
--- a/app/views/auth/passwords/edit.html.haml
+++ b/app/views/auth/passwords/edit.html.haml
@@ -8,9 +8,9 @@
     = f.input :reset_password_token, as: :hidden
 
     .fields-group
-      = f.input :password, wrapper: :with_label, autofocus: true, label: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'off', :minlength => User.password_length.first, :maxlength => User.password_length.last }, required: true
+      = f.input :password, wrapper: :with_label, autofocus: true, label: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'new-password', :minlength => User.password_length.first, :maxlength => User.password_length.last }, required: true
     .fields-group
-      = f.input :password_confirmation, wrapper: :with_label, label: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'off' }, required: true
+      = f.input :password_confirmation, wrapper: :with_label, label: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'new-password' }, required: true
 
     .actions
       = f.button :button, t('auth.set_new_password'), type: :submit
diff --git a/app/views/auth/registrations/edit.html.haml b/app/views/auth/registrations/edit.html.haml
index df929e3e8..c642c2293 100644
--- a/app/views/auth/registrations/edit.html.haml
+++ b/app/views/auth/registrations/edit.html.haml
@@ -13,13 +13,13 @@
       .fields-row__column.fields-group.fields-row__column-6
         = f.input :email, wrapper: :with_label, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, required: true, disabled: current_account.suspended?
       .fields-row__column.fields-group.fields-row__column-6
-        = f.input :current_password, wrapper: :with_label, input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password'), :autocomplete => 'off' }, required: true, disabled: current_account.suspended?, hint: false
+        = f.input :current_password, wrapper: :with_label, input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password'), :autocomplete => 'current-password' }, required: true, disabled: current_account.suspended?, hint: false
 
     .fields-row
       .fields-row__column.fields-group.fields-row__column-6
-        = f.input :password, wrapper: :with_label, label: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'off', :minlength => User.password_length.first, :maxlength => User.password_length.last }, hint: t('simple_form.hints.defaults.password'), disabled: current_account.suspended?
+        = f.input :password, wrapper: :with_label, label: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'new-password', :minlength => User.password_length.first, :maxlength => User.password_length.last }, hint: t('simple_form.hints.defaults.password'), disabled: current_account.suspended?
       .fields-row__column.fields-group.fields-row__column-6
-        = f.input :password_confirmation, wrapper: :with_label, label: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'off' }, disabled: current_account.suspended?
+        = f.input :password_confirmation, wrapper: :with_label, label: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'new-password' }, disabled: current_account.suspended?
 
     .actions
       = f.button :button, t('generic.save_changes'), type: :submit, class: 'button', disabled: current_account.suspended?
diff --git a/app/views/auth/sessions/new.html.haml b/app/views/auth/sessions/new.html.haml
index a4323d1d9..943618e39 100644
--- a/app/views/auth/sessions/new.html.haml
+++ b/app/views/auth/sessions/new.html.haml
@@ -12,7 +12,7 @@
       - else
         = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false
     .fields-group
-      = f.input :password, wrapper: :with_label, label: t('simple_form.labels.defaults.password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }, hint: false
+      = f.input :password, wrapper: :with_label, label: t('simple_form.labels.defaults.password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'current-password' }, hint: false
 
     .actions
       = f.button :button, t('auth.login'), type: :submit
diff --git a/app/views/settings/deletes/show.html.haml b/app/views/settings/deletes/show.html.haml
index ddf090879..c08ee85b0 100644
--- a/app/views/settings/deletes/show.html.haml
+++ b/app/views/settings/deletes/show.html.haml
@@ -21,7 +21,7 @@
   %hr.spacer/
 
   - if current_user.encrypted_password.present?
-    = f.input :password, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, hint: t('deletes.confirm_password')
+    = f.input :password, wrapper: :with_block_label, input_html: { :autocomplete => 'current-password' }, hint: t('deletes.confirm_password')
   - else
     = f.input :username, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, hint: t('deletes.confirm_username')
 
diff --git a/app/views/settings/migration/redirects/new.html.haml b/app/views/settings/migration/redirects/new.html.haml
index 017450f4b..d7868e900 100644
--- a/app/views/settings/migration/redirects/new.html.haml
+++ b/app/views/settings/migration/redirects/new.html.haml
@@ -19,7 +19,7 @@
 
     .fields-row__column.fields-group.fields-row__column-6
       - if current_user.encrypted_password.present?
-        = f.input :current_password, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, required: true
+        = f.input :current_password, wrapper: :with_block_label, input_html: { :autocomplete => 'current-password' }, required: true
       - else
         = f.input :current_username, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, required: true
 
diff --git a/app/views/settings/migrations/show.html.haml b/app/views/settings/migrations/show.html.haml
index 14bebb19b..1ecf7302a 100644
--- a/app/views/settings/migrations/show.html.haml
+++ b/app/views/settings/migrations/show.html.haml
@@ -48,7 +48,7 @@
 
     .fields-row__column.fields-group.fields-row__column-6
       - if current_user.encrypted_password.present?
-        = f.input :current_password, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, required: true, disabled: on_cooldown?
+        = f.input :current_password, wrapper: :with_block_label, input_html: { :autocomplete => 'current-password' }, required: true, disabled: on_cooldown?
       - else
         = f.input :current_username, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, required: true, disabled: on_cooldown?
 
diff --git a/spec/services/activitypub/fetch_featured_tags_collection_service_spec.rb b/spec/services/activitypub/fetch_featured_tags_collection_service_spec.rb
new file mode 100644
index 000000000..6ca22c9fc
--- /dev/null
+++ b/spec/services/activitypub/fetch_featured_tags_collection_service_spec.rb
@@ -0,0 +1,95 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::FetchFeaturedTagsCollectionService, type: :service do
+  let(:collection_url) { 'https://example.com/account/tags' }
+  let(:actor) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/account') }
+
+  let(:items) do
+    [
+      { type: 'Hashtag', href: 'https://example.com/account/tagged/foo', name: 'Foo' },
+      { type: 'Hashtag', href: 'https://example.com/account/tagged/bar', name: 'bar' },
+      { type: 'Hashtag', href: 'https://example.com/account/tagged/baz', name: 'baZ' },
+    ]
+  end
+
+  let(:payload) do
+    {
+      '@context': 'https://www.w3.org/ns/activitystreams',
+      type: 'Collection',
+      id: collection_url,
+      items: items,
+    }.with_indifferent_access
+  end
+
+  subject { described_class.new }
+
+  shared_examples 'sets featured tags' do
+    before do
+      subject.call(actor, collection_url)
+    end
+
+    it 'sets expected tags as pinned tags' do
+      expect(actor.featured_tags.map(&:display_name)).to match_array ['Foo', 'bar', 'baZ']
+    end
+  end
+
+  describe '#call' do
+    context 'when the endpoint is a Collection' do
+      before do
+        stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload))
+      end
+
+      it_behaves_like 'sets featured tags'
+    end
+
+    context 'when the account already has featured tags' do
+      before do
+        stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload))
+
+        actor.featured_tags.create!(name: 'FoO')
+        actor.featured_tags.create!(name: 'baz')
+        actor.featured_tags.create!(name: 'oh').update(name: nil)
+      end
+
+      it_behaves_like 'sets featured tags'
+    end
+
+    context 'when the endpoint is an OrderedCollection' do
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          type: 'OrderedCollection',
+          id: collection_url,
+          orderedItems: items,
+        }.with_indifferent_access
+      end
+
+      before do
+        stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload))
+      end
+
+      it_behaves_like 'sets featured tags'
+    end
+
+    context 'when the endpoint is a paginated Collection' do
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          type: 'Collection',
+          id: collection_url,
+          first: {
+            type: 'CollectionPage',
+            partOf: collection_url,
+            items: items,
+          }
+        }.with_indifferent_access
+      end
+
+      before do
+        stub_request(:get, collection_url).to_return(status: 200, body: Oj.dump(payload))
+      end
+
+      it_behaves_like 'sets featured tags'
+    end
+  end
+end