From e4cfe4b3db2e71cef43c7b8f7ce42ba46f7aa0b5 Mon Sep 17 00:00:00 2001 From: Kurtis Rainbolt-Greene Date: Sat, 8 Jul 2023 10:45:36 -0700 Subject: [PATCH 1/6] First pass at multi-database for read replica using Rails native adapter (#25693) Co-authored-by: emilweth <7402764+emilweth@users.noreply.github.com> --- .rubocop_todo.yml | 1 - Gemfile | 1 - Gemfile.lock | 3 --- .../api/v1/timelines/home_controller.rb | 7 ++++-- app/workers/feed_insert_worker.rb | 24 +++++++++++-------- config/database.yml | 24 +++++++++++++------ config/initializers/makara.rb | 2 -- 7 files changed, 36 insertions(+), 26 deletions(-) delete mode 100644 config/initializers/makara.rb diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 975c9d28f..24f02d4d3 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -809,7 +809,6 @@ Style/FrozenStringLiteralComment: - 'config/initializers/httplog.rb' - 'config/initializers/inflections.rb' - 'config/initializers/mail_delivery_job.rb' - - 'config/initializers/makara.rb' - 'config/initializers/mime_types.rb' - 'config/initializers/oj.rb' - 'config/initializers/omniauth.rb' diff --git a/Gemfile b/Gemfile index 3feb3f954..24cb43e65 100644 --- a/Gemfile +++ b/Gemfile @@ -11,7 +11,6 @@ gem 'rack', '~> 2.2.7' gem 'haml-rails', '~>2.0' gem 'pg', '~> 1.5' -gem 'makara', '~> 0.5' gem 'pghero' gem 'dotenv-rails', '~> 2.8' diff --git a/Gemfile.lock b/Gemfile.lock index 985e36c20..9bd708d61 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -399,8 +399,6 @@ GEM net-imap net-pop net-smtp - makara (0.5.1) - activerecord (>= 5.2.0) marcel (1.0.2) mario-redis-lock (1.2.1) redis (>= 3.0.5) @@ -815,7 +813,6 @@ DEPENDENCIES letter_opener_web (~> 2.0) link_header (~> 0.0) lograge (~> 0.12) - makara (~> 0.5) mario-redis-lock (~> 1.2) memory_profiler mime-types (~> 3.4.1) diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb index ae6dbcb8b..0ee28ef04 100644 --- a/app/controllers/api/v1/timelines/home_controller.rb +++ b/app/controllers/api/v1/timelines/home_controller.rb @@ -6,11 +6,14 @@ class Api::V1::Timelines::HomeController < Api::BaseController after_action :insert_pagination_headers, unless: -> { @statuses.empty? } def show - @statuses = load_statuses + ApplicationRecord.connected_to(role: :read, prevent_writes: true) do + @statuses = load_statuses + @relationships = StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + end render json: @statuses, each_serializer: REST::StatusSerializer, - relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + relationships: @relationships, status: account_home_feed.regenerating? ? 206 : 200 end diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb index 758cebd4b..47826c211 100644 --- a/app/workers/feed_insert_worker.rb +++ b/app/workers/feed_insert_worker.rb @@ -4,19 +4,23 @@ class FeedInsertWorker include Sidekiq::Worker def perform(status_id, id, type = 'home', options = {}) - @type = type.to_sym - @status = Status.find(status_id) - @options = options.symbolize_keys + ApplicationRecord.connected_to(role: :primary) do + @type = type.to_sym + @status = Status.find(status_id) + @options = options.symbolize_keys - case @type - when :home, :tags - @follower = Account.find(id) - when :list - @list = List.find(id) - @follower = @list.account + case @type + when :home, :tags + @follower = Account.find(id) + when :list + @list = List.find(id) + @follower = @list.account + end end - check_and_insert + ApplicationRecord.connected_to(role: :read, prevent_writes: true) do + check_and_insert + end rescue ActiveRecord::RecordNotFound true end diff --git a/config/database.yml b/config/database.yml index 34acf2f19..f7ecbd981 100644 --- a/config/database.yml +++ b/config/database.yml @@ -27,10 +27,20 @@ test: port: <%= ENV['DB_PORT'] %> production: - <<: *default - database: <%= ENV['DB_NAME'] || 'mastodon_production' %> - username: <%= ENV['DB_USER'] || 'mastodon' %> - password: <%= (ENV['DB_PASS'] || '').to_json %> - host: <%= ENV['DB_HOST'] || 'localhost' %> - port: <%= ENV['DB_PORT'] || 5432 %> - prepared_statements: <%= ENV['PREPARED_STATEMENTS'] || 'true' %> + primary: + <<: *default + database: <%= ENV['DB_NAME'] || 'mastodon_production' %> + username: <%= ENV['DB_USER'] || 'mastodon' %> + password: <%= (ENV['DB_PASS'] || '').to_json %> + host: <%= ENV['DB_HOST'] || 'localhost' %> + port: <%= ENV['DB_PORT'] || 5432 %> + prepared_statements: <%= ENV['PREPARED_STATEMENTS'] || 'true' %> + read: + <<: *default + database: <%= ENV['DB_REPLICA_NAME'] ||ENV['DB_NAME'] || 'mastodon_production' %> + username: <%= ENV['DB_REPLICA_USER'] ||ENV['DB_USER'] || 'mastodon' %> + password: <%= (ENV['DB_REPLICA_PASS'] || ENV['DB_PASS'] || '').to_json %> + host: <%= ENV['DB_REPLICA_HOST'] ||ENV['DB_HOST'] || 'localhost' %> + port: <%= ENV['DB_REPLICA_PORT'] ||ENV['DB_PORT'] || 5432 %> + prepared_statements: <%= ENV['PREPARED_STATEMENTS'] || 'true' %> + replica: true diff --git a/config/initializers/makara.rb b/config/initializers/makara.rb deleted file mode 100644 index dc88fa63c..000000000 --- a/config/initializers/makara.rb +++ /dev/null @@ -1,2 +0,0 @@ -Makara::Cookie::DEFAULT_OPTIONS[:same_site] = :lax -Makara::Cookie::DEFAULT_OPTIONS[:secure] = Rails.env.production? || ENV['LOCAL_HTTPS'] == 'true' From 93e8a15415c35dbe4089b5d20c08161646ba76b3 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 8 Jul 2023 20:00:02 +0200 Subject: [PATCH 2/6] Add forwarding of reported replies to servers being replied to (#25341) --- app/lib/activitypub/activity/flag.rb | 9 +++--- app/services/report_service.rb | 14 +++++---- spec/services/report_service_spec.rb | 43 +++++++++++++++++++++------- 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/app/lib/activitypub/activity/flag.rb b/app/lib/activitypub/activity/flag.rb index dc1932f59..304cf0ad2 100644 --- a/app/lib/activitypub/activity/flag.rb +++ b/app/lib/activitypub/activity/flag.rb @@ -4,13 +4,14 @@ class ActivityPub::Activity::Flag < ActivityPub::Activity def perform return if skip_reports? - target_accounts = object_uris.filter_map { |uri| account_from_uri(uri) }.select(&:local?) - target_statuses_by_account = object_uris.filter_map { |uri| status_from_uri(uri) }.select(&:local?).group_by(&:account_id) + target_accounts = object_uris.filter_map { |uri| account_from_uri(uri) } + target_statuses_by_account = object_uris.filter_map { |uri| status_from_uri(uri) }.group_by(&:account_id) target_accounts.each do |target_account| - target_statuses = target_statuses_by_account[target_account.id] + target_statuses = target_statuses_by_account[target_account.id] + replied_to_accounts = Account.local.where(id: target_statuses.filter_map(&:in_reply_to_account_id)) - next if target_account.suspended? + next if target_account.suspended? || (!target_account.local? && replied_to_accounts.none?) ReportService.new.call( @account, diff --git a/app/services/report_service.rb b/app/services/report_service.rb index 0ce525b07..3444e1dfa 100644 --- a/app/services/report_service.rb +++ b/app/services/report_service.rb @@ -45,11 +45,15 @@ class ReportService < BaseService end def forward_to_origin! - ActivityPub::DeliveryWorker.perform_async( - payload, - some_local_account.id, - @target_account.inbox_url - ) + # Send report to the server where the account originates from + ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, @target_account.inbox_url) + + # Send report to servers to which the account was replying to, so they also have a chance to act + inbox_urls = Account.remote.where(id: Status.where(id: reported_status_ids).where.not(in_reply_to_account_id: nil).select(:in_reply_to_account_id)).inboxes - [@target_account.inbox_url] + + inbox_urls.each do |inbox_url| + ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url) + end end def forward? diff --git a/spec/services/report_service_spec.rb b/spec/services/report_service_spec.rb index b8ceedb85..660ce3db2 100644 --- a/spec/services/report_service_spec.rb +++ b/spec/services/report_service_spec.rb @@ -17,24 +17,45 @@ RSpec.describe ReportService, type: :service do context 'with a remote account' do let(:remote_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') } + let(:forward) { false } before do stub_request(:post, 'http://example.com/inbox').to_return(status: 200) end - it 'sends ActivityPub payload when forward is true' do - subject.call(source_account, remote_account, forward: true) - expect(a_request(:post, 'http://example.com/inbox')).to have_been_made + context 'when forward is true' do + let(:forward) { true } + + it 'sends ActivityPub payload when forward is true' do + subject.call(source_account, remote_account, forward: forward) + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made + end + + it 'has an uri' do + report = subject.call(source_account, remote_account, forward: forward) + expect(report.uri).to_not be_nil + end + + context 'when reporting a reply' do + let(:remote_thread_account) { Fabricate(:account, domain: 'foo.com', protocol: :activitypub, inbox_url: 'http://foo.com/inbox') } + let(:reported_status) { Fabricate(:status, account: remote_account, thread: Fabricate(:status, account: remote_thread_account)) } + + before do + stub_request(:post, 'http://foo.com/inbox').to_return(status: 200) + end + + it 'sends ActivityPub payload to the author of the replied-to post' do + subject.call(source_account, remote_account, status_ids: [reported_status.id], forward: forward) + expect(a_request(:post, 'http://foo.com/inbox')).to have_been_made + end + end end - it 'does not send anything when forward is false' do - subject.call(source_account, remote_account, forward: false) - expect(a_request(:post, 'http://example.com/inbox')).to_not have_been_made - end - - it 'has an uri' do - report = subject.call(source_account, remote_account, forward: true) - expect(report.uri).to_not be_nil + context 'when forward is false' do + it 'does not send anything' do + subject.call(source_account, remote_account, forward: forward) + expect(a_request(:post, 'http://example.com/inbox')).to_not have_been_made + end end end From ceeb2b8c419bce653fd9296c42ab655aa6a816f3 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 8 Jul 2023 20:00:12 +0200 Subject: [PATCH 3/6] Fix explore page being inaccessible when opted-out of trends in web UI (#25716) --- app/javascript/mastodon/features/explore/index.jsx | 4 ++-- .../mastodon/features/ui/components/navigation_panel.jsx | 4 ++-- app/javascript/mastodon/features/ui/index.jsx | 4 ++-- app/javascript/mastodon/initial_state.js | 6 ++++-- app/serializers/initial_state_serializer.rb | 4 ++-- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/javascript/mastodon/features/explore/index.jsx b/app/javascript/mastodon/features/explore/index.jsx index 185db0732..1a66adc87 100644 --- a/app/javascript/mastodon/features/explore/index.jsx +++ b/app/javascript/mastodon/features/explore/index.jsx @@ -11,7 +11,7 @@ import { connect } from 'react-redux'; import Column from 'mastodon/components/column'; import ColumnHeader from 'mastodon/components/column_header'; import Search from 'mastodon/features/compose/containers/search_container'; -import { showTrends } from 'mastodon/initial_state'; +import { trendsEnabled } from 'mastodon/initial_state'; import Links from './links'; import SearchResults from './results'; @@ -26,7 +26,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ layout: state.getIn(['meta', 'layout']), - isSearching: state.getIn(['search', 'submitted']) || !showTrends, + isSearching: state.getIn(['search', 'submitted']) || !trendsEnabled, }); class Explore extends PureComponent { diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx index d5e98461a..dc406fa55 100644 --- a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx @@ -7,7 +7,7 @@ import { Link } from 'react-router-dom'; import { WordmarkLogo } from 'mastodon/components/logo'; import NavigationPortal from 'mastodon/components/navigation_portal'; -import { timelinePreview, showTrends } from 'mastodon/initial_state'; +import { timelinePreview, trendsEnabled } from 'mastodon/initial_state'; import ColumnLink from './column_link'; import DisabledAccountBanner from './disabled_account_banner'; @@ -65,7 +65,7 @@ class NavigationPanel extends Component { )} - {showTrends ? ( + {trendsEnabled ? ( ) : ( diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 59327f049..b38acfc14 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -22,7 +22,7 @@ import { clearHeight } from '../../actions/height_cache'; import { expandNotifications } from '../../actions/notifications'; import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server'; import { expandHomeTimeline } from '../../actions/timelines'; -import initialState, { me, owner, singleUserMode, showTrends, trendsAsLanding } from '../../initial_state'; +import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding } from '../../initial_state'; import BundleColumnError from './components/bundle_column_error'; import Header from './components/header'; @@ -170,7 +170,7 @@ class SwitchingColumnsArea extends PureComponent { } } else if (singleUserMode && owner && initialState?.accounts[owner]) { redirect = ; - } else if (showTrends && trendsAsLanding) { + } else if (trendsEnabled && trendsAsLanding) { redirect = ; } else { redirect = ; diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 1f0f9d5b1..5ad61e1f6 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -69,12 +69,13 @@ * @property {boolean} reduce_motion * @property {string} repository * @property {boolean} search_enabled + * @property {boolean} trends_enabled * @property {boolean} single_user_mode * @property {string} source_url * @property {string} streaming_api_base_url * @property {boolean} timeline_preview * @property {string} title - * @property {boolean} trends + * @property {boolean} show_trends * @property {boolean} trends_as_landing_page * @property {boolean} unfollow_modal * @property {boolean} use_blurhash @@ -121,7 +122,8 @@ export const reduceMotion = getMeta('reduce_motion'); export const registrationsOpen = getMeta('registrations_open'); export const repository = getMeta('repository'); export const searchEnabled = getMeta('search_enabled'); -export const showTrends = getMeta('trends'); +export const trendsEnabled = getMeta('trends_enabled'); +export const showTrends = getMeta('show_trends'); export const singleUserMode = getMeta('single_user_mode'); export const source_url = getMeta('source_url'); export const timelinePreview = getMeta('timeline_preview'); diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 769ba653e..7676942a7 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -25,7 +25,7 @@ class InitialStateSerializer < ActiveModel::Serializer limited_federation_mode: Rails.configuration.x.whitelist_mode, mascot: instance_presenter.mascot&.file&.url, profile_directory: Setting.profile_directory, - trends: Setting.trends, + trends_enabled: Setting.trends, registrations_open: Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode, timeline_preview: Setting.timeline_preview, activity_api_enabled: Setting.activity_api_enabled, @@ -47,7 +47,7 @@ class InitialStateSerializer < ActiveModel::Serializer store[:advanced_layout] = object.current_account.user.setting_advanced_layout store[:use_blurhash] = object.current_account.user.setting_use_blurhash store[:use_pending_items] = object.current_account.user.setting_use_pending_items - store[:trends] = Setting.trends && object.current_account.user.setting_trends + store[:show_trends] = Setting.trends && object.current_account.user.setting_trends store[:crop_images] = object.current_account.user.setting_crop_images else store[:auto_play_gif] = Setting.auto_play_gif From a8edbcf963f5d029f5af020fe7d736e6255638be Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 8 Jul 2023 20:00:52 +0200 Subject: [PATCH 4/6] Fix dropdowns being disabled for logged out users in web UI (#25714) --- .../mastodon/components/status_action_bar.jsx | 114 +++++++++--------- .../features/account/components/header.jsx | 1 - .../features/status/components/action_bar.jsx | 109 +++++++++-------- 3 files changed, 113 insertions(+), 111 deletions(-) diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index 8b3c20f82..ab9dac27e 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -237,7 +237,6 @@ class StatusActionBar extends ImmutablePureComponent { const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props; const { signedIn, permissions } = this.context.identity; - const anonymousAccess = !signedIn; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility')); const mutingConversation = status.get('muted'); @@ -263,71 +262,73 @@ class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); } - menu.push(null); - - menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick }); - - if (writtenByMe && pinnableStatus) { - menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); - } - - menu.push(null); - - if (writtenByMe || withDismiss) { - menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); - menu.push(null); - } - - if (writtenByMe) { - menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); - menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true }); - menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true }); - } else { - menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.handleMentionClick }); - menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick }); + if (signedIn) { menu.push(null); - if (relationship && relationship.get('muting')) { - menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick }); + menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick }); + + if (writtenByMe && pinnableStatus) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); + } + + menu.push(null); + + if (writtenByMe || withDismiss) { + menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); + menu.push(null); + } + + if (writtenByMe) { + menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true }); + menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true }); } else { - menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick, dangerous: true }); - } - - if (relationship && relationship.get('blocking')) { - menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.handleBlockClick }); - } else { - menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick, dangerous: true }); - } - - if (!this.props.onFilter) { - menu.push(null); - menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick, dangerous: true }); - menu.push(null); - } - - menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport, dangerous: true }); - - if (account.get('acct') !== account.get('username')) { - const domain = account.get('acct').split('@')[1]; - + menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick }); menu.push(null); - if (relationship && relationship.get('domain_blocking')) { - menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.handleUnblockDomain }); + if (relationship && relationship.get('muting')) { + menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick }); } else { - menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain, dangerous: true }); + menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick, dangerous: true }); } - } - if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) { - menu.push(null); - if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { - menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); - menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` }); + if (relationship && relationship.get('blocking')) { + menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.handleBlockClick }); + } else { + menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick, dangerous: true }); } - if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) { + + if (!this.props.onFilter) { + menu.push(null); + menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick, dangerous: true }); + menu.push(null); + } + + menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport, dangerous: true }); + + if (account.get('acct') !== account.get('username')) { const domain = account.get('acct').split('@')[1]; - menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` }); + + menu.push(null); + + if (relationship && relationship.get('domain_blocking')) { + menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.handleUnblockDomain }); + } else { + menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain, dangerous: true }); + } + } + + if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) { + menu.push(null); + if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { + menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); + menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` }); + } + if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) { + const domain = account.get('acct').split('@')[1]; + menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` }); + } } } } @@ -371,7 +372,6 @@ class StatusActionBar extends ImmutablePureComponent {
- +
); From a7ca33ad96d4ff8ae3b714d7dfbaebc962a86c27 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 8 Jul 2023 20:01:08 +0200 Subject: [PATCH 5/6] Add toast with option to open post after publishing in web UI (#25564) --- app/javascript/mastodon/actions/alerts.js | 64 +++++++++---------- app/javascript/mastodon/actions/compose.js | 18 ++++-- .../containers/column_settings_container.js | 4 +- .../ui/containers/notifications_container.js | 37 +++++------ app/javascript/mastodon/locales/en.json | 2 + app/javascript/mastodon/reducers/alerts.js | 19 +++--- app/javascript/mastodon/selectors/index.js | 28 +++----- .../styles/mastodon/components.scss | 59 +++++++++++++++++ 8 files changed, 146 insertions(+), 85 deletions(-) diff --git a/app/javascript/mastodon/actions/alerts.js b/app/javascript/mastodon/actions/alerts.js index 0220b0af5..051a9675b 100644 --- a/app/javascript/mastodon/actions/alerts.js +++ b/app/javascript/mastodon/actions/alerts.js @@ -12,52 +12,48 @@ export const ALERT_DISMISS = 'ALERT_DISMISS'; export const ALERT_CLEAR = 'ALERT_CLEAR'; export const ALERT_NOOP = 'ALERT_NOOP'; -export function dismissAlert(alert) { - return { - type: ALERT_DISMISS, - alert, - }; -} +export const dismissAlert = alert => ({ + type: ALERT_DISMISS, + alert, +}); -export function clearAlert() { - return { - type: ALERT_CLEAR, - }; -} +export const clearAlert = () => ({ + type: ALERT_CLEAR, +}); -export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) { - return { - type: ALERT_SHOW, - title, - message, - message_values, - }; -} +export const showAlert = alert => ({ + type: ALERT_SHOW, + alert, +}); -export function showAlertForError(error, skipNotFound = false) { +export const showAlertForError = (error, skipNotFound = false) => { if (error.response) { const { data, status, statusText, headers } = error.response; + // Skip these errors as they are reflected in the UI if (skipNotFound && (status === 404 || status === 410)) { - // Skip these errors as they are reflected in the UI return { type: ALERT_NOOP }; } + // Rate limit errors if (status === 429 && headers['x-ratelimit-reset']) { - const reset_date = new Date(headers['x-ratelimit-reset']); - return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date }); + return showAlert({ + title: messages.rateLimitedTitle, + message: messages.rateLimitedMessage, + values: { 'retry_time': new Date(headers['x-ratelimit-reset']) }, + }); } - let message = statusText; - let title = `${status}`; - - if (data.error) { - message = data.error; - } - - return showAlert(title, message); - } else { - console.error(error); - return showAlert(); + return showAlert({ + title: `${status}`, + message: data.error || statusText, + }); } + + console.error(error); + + return showAlert({ + title: messages.unexpectedTitle, + message: messages.unexpectedMessage, + }); } diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 99610ac31..260fb43f0 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -82,6 +82,8 @@ export const COMPOSE_FOCUS = 'COMPOSE_FOCUS'; const messages = defineMessages({ uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, + open: { id: 'compose.published.open', defaultMessage: 'Open' }, + published: { id: 'compose.published.body', defaultMessage: 'Post published.' }, }); export const ensureComposeIsVisible = (getState, routerHistory) => { @@ -240,6 +242,13 @@ export function submitCompose(routerHistory) { insertIfOnline('public'); insertIfOnline(`account:${response.data.account.id}`); } + + dispatch(showAlert({ + message: messages.published, + action: messages.open, + dismissAfter: 10000, + onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`), + })); }).catch(function (error) { dispatch(submitComposeFail(error)); }); @@ -269,18 +278,19 @@ export function submitComposeFail(error) { export function uploadCompose(files) { return function (dispatch, getState) { const uploadLimit = 4; - const media = getState().getIn(['compose', 'media_attachments']); - const pending = getState().getIn(['compose', 'pending_media_attachments']); + const media = getState().getIn(['compose', 'media_attachments']); + const pending = getState().getIn(['compose', 'pending_media_attachments']); const progress = new Array(files.length).fill(0); + let total = Array.from(files).reduce((a, v) => a + v.size, 0); if (files.length + media.size + pending > uploadLimit) { - dispatch(showAlert(undefined, messages.uploadErrorLimit)); + dispatch(showAlert({ message: messages.uploadErrorLimit })); return; } if (getState().getIn(['compose', 'poll'])) { - dispatch(showAlert(undefined, messages.uploadErrorPoll)); + dispatch(showAlert({ message: messages.uploadErrorPoll })); return; } diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js index b63796a8b..1e62ed9a5 100644 --- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js +++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js @@ -32,7 +32,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ if (permission === 'granted') { dispatch(changePushNotifications(path.slice(1), checked)); } else { - dispatch(showAlert(undefined, messages.permissionDenied)); + dispatch(showAlert({ message: messages.permissionDenied })); } })); } else { @@ -47,7 +47,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ if (permission === 'granted') { dispatch(changeSetting(['notifications', ...path], checked)); } else { - dispatch(showAlert(undefined, messages.permissionDenied)); + dispatch(showAlert({ message: messages.permissionDenied })); } })); } else { diff --git a/app/javascript/mastodon/features/ui/containers/notifications_container.js b/app/javascript/mastodon/features/ui/containers/notifications_container.js index c1d19f710..3d60cfdad 100644 --- a/app/javascript/mastodon/features/ui/containers/notifications_container.js +++ b/app/javascript/mastodon/features/ui/containers/notifications_container.js @@ -7,26 +7,27 @@ import { NotificationStack } from 'react-notification'; import { dismissAlert } from '../../../actions/alerts'; import { getAlerts } from '../../../selectors'; -const mapStateToProps = (state, { intl }) => { - const notifications = getAlerts(state); +const formatIfNeeded = (intl, message, values) => { + if (typeof message === 'object') { + return intl.formatMessage(message, values); + } - notifications.forEach(notification => ['title', 'message'].forEach(key => { - const value = notification[key]; - - if (typeof value === 'object') { - notification[key] = intl.formatMessage(value, notification[`${key}_values`]); - } - })); - - return { notifications }; + return message; }; -const mapDispatchToProps = (dispatch) => { - return { - onDismiss: alert => { - dispatch(dismissAlert(alert)); - }, - }; -}; +const mapStateToProps = (state, { intl }) => ({ + notifications: getAlerts(state).map(alert => ({ + ...alert, + action: formatIfNeeded(intl, alert.action, alert.values), + title: formatIfNeeded(intl, alert.title, alert.values), + message: formatIfNeeded(intl, alert.message, alert.values), + })), +}); + +const mapDispatchToProps = (dispatch) => ({ + onDismiss (alert) { + dispatch(dismissAlert(alert)); + }, +}); export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack)); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 2afac7e7e..8705e6cd6 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -135,6 +135,8 @@ "community.column_settings.remote_only": "Remote only", "compose.language.change": "Change language", "compose.language.search": "Search languages...", + "compose.published.body": "Post published.", + "compose.published.open": "Open", "compose_form.direct_message_warning_learn_more": "Learn more", "compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any sensitive information over Mastodon.", "compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is not public. Only public posts can be searched by hashtag.", diff --git a/app/javascript/mastodon/reducers/alerts.js b/app/javascript/mastodon/reducers/alerts.js index bd49d748f..1ca9b62a0 100644 --- a/app/javascript/mastodon/reducers/alerts.js +++ b/app/javascript/mastodon/reducers/alerts.js @@ -1,4 +1,4 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { List as ImmutableList } from 'immutable'; import { ALERT_SHOW, @@ -8,17 +8,20 @@ import { const initialState = ImmutableList([]); +let id = 0; + +const addAlert = (state, alert) => + state.push({ + key: id++, + ...alert, + }); + export default function alerts(state = initialState, action) { switch(action.type) { case ALERT_SHOW: - return state.push(ImmutableMap({ - key: state.size > 0 ? state.last().get('key') + 1 : 0, - title: action.title, - message: action.message, - message_values: action.message_values, - })); + return addAlert(state, action.alert); case ALERT_DISMISS: - return state.filterNot(item => item.get('key') === action.alert.key); + return state.filterNot(item => item.key === action.alert.key); case ALERT_CLEAR: return state.clear(); default: diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index f92e7fe48..0968fb090 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -84,26 +84,16 @@ export const makeGetPictureInPicture = () => { })); }; -const getAlertsBase = state => state.get('alerts'); +const ALERT_DEFAULTS = { + dismissAfter: 5000, + style: false, +}; -export const getAlerts = createSelector([getAlertsBase], (base) => { - let arr = []; - - base.forEach(item => { - arr.push({ - message: item.get('message'), - message_values: item.get('message_values'), - title: item.get('title'), - key: item.get('key'), - dismissAfter: 5000, - barStyle: { - zIndex: 200, - }, - }); - }); - - return arr; -}); +export const getAlerts = createSelector(state => state.get('alerts'), alerts => + alerts.map(item => ({ + ...ALERT_DEFAULTS, + ...item, + })).toArray()); export const makeGetNotification = () => createSelector([ (_, base) => base, diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 5e261e1ee..434a2f542 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -9077,3 +9077,62 @@ noscript { } } } + +.notification-list { + position: fixed; + bottom: 2rem; + inset-inline-start: 0; + z-index: 999; + display: flex; + flex-direction: column; + gap: 4px; +} + +.notification-bar { + flex: 0 0 auto; + position: relative; + inset-inline-start: -100%; + width: auto; + padding: 15px; + margin: 0; + color: $primary-text-color; + background: rgba($black, 0.85); + backdrop-filter: blur(8px); + border: 1px solid rgba(lighten($ui-base-color, 4%), 0.85); + border-radius: 8px; + box-shadow: 0 10px 15px -3px rgba($base-shadow-color, 0.25), + 0 4px 6px -4px rgba($base-shadow-color, 0.25); + cursor: default; + transition: 0.5s cubic-bezier(0.89, 0.01, 0.5, 1.1); + transform: translateZ(0); + font-size: 15px; + line-height: 21px; + + &.notification-bar-active { + inset-inline-start: 1rem; + } +} + +.notification-bar-title { + margin-inline-end: 5px; +} + +.notification-bar-title, +.notification-bar-action { + font-weight: 700; +} + +.notification-bar-action { + text-transform: uppercase; + margin-inline-start: 10px; + cursor: pointer; + color: $highlight-text-color; + border-radius: 4px; + padding: 0 4px; + + &:hover, + &:focus, + &:active { + background: rgba($ui-base-color, 0.85); + } +} From 41a505513fb36f7c28c8d8a4270d5ee192169462 Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Sat, 8 Jul 2023 20:02:14 +0200 Subject: [PATCH 6/6] Remove unused `missed_update` state (#25832) --- app/javascript/mastodon/reducers/index.ts | 2 -- .../mastodon/reducers/missed_updates.ts | 33 ------------------- 2 files changed, 35 deletions(-) delete mode 100644 app/javascript/mastodon/reducers/missed_updates.ts diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index 67aa5f6c5..ad3077e37 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -26,7 +26,6 @@ import lists from './lists'; import markers from './markers'; import media_attachments from './media_attachments'; import meta from './meta'; -import { missedUpdatesReducer } from './missed_updates'; import { modalReducer } from './modal'; import mutes from './mutes'; import notifications from './notifications'; @@ -82,7 +81,6 @@ const reducers = { suggestions, polls, trends, - missed_updates: missedUpdatesReducer, markers, picture_in_picture, history, diff --git a/app/javascript/mastodon/reducers/missed_updates.ts b/app/javascript/mastodon/reducers/missed_updates.ts deleted file mode 100644 index a587fcb03..000000000 --- a/app/javascript/mastodon/reducers/missed_updates.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Record } from 'immutable'; - -import type { Action } from 'redux'; - -import { focusApp, unfocusApp } from '../actions/app'; -import { NOTIFICATIONS_UPDATE } from '../actions/notifications'; - -interface MissedUpdatesState { - focused: boolean; - unread: number; -} -const initialState = Record({ - focused: true, - unread: 0, -})(); - -export function missedUpdatesReducer( - state = initialState, - action: Action -) { - switch (action.type) { - case focusApp.type: - return state.set('focused', true).set('unread', 0); - case unfocusApp.type: - return state.set('focused', false); - case NOTIFICATIONS_UPDATE: - return state.get('focused') - ? state - : state.update('unread', (x) => x + 1); - default: - return state; - } -}