diff --git a/app/javascript/mastodon/features/compose/components/language_dropdown.js b/app/javascript/mastodon/features/compose/components/language_dropdown.js
index e48fa60ffb2..bf56fd0faef 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
-
+
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index e4601e4715a..ad384bd0b50 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 d928a55ed56..d960070d62d 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 648aa30850d..1ea86862dbb 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 36512365314..49adc6ad05a 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 78185b2a994..debae22121e 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 5559199381d..ab047a0f8b3 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
+ 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.where(account: @account).map(&:name).each do |name|
- if names.include?(name)
- to_add.delete(name)
- else
- to_remove << name
- end
+ 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
- FeaturedTag.includes(:tag).where(account: @account, tags: { name: to_remove }).delete_all unless to_remove.empty?
-
- 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 9aef2c35d6e..ff4b7a506fc 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 114a7445422..c7dbebe7565 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 df929e3e801..c642c2293b9 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 a4323d1d9a4..943618e3902 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 ddf0908794e..c08ee85b0b3 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 017450f4b94..d7868e900d3 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 14bebb19b0a..1ecf7302a9f 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 00000000000..6ca22c9fc66
--- /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