diff --git a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx
index 1b8040e55b..3269b5a497 100644
--- a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx
+++ b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx
@@ -129,8 +129,13 @@ export const InlineFollowSuggestions = ({ hidden }) => {
return;
}
- setCanScrollLeft(bodyRef.current.scrollLeft > 0);
- setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
+ if (getComputedStyle(bodyRef.current).direction === 'rtl') {
+ setCanScrollLeft((bodyRef.current.clientWidth - bodyRef.current.scrollLeft) < bodyRef.current.scrollWidth);
+ setCanScrollRight(bodyRef.current.scrollLeft < 0);
+ } else {
+ setCanScrollLeft(bodyRef.current.scrollLeft > 0);
+ setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
+ }
}, [setCanScrollRight, setCanScrollLeft, bodyRef, suggestions]);
const handleLeftNav = useCallback(() => {
@@ -146,8 +151,13 @@ export const InlineFollowSuggestions = ({ hidden }) => {
return;
}
- setCanScrollLeft(bodyRef.current.scrollLeft > 0);
- setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
+ if (getComputedStyle(bodyRef.current).direction === 'rtl') {
+ setCanScrollLeft((bodyRef.current.clientWidth - bodyRef.current.scrollLeft) < bodyRef.current.scrollWidth);
+ setCanScrollRight(bodyRef.current.scrollLeft < 0);
+ } else {
+ setCanScrollLeft(bodyRef.current.scrollLeft > 0);
+ setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
+ }
}, [setCanScrollRight, setCanScrollLeft, bodyRef]);
const handleDismiss = useCallback(() => {
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx b/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx
index 300c8dd5b3..5c83f99b54 100644
--- a/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx
+++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx
@@ -14,6 +14,8 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
import StarIcon from '@/material-icons/400-24px/star.svg?react';
+import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
+import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
import { replyCompose } from 'mastodon/actions/compose';
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
import { openModal } from 'mastodon/actions/modal';
@@ -159,22 +161,26 @@ class Footer extends ImmutablePureComponent {
replyTitle = intl.formatMessage(messages.replyAll);
}
- let reblogTitle = '';
+ let reblogTitle, reblogIconComponent;
if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
+ reblogIconComponent = publicStatus ? RepeatIcon : RepeatPrivateIcon;
} else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog);
+ reblogIconComponent = RepeatIcon;
} else if (reblogPrivate) {
reblogTitle = intl.formatMessage(messages.reblog_private);
+ reblogIconComponent = RepeatPrivateIcon;
} else {
reblogTitle = intl.formatMessage(messages.cannot_reblog);
+ reblogIconComponent = RepeatDisabledIcon;
}
return (
-
+
{withOpenButton && }
diff --git a/app/javascript/styles/mastodon/rtl.scss b/app/javascript/styles/mastodon/rtl.scss
index e4e299ff82..0a05ce7c62 100644
--- a/app/javascript/styles/mastodon/rtl.scss
+++ b/app/javascript/styles/mastodon/rtl.scss
@@ -35,6 +35,10 @@ body.rtl {
direction: rtl;
}
+ .column-back-button__icon {
+ transform: scale(-1, 1);
+ }
+
.simple_form select {
background: $ui-base-color
url("data:image/svg+xml;utf8,")
diff --git a/app/lib/content_security_policy.rb b/app/lib/content_security_policy.rb
index a6901a6757..2e6c43be8f 100644
--- a/app/lib/content_security_policy.rb
+++ b/app/lib/content_security_policy.rb
@@ -40,7 +40,7 @@ class ContentSecurityPolicy
end
def cdn_host_value
- s3_alias_host || s3_cloudfront_host || azure_alias_host || s3_hostname_host
+ s3_alias_host || s3_cloudfront_host || azure_alias_host || s3_hostname_host || swift_object_url
end
def paperclip_root_url
@@ -76,6 +76,14 @@ class ContentSecurityPolicy
host_to_url ENV.fetch('S3_HOSTNAME', nil)
end
+ def swift_object_url
+ url = ENV.fetch('SWIFT_OBJECT_URL', nil)
+ return if url.blank? || !url.start_with?('https://')
+
+ url += '/' unless url.end_with?('/')
+ url
+ end
+
def uri_from_configuration_and_string(host_string)
Addressable::URI.parse("#{host_protocol}://#{host_string}").tap do |uri|
uri.path += '/' unless uri.path.blank? || uri.path.end_with?('/')
diff --git a/app/models/follow_recommendation.rb b/app/models/follow_recommendation.rb
index 7ac9e6dfb9..0435437a81 100644
--- a/app/models/follow_recommendation.rb
+++ b/app/models/follow_recommendation.rb
@@ -18,5 +18,6 @@ class FollowRecommendation < ApplicationRecord
belongs_to :account_summary, foreign_key: :account_id, inverse_of: false
belongs_to :account
- scope :localized, ->(locale) { joins(:account_summary).merge(AccountSummary.localized(locale)) }
+ scope :unsupressed, -> { where.not(FollowRecommendationSuppression.where(FollowRecommendationSuppression.arel_table[:account_id].eq(arel_table[:account_id])).select(1).arel.exists) }
+ scope :localized, ->(locale) { unsupressed.joins(:account_summary).merge(AccountSummary.localized(locale)) }
end
diff --git a/config/initializers/active_record_encryption.rb b/config/initializers/active_record_encryption.rb
index b7a874e404..c53f16d4d1 100644
--- a/config/initializers/active_record_encryption.rb
+++ b/config/initializers/active_record_encryption.rb
@@ -20,6 +20,7 @@
- ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
Run `bin/rails db:encryption:init` to generate new secrets and then assign the environment variables.
+ Do not change the secrets once they are set, as doing so may cause data loss and other issues that will be difficult or impossible to recover from.
MESSAGE
end
diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake
index 79599bd917..73de0c120f 100644
--- a/lib/tasks/db.rake
+++ b/lib/tasks/db.rake
@@ -7,6 +7,17 @@ namespace :db do
namespace :encryption do
desc 'Generate a set of keys for configuring Active Record encryption in a given environment'
task :init do # rubocop:disable Rails/RakeEnvironment
+ if %w(
+ ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
+ ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
+ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
+ ).any? { |key| ENV.key?(key) }
+ pastel = Pastel.new
+ puts pastel.red(<<~MSG)
+ WARNING: It looks like encryption secrets have already been set. Please ensure you are not changing secrets for a Mastodon installation that already uses them, as this will cause data loss and other issues that are difficult to recover from.
+ MSG
+ end
+
puts <<~MSG
Add the following secret environment variables to your Mastodon environment (e.g. .env.production), ensure they are shared across all your nodes and do not change them after they are set:#{' '}
diff --git a/spec/controllers/admin/tags_controller_spec.rb b/spec/controllers/admin/tags_controller_spec.rb
deleted file mode 100644
index 1df2bc4003..0000000000
--- a/spec/controllers/admin/tags_controller_spec.rb
+++ /dev/null
@@ -1,82 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Admin::TagsController do
- render_views
-
- before do
- sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin'))
- end
-
- describe 'GET #index' do
- before do
- Fabricate(:tag)
-
- tag_filter = instance_double(Admin::TagFilter, results: Tag.all)
- allow(Admin::TagFilter).to receive(:new).and_return(tag_filter)
- end
-
- let(:params) { { order: 'newest' } }
-
- it 'returns http success' do
- get :index
-
- expect(response).to have_http_status(200)
- expect(response).to render_template(:index)
-
- expect(Admin::TagFilter)
- .to have_received(:new)
- .with(hash_including(params))
- end
-
- describe 'with filters' do
- let(:params) { { order: 'newest', name: 'test' } }
-
- it 'returns http success' do
- get :index, params: { name: 'test' }
-
- expect(response).to have_http_status(200)
- expect(response).to render_template(:index)
-
- expect(Admin::TagFilter)
- .to have_received(:new)
- .with(hash_including(params))
- end
- end
- end
-
- describe 'GET #show' do
- let!(:tag) { Fabricate(:tag) }
-
- before do
- get :show, params: { id: tag.id }
- end
-
- it 'returns status 200' do
- expect(response).to have_http_status(200)
- end
- end
-
- describe 'PUT #update' do
- let!(:tag) { Fabricate(:tag, listable: false) }
-
- context 'with valid params' do
- it 'updates the tag' do
- put :update, params: { id: tag.id, tag: { listable: '1' } }
-
- expect(response).to redirect_to(admin_tag_path(tag.id))
- expect(tag.reload).to be_listable
- end
- end
-
- context 'with invalid params' do
- it 'does not update the tag' do
- put :update, params: { id: tag.id, tag: { name: 'cant-change-name' } }
-
- expect(response).to have_http_status(200)
- expect(response).to render_template(:show)
- end
- end
- end
-end
diff --git a/spec/system/admin/tags_spec.rb b/spec/system/admin/tags_spec.rb
new file mode 100644
index 0000000000..a3eca80d13
--- /dev/null
+++ b/spec/system/admin/tags_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Admin Tags' do
+ describe 'Tag interaction' do
+ let!(:tag) { Fabricate(:tag, name: 'test') }
+
+ before { sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
+
+ it 'allows tags listing and editing' do
+ visit admin_tags_path
+
+ expect(page)
+ .to have_title(I18n.t('admin.tags.title'))
+
+ click_on '#test'
+
+ fill_in display_name_field, with: 'NewTagName'
+ expect { click_on submit_button }
+ .to_not(change { tag.reload.display_name })
+ expect(page)
+ .to have_content(match_error_text)
+
+ fill_in display_name_field, with: 'TEST'
+ expect { click_on submit_button }
+ .to(change { tag.reload.display_name }.to('TEST'))
+ end
+
+ def display_name_field
+ I18n.t('simple_form.labels.defaults.display_name')
+ end
+
+ def match_error_text
+ I18n.t('tags.does_not_match_previous_name')
+ end
+ end
+end
diff --git a/streaming/redis.js b/streaming/redis.js
index 2a36b89dc5..0b582ef2f5 100644
--- a/streaming/redis.js
+++ b/streaming/redis.js
@@ -50,9 +50,9 @@ function getSentinelConfiguration(env, commonOptions) {
return {
db: redisDatabase,
name: env.REDIS_SENTINEL_MASTER,
- username: env.REDIS_USERNAME,
+ username: env.REDIS_USER,
password: env.REDIS_PASSWORD,
- sentinelUsername: env.REDIS_SENTINEL_USERNAME ?? env.REDIS_USERNAME,
+ sentinelUsername: env.REDIS_SENTINEL_USERNAME ?? env.REDIS_USER,
sentinelPassword: env.REDIS_SENTINEL_PASSWORD ?? env.REDIS_PASSWORD,
sentinels,
...commonOptions,
@@ -104,7 +104,7 @@ export function configFromEnv(env) {
host: env.REDIS_HOST ?? '127.0.0.1',
port: redisPort,
db: redisDatabase,
- username: env.REDIS_USERNAME,
+ username: env.REDIS_USER,
password: env.REDIS_PASSWORD,
...commonOptions,
};