From 513078de7acea9eb3809e04c8b9bfb67f3039d60 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 7 Jul 2023 09:50:06 +0200 Subject: [PATCH 0001/1942] Fix incorrect secondary button size (#2276) --- app/javascript/flavours/glitch/styles/components/misc.scss | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/javascript/flavours/glitch/styles/components/misc.scss b/app/javascript/flavours/glitch/styles/components/misc.scss index c8c227e0c..ef9044050 100644 --- a/app/javascript/flavours/glitch/styles/components/misc.scss +++ b/app/javascript/flavours/glitch/styles/components/misc.scss @@ -80,11 +80,7 @@ } &.button-secondary { - font-size: 16px; - line-height: 36px; - height: auto; color: $ui-button-secondary-color; - text-transform: none; background: transparent; padding: 6px 17px; border: 1px solid $ui-button-secondary-border-color; From 94fbac77e79b080b3340672fb4d14c97bc893c6c Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 7 Jul 2023 13:35:22 +0200 Subject: [PATCH 0002/1942] Fix processing of media files with unusual names (#25788) --- app/models/concerns/attachmentable.rb | 2 +- .../fixtures/files/attachment-jpg.123456_abcd | Bin 0 -> 61022 bytes spec/requests/api/v2/media_spec.rb | 18 ++++++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 spec/fixtures/files/attachment-jpg.123456_abcd create mode 100644 spec/requests/api/v2/media_spec.rb diff --git a/app/models/concerns/attachmentable.rb b/app/models/concerns/attachmentable.rb index f93ee4c91..c0ee1bdce 100644 --- a/app/models/concerns/attachmentable.rb +++ b/app/models/concerns/attachmentable.rb @@ -24,7 +24,7 @@ module Attachmentable def self.has_attached_file(name, options = {}) # rubocop:disable Naming/PredicateName super(name, options) - send(:"before_#{name}_validate") do + send(:"before_#{name}_validate", prepend: true) do attachment = send(name) check_image_dimension(attachment) set_file_content_type(attachment) diff --git a/spec/fixtures/files/attachment-jpg.123456_abcd b/spec/fixtures/files/attachment-jpg.123456_abcd new file mode 100644 index 0000000000000000000000000000000000000000..f1d40539ac0484516a4f72989d28d1bd33e46d41 GIT binary patch literal 61022 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 Bw1ofw literal 0 HcmV?d00001 diff --git a/spec/requests/api/v2/media_spec.rb b/spec/requests/api/v2/media_spec.rb new file mode 100644 index 000000000..89384d0ca --- /dev/null +++ b/spec/requests/api/v2/media_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Media API', paperclip_processing: true do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'write' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'POST /api/v2/media' do + it 'returns http success' do + post '/api/v2/media', headers: headers, params: { file: fixture_file_upload('attachment-jpg.123456_abcd', 'image/jpeg') } + expect(File.exist?(user.account.media_attachments.first.file.path(:small))).to be true + expect(response).to have_http_status(200) + end + end +end From 8b624553efbf43ed5bf242f49a3f81e2730f489e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 7 Jul 2023 13:35:54 +0200 Subject: [PATCH 0003/1942] Update dependency sanitize to v6.0.2 [SECURITY] (#25777) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index b2d75e9d4..985e36c20 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -432,7 +432,7 @@ GEM net-protocol net-ssh (7.1.0) nio4r (2.5.9) - nokogiri (1.15.2) + nokogiri (1.15.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) oj (3.15.0) @@ -628,7 +628,7 @@ GEM fugit (~> 1.1, >= 1.1.6) safety_net_attestation (0.4.0) jwt (~> 2.0) - sanitize (6.0.1) + sanitize (6.0.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) scenic (1.7.0) From dfedf0ec64983952e8fd91d0fb95b1f332edfd55 Mon Sep 17 00:00:00 2001 From: nemobis Date: Fri, 7 Jul 2023 15:15:54 +0300 Subject: [PATCH 0004/1942] Fix typo in CHANGELOG.md (#25764) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 425c09850..f16157281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ All notable changes to this project will be documented in this file. - Fix multiple inefficiencies in automatic post cleanup worker ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24607), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24785), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24840)) - Fix performance of streaming by parsing message JSON once ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25278), [ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25361)) - Fix CSP headers when `S3_ALIAS_HOST` includes a path component ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25273)) -- Fix `tootctl accounts approve --number N` not aproving N earliest registrations ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24605)) +- Fix `tootctl accounts approve --number N` not approving N earliest registrations ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24605)) - Fix reports not being closed when performing batch suspensions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24988)) - Fix being able to vote on your own polls ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25015)) - Fix race condition when reblogging a status ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25016)) From 71d44949bf358421ffe2eec9ae040a3e95c79151 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 7 Jul 2023 18:10:00 +0200 Subject: [PATCH 0005/1942] Fix branding:generate_app_icons failing because of disallowed ICO coder (#25794) --- lib/tasks/branding.rake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tasks/branding.rake b/lib/tasks/branding.rake index d1c1c9ded..d97c97c99 100644 --- a/lib/tasks/branding.rake +++ b/lib/tasks/branding.rake @@ -40,7 +40,7 @@ namespace :branding do output_dest = Rails.root.join('app', 'javascript', 'icons') rsvg_convert = Terrapin::CommandLine.new('rsvg-convert', '-w :size -h :size --keep-aspect-ratio :input -o :output') - convert = Terrapin::CommandLine.new('convert', ':input :output') + convert = Terrapin::CommandLine.new('convert', ':input :output', environment: { 'MAGICK_CONFIGURE_PATH' => nil }) favicon_sizes = [16, 32, 48] apple_icon_sizes = [57, 60, 72, 76, 114, 120, 144, 152, 167, 180, 1024] From b6d173b4598db3e4c4b8f78f100d39dcb5e71f31 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 7 Jul 2023 18:10:17 +0200 Subject: [PATCH 0006/1942] Fix crash in admin interface when viewing a remote user with verified links (#25796) --- app/lib/text_formatter.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/text_formatter.rb b/app/lib/text_formatter.rb index 3570632dd..04b34cf19 100644 --- a/app/lib/text_formatter.rb +++ b/app/lib/text_formatter.rb @@ -60,7 +60,7 @@ class TextFormatter suffix = url[prefix.length + 30..-1] cutoff = url[prefix.length..-1].length > 30 - <<~HTML.squish + <<~HTML.squish.html_safe # rubocop:disable Rails/OutputSafety #{h(display_url)} HTML rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError From d481e72e85d5243b40c2a22c272c7e9f6404a568 Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Fri, 7 Jul 2023 19:31:55 +0200 Subject: [PATCH 0007/1942] Tag images with the latest tag only when running against the latest stable branch (#25803) --- .github/workflows/build-image.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 982031c9d..f9dd36e36 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -49,8 +49,10 @@ jobs: images: | tootsuite/mastodon ghcr.io/mastodon/mastodon + # Only tag with latest when ran against the latest stable branch + # This needs to be updated after each minor version release flavor: | - latest=auto + latest=${{ startsWith(github.ref, 'refs/tags/v4.1.') && 'auto' || 'false' }} tags: | type=edge,branch=main type=pep440,pattern={{raw}} From 0051128387ee97212f51bf2c7baa78b6757f4c81 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 7 Jul 2023 19:42:03 +0200 Subject: [PATCH 0008/1942] Bump version to v4.1.4 (#25805) --- CHANGELOG.md | 8 ++++++++ lib/mastodon/version.rb | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f16157281..d6f1b7bcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. +## [4.1.4] - 2023-07-07 + +### Fixed + +- Fix branding:generate_app_icons failing because of disallowed ICO coder ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25794)) +- Fix crash in admin interface when viewing a remote user with verified links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25796)) +- Fix processing of media files with unusual names ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25788)) + ## [4.1.3] - 2023-07-06 ### Added diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 6ef388613..cbec893e0 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 3 + 4 end def flags From 9f078e238d389199f9db42e408ec5606f5001417 Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Sat, 8 Jul 2023 00:12:31 +0200 Subject: [PATCH 0009/1942] Fix translate button position (#25807) --- app/javascript/mastodon/components/status_content.jsx | 2 +- app/javascript/styles/contrast/diff.scss | 3 ++- app/javascript/styles/mastodon/components.scss | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 688a45631..84a698810 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -44,7 +44,7 @@ class TranslateButton extends PureComponent { } return ( - ); diff --git a/app/javascript/styles/contrast/diff.scss b/app/javascript/styles/contrast/diff.scss index 4fa1a0361..1c2386f02 100644 --- a/app/javascript/styles/contrast/diff.scss +++ b/app/javascript/styles/contrast/diff.scss @@ -15,7 +15,8 @@ .status__content a, .link-footer a, .reply-indicator__content a, -.status__content__read-more-button { +.status__content__read-more-button, +.status__content__translate-button { text-decoration: underline; &:hover, diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index e66f8bdfe..5e261e1ee 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -981,7 +981,8 @@ body > [data-popper-placement] { max-height: 22px * 15; // 15 lines is roughly above 500 characters } -.status__content__read-more-button { +.status__content__read-more-button, +.status__content__translate-button { display: block; font-size: 15px; line-height: 22px; From 0f9b803eb33bfa7461e67bf003da3b8d0d1f22f8 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sat, 8 Jul 2023 05:07:19 -0400 Subject: [PATCH 0010/1942] Regenerate brakeman ignore, pruning warnings (#25749) --- config/brakeman.ignore | 92 +++++++++++++----------------------------- 1 file changed, 28 insertions(+), 64 deletions(-) diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 80c5f6d4e..d89591cfe 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -18,6 +18,9 @@ }, "user_input": "id", "confidence": "Weak", + "cwe_id": [ + 89 + ], "note": "" }, { @@ -38,26 +41,9 @@ }, "user_input": "ids.join(\",\")", "confidence": "Weak", - "note": "" - }, - { - "warning_type": "Redirect", - "warning_code": 18, - "fingerprint": "5fad11cd67f905fab9b1d5739d01384a1748ebe78c5af5ac31518201925265a7", - "check_name": "Redirect", - "message": "Possible unprotected redirect", - "file": "app/controllers/remote_interaction_controller.rb", - "line": 24, - "link": "https://brakemanscanner.org/docs/warning_types/redirect/", - "code": "redirect_to(RemoteFollow.new(resource_params).interact_address_for(Status.find(params[:id])))", - "render_path": null, - "location": { - "type": "method", - "class": "RemoteInteractionController", - "method": "create" - }, - "user_input": "RemoteFollow.new(resource_params).interact_address_for(Status.find(params[:id]))", - "confidence": "High", + "cwe_id": [ + 89 + ], "note": "" }, { @@ -88,6 +74,9 @@ }, "user_input": "(Unresolved Model).new.strike", "confidence": "Weak", + "cwe_id": [ + 79 + ], "note": "" }, { @@ -108,26 +97,9 @@ }, "user_input": "SecureRandom.hex(16)", "confidence": "Medium", - "note": "" - }, - { - "warning_type": "Mass Assignment", - "warning_code": 105, - "fingerprint": "7631e93d0099506e7c3e5c91ba8d88523b00a41a0834ae30031a5a4e8bb3020a", - "check_name": "PermitAttributes", - "message": "Potentially dangerous key allowed for mass assignment", - "file": "app/controllers/api/v2/search_controller.rb", - "line": 28, - "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", - "code": "params.permit(:type, :offset, :min_id, :max_id, :account_id)", - "render_path": null, - "location": { - "type": "method", - "class": "Api::V2::SearchController", - "method": "search_params" - }, - "user_input": ":account_id", - "confidence": "High", + "cwe_id": [ + 89 + ], "note": "" }, { @@ -137,7 +109,7 @@ "check_name": "PermitAttributes", "message": "Potentially dangerous key allowed for mass assignment", "file": "app/controllers/api/v1/admin/reports_controller.rb", - "line": 90, + "line": 88, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", "code": "params.permit(:resolved, :account_id, :target_account_id)", "render_path": null, @@ -148,6 +120,9 @@ }, "user_input": ":account_id", "confidence": "High", + "cwe_id": [ + 915 + ], "note": "" }, { @@ -157,7 +132,7 @@ "check_name": "PermitAttributes", "message": "Potentially dangerous key allowed for mass assignment", "file": "app/controllers/api/v1/notifications_controller.rb", - "line": 81, + "line": 77, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", "code": "params.permit(:account_id, :types => ([]), :exclude_types => ([]))", "render_path": null, @@ -168,26 +143,9 @@ }, "user_input": ":account_id", "confidence": "High", - "note": "" - }, - { - "warning_type": "Redirect", - "warning_code": 18, - "fingerprint": "ba568ac09683f98740f663f3d850c31785900215992e8c090497d359a2563d50", - "check_name": "Redirect", - "message": "Possible unprotected redirect", - "file": "app/controllers/remote_follow_controller.rb", - "line": 21, - "link": "https://brakemanscanner.org/docs/warning_types/redirect/", - "code": "redirect_to(RemoteFollow.new(resource_params).subscribe_address_for(@account))", - "render_path": null, - "location": { - "type": "method", - "class": "RemoteFollowController", - "method": "create" - }, - "user_input": "RemoteFollow.new(resource_params).subscribe_address_for(@account)", - "confidence": "High", + "cwe_id": [ + 915 + ], "note": "" }, { @@ -218,6 +176,9 @@ }, "user_input": "(Unresolved Model).new.url", "confidence": "Weak", + "cwe_id": [ + 79 + ], "note": "" }, { @@ -238,9 +199,12 @@ }, "user_input": ":account_id", "confidence": "High", + "cwe_id": [ + 915 + ], "note": "" } ], - "updated": "2022-03-22 07:48:32 +0100", - "brakeman_version": "5.2.1" + "updated": "2023-07-05 14:34:42 -0400", + "brakeman_version": "5.4.1" } From e0d230fb37848efd788eea54a83869a63ff0fb39 Mon Sep 17 00:00:00 2001 From: fusagiko / takayamaki <24884114+takayamaki@users.noreply.github.com> Date: Sat, 8 Jul 2023 18:11:22 +0900 Subject: [PATCH 0011/1942] simplify counters (#25541) --- .../mastodon/components/account.jsx | 4 +- .../mastodon/components/common_counter.jsx | 60 ------------------- .../mastodon/components/counters.tsx | 45 ++++++++++++++ .../features/account/components/header.jsx | 8 +-- 4 files changed, 51 insertions(+), 66 deletions(-) delete mode 100644 app/javascript/mastodon/components/common_counter.jsx create mode 100644 app/javascript/mastodon/components/counters.tsx diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx index dd5aff1d8..fbcd4cfb3 100644 --- a/app/javascript/mastodon/components/account.jsx +++ b/app/javascript/mastodon/components/account.jsx @@ -8,7 +8,6 @@ import { Link } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { counterRenderer } from 'mastodon/components/common_counter'; import { EmptyAccount } from 'mastodon/components/empty_account'; import ShortNumber from 'mastodon/components/short_number'; import { VerifiedBadge } from 'mastodon/components/verified_badge'; @@ -17,6 +16,7 @@ import { me } from '../initial_state'; import { Avatar } from './avatar'; import Button from './button'; +import { FollowersCounter } from './counters'; import { DisplayName } from './display_name'; import { IconButton } from './icon_button'; import { RelativeTimestamp } from './relative_timestamp'; @@ -160,7 +160,7 @@ class Account extends ImmutablePureComponent { {!minimal && (
- {verification} {muteTimeRemaining} + {verification} {muteTimeRemaining}
)} diff --git a/app/javascript/mastodon/components/common_counter.jsx b/app/javascript/mastodon/components/common_counter.jsx deleted file mode 100644 index 23e1f2263..000000000 --- a/app/javascript/mastodon/components/common_counter.jsx +++ /dev/null @@ -1,60 +0,0 @@ -// @ts-check -import { FormattedMessage } from 'react-intl'; - -/** - * Returns custom renderer for one of the common counter types - * @param {"statuses" | "following" | "followers"} counterType - * Type of the counter - * @param {boolean} isBold Whether display number must be displayed in bold - * @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element} - * Renderer function - * @throws If counterType is not covered by this function - */ -export function counterRenderer(counterType, isBold = true) { - /** - * @type {(displayNumber: JSX.Element) => JSX.Element} - */ - const renderCounter = isBold - ? (displayNumber) => {displayNumber} - : (displayNumber) => displayNumber; - - switch (counterType) { - case 'statuses': { - return (displayNumber, pluralReady) => ( - - ); - } - case 'following': { - return (displayNumber, pluralReady) => ( - - ); - } - case 'followers': { - return (displayNumber, pluralReady) => ( - - ); - } - default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`); - } -} diff --git a/app/javascript/mastodon/components/counters.tsx b/app/javascript/mastodon/components/counters.tsx new file mode 100644 index 000000000..e0c818f24 --- /dev/null +++ b/app/javascript/mastodon/components/counters.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { FormattedMessage } from 'react-intl'; + +export const StatusesCounter = ( + displayNumber: React.ReactNode, + pluralReady: number +) => ( + {displayNumber}, + }} + /> +); + +export const FollowingCounter = ( + displayNumber: React.ReactNode, + pluralReady: number +) => ( + {displayNumber}, + }} + /> +); + +export const FollowersCounter = ( + displayNumber: React.ReactNode, + pluralReady: number +) => ( + {displayNumber}, + }} + /> +); diff --git a/app/javascript/mastodon/features/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx index b718e860d..f7ebc34bc 100644 --- a/app/javascript/mastodon/features/account/components/header.jsx +++ b/app/javascript/mastodon/features/account/components/header.jsx @@ -11,7 +11,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { Avatar } from 'mastodon/components/avatar'; import Button from 'mastodon/components/button'; -import { counterRenderer } from 'mastodon/components/common_counter'; +import { FollowersCounter, FollowingCounter, StatusesCounter } from 'mastodon/components/counters'; import { Icon } from 'mastodon/components/icon'; import { IconButton } from 'mastodon/components/icon_button'; import ShortNumber from 'mastodon/components/short_number'; @@ -451,21 +451,21 @@ class Header extends ImmutablePureComponent { From 20e85c0e837ef17219a1c317d7962286c9b4237d Mon Sep 17 00:00:00 2001 From: alfe Date: Sat, 8 Jul 2023 18:11:58 +0900 Subject: [PATCH 0012/1942] Rewrite `` as FC and TS (#25492) --- .../mastodon/components/account.jsx | 2 +- .../mastodon/components/animated_number.tsx | 2 +- .../components/autosuggest_hashtag.tsx | 2 +- .../mastodon/components/hashtag.jsx | 2 +- .../mastodon/components/server_banner.jsx | 2 +- .../mastodon/components/short_number.jsx | 115 ------------------ .../mastodon/components/short_number.tsx | 90 ++++++++++++++ .../features/account/components/header.jsx | 2 +- .../directory/components/account_card.jsx | 2 +- .../features/explore/components/story.jsx | 2 +- 10 files changed, 98 insertions(+), 123 deletions(-) delete mode 100644 app/javascript/mastodon/components/short_number.jsx create mode 100644 app/javascript/mastodon/components/short_number.tsx diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx index fbcd4cfb3..fd5ea6040 100644 --- a/app/javascript/mastodon/components/account.jsx +++ b/app/javascript/mastodon/components/account.jsx @@ -9,7 +9,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { EmptyAccount } from 'mastodon/components/empty_account'; -import ShortNumber from 'mastodon/components/short_number'; +import { ShortNumber } from 'mastodon/components/short_number'; import { VerifiedBadge } from 'mastodon/components/verified_badge'; import { me } from '../initial_state'; diff --git a/app/javascript/mastodon/components/animated_number.tsx b/app/javascript/mastodon/components/animated_number.tsx index ad985a29e..3122d6421 100644 --- a/app/javascript/mastodon/components/animated_number.tsx +++ b/app/javascript/mastodon/components/animated_number.tsx @@ -4,7 +4,7 @@ import { TransitionMotion, spring } from 'react-motion'; import { reduceMotion } from '../initial_state'; -import ShortNumber from './short_number'; +import { ShortNumber } from './short_number'; const obfuscatedCount = (count: number) => { if (count < 0) { diff --git a/app/javascript/mastodon/components/autosuggest_hashtag.tsx b/app/javascript/mastodon/components/autosuggest_hashtag.tsx index c6798054d..59d66ec87 100644 --- a/app/javascript/mastodon/components/autosuggest_hashtag.tsx +++ b/app/javascript/mastodon/components/autosuggest_hashtag.tsx @@ -1,6 +1,6 @@ import { FormattedMessage } from 'react-intl'; -import ShortNumber from 'mastodon/components/short_number'; +import { ShortNumber } from 'mastodon/components/short_number'; interface Props { tag: { diff --git a/app/javascript/mastodon/components/hashtag.jsx b/app/javascript/mastodon/components/hashtag.jsx index 4a7b9ef71..14bb4ddc6 100644 --- a/app/javascript/mastodon/components/hashtag.jsx +++ b/app/javascript/mastodon/components/hashtag.jsx @@ -11,7 +11,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { Sparklines, SparklinesCurve } from 'react-sparklines'; -import ShortNumber from 'mastodon/components/short_number'; +import { ShortNumber } from 'mastodon/components/short_number'; import { Skeleton } from 'mastodon/components/skeleton'; class SilentErrorBoundary extends Component { diff --git a/app/javascript/mastodon/components/server_banner.jsx b/app/javascript/mastodon/components/server_banner.jsx index 998237860..63eec5349 100644 --- a/app/javascript/mastodon/components/server_banner.jsx +++ b/app/javascript/mastodon/components/server_banner.jsx @@ -9,7 +9,7 @@ import { connect } from 'react-redux'; import { fetchServer } from 'mastodon/actions/server'; import { ServerHeroImage } from 'mastodon/components/server_hero_image'; -import ShortNumber from 'mastodon/components/short_number'; +import { ShortNumber } from 'mastodon/components/short_number'; import { Skeleton } from 'mastodon/components/skeleton'; import Account from 'mastodon/containers/account_container'; import { domain } from 'mastodon/initial_state'; diff --git a/app/javascript/mastodon/components/short_number.jsx b/app/javascript/mastodon/components/short_number.jsx deleted file mode 100644 index b7ac4f5fd..000000000 --- a/app/javascript/mastodon/components/short_number.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import PropTypes from 'prop-types'; -import { memo } from 'react'; - -import { FormattedMessage, FormattedNumber } from 'react-intl'; - -import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers'; - -// @ts-check - -/** - * @callback ShortNumberRenderer - * @param {JSX.Element} displayNumber Number to display - * @param {number} pluralReady Number used for pluralization - * @returns {JSX.Element} Final render of number - */ - -/** - * @typedef {object} ShortNumberProps - * @property {number} value Number to display in short variant - * @property {ShortNumberRenderer} [renderer] - * Custom renderer for numbers, provided as a prop. If another renderer - * passed as a child of this component, this prop won't be used. - * @property {ShortNumberRenderer} [children] - * Custom renderer for numbers, provided as a child. If another renderer - * passed as a prop of this component, this one will be used instead. - */ - -/** - * Component that renders short big number to a shorter version - * @param {ShortNumberProps} param0 Props for the component - * @returns {JSX.Element} Rendered number - */ -function ShortNumber({ value, renderer, children }) { - const shortNumber = toShortNumber(value); - const [, division] = shortNumber; - - if (children != null && renderer != null) { - console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.'); - } - - const customRenderer = children != null ? children : renderer; - - const displayNumber = ; - - return customRenderer != null - ? customRenderer(displayNumber, pluralReady(value, division)) - : displayNumber; -} - -ShortNumber.propTypes = { - value: PropTypes.number.isRequired, - renderer: PropTypes.func, - children: PropTypes.func, -}; - -/** - * @typedef {object} ShortNumberCounterProps - * @property {import('../utils/number').ShortNumber} value Short number - */ - -/** - * Renders short number into corresponding localizable react fragment - * @param {ShortNumberCounterProps} param0 Props for the component - * @returns {JSX.Element} FormattedMessage ready to be embedded in code - */ -function ShortNumberCounter({ value }) { - const [rawNumber, unit, maxFractionDigits = 0] = value; - - const count = ( - - ); - - let values = { count, rawNumber }; - - switch (unit) { - case DECIMAL_UNITS.THOUSAND: { - return ( - - ); - } - case DECIMAL_UNITS.MILLION: { - return ( - - ); - } - case DECIMAL_UNITS.BILLION: { - return ( - - ); - } - // Not sure if we should go farther - @Sasha-Sorokin - default: return count; - } -} - -ShortNumberCounter.propTypes = { - value: PropTypes.arrayOf(PropTypes.number), -}; - -export default memo(ShortNumber); diff --git a/app/javascript/mastodon/components/short_number.tsx b/app/javascript/mastodon/components/short_number.tsx new file mode 100644 index 000000000..010586c04 --- /dev/null +++ b/app/javascript/mastodon/components/short_number.tsx @@ -0,0 +1,90 @@ +import { memo } from 'react'; + +import { FormattedMessage, FormattedNumber } from 'react-intl'; + +import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers'; + +type ShortNumberRenderer = ( + displayNumber: JSX.Element, + pluralReady: number +) => JSX.Element; + +interface ShortNumberProps { + value: number; + renderer?: ShortNumberRenderer; + children?: ShortNumberRenderer; +} + +export const ShortNumberRenderer: React.FC = ({ + value, + renderer, + children, +}) => { + const shortNumber = toShortNumber(value); + const [, division] = shortNumber; + + if (children && renderer) { + console.warn( + 'Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.' + ); + } + + const customRenderer = children || renderer || null; + + const displayNumber = ; + + return ( + customRenderer?.(displayNumber, pluralReady(value, division)) || + displayNumber + ); +}; +export const ShortNumber = memo(ShortNumberRenderer); + +interface ShortNumberCounterProps { + value: number[]; +} +const ShortNumberCounter: React.FC = ({ value }) => { + const [rawNumber, unit, maxFractionDigits = 0] = value; + + const count = ( + + ); + + const values = { count, rawNumber }; + + switch (unit) { + case DECIMAL_UNITS.THOUSAND: { + return ( + + ); + } + case DECIMAL_UNITS.MILLION: { + return ( + + ); + } + case DECIMAL_UNITS.BILLION: { + return ( + + ); + } + // Not sure if we should go farther - @Sasha-Sorokin + default: + return count; + } +}; diff --git a/app/javascript/mastodon/features/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx index f7ebc34bc..5e30205b0 100644 --- a/app/javascript/mastodon/features/account/components/header.jsx +++ b/app/javascript/mastodon/features/account/components/header.jsx @@ -14,7 +14,7 @@ import Button from 'mastodon/components/button'; import { FollowersCounter, FollowingCounter, StatusesCounter } from 'mastodon/components/counters'; import { Icon } from 'mastodon/components/icon'; import { IconButton } from 'mastodon/components/icon_button'; -import ShortNumber from 'mastodon/components/short_number'; +import { ShortNumber } from 'mastodon/components/short_number'; import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; import { autoPlayGif, me, domain } from 'mastodon/initial_state'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions'; diff --git a/app/javascript/mastodon/features/directory/components/account_card.jsx b/app/javascript/mastodon/features/directory/components/account_card.jsx index cf1c63f9e..795979530 100644 --- a/app/javascript/mastodon/features/directory/components/account_card.jsx +++ b/app/javascript/mastodon/features/directory/components/account_card.jsx @@ -19,7 +19,7 @@ import { openModal } from 'mastodon/actions/modal'; import { Avatar } from 'mastodon/components/avatar'; import Button from 'mastodon/components/button'; import { DisplayName } from 'mastodon/components/display_name'; -import ShortNumber from 'mastodon/components/short_number'; +import { ShortNumber } from 'mastodon/components/short_number'; import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state'; import { makeGetAccount } from 'mastodon/selectors'; diff --git a/app/javascript/mastodon/features/explore/components/story.jsx b/app/javascript/mastodon/features/explore/components/story.jsx index 0a9fbb190..73ec99c14 100644 --- a/app/javascript/mastodon/features/explore/components/story.jsx +++ b/app/javascript/mastodon/features/explore/components/story.jsx @@ -5,7 +5,7 @@ import classNames from 'classnames'; import { Blurhash } from 'mastodon/components/blurhash'; import { accountsCountRenderer } from 'mastodon/components/hashtag'; -import ShortNumber from 'mastodon/components/short_number'; +import { ShortNumber } from 'mastodon/components/short_number'; import { Skeleton } from 'mastodon/components/skeleton'; export default class Story extends PureComponent { From 4534498a8e43f59980ee56e9938efab8580c78c8 Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Sat, 8 Jul 2023 11:12:20 +0200 Subject: [PATCH 0013/1942] Convert `` to Typescript (#25582) --- .../components/dismissable_banner.jsx | 55 ------------------- .../components/dismissable_banner.tsx | 47 ++++++++++++++++ .../features/community_timeline/index.jsx | 2 +- .../mastodon/features/explore/links.jsx | 2 +- .../mastodon/features/explore/statuses.jsx | 2 +- .../mastodon/features/explore/tags.jsx | 2 +- .../mastodon/features/firehose/index.jsx | 2 +- .../components/explore_prompt.jsx | 2 +- .../features/public_timeline/index.jsx | 2 +- 9 files changed, 54 insertions(+), 62 deletions(-) delete mode 100644 app/javascript/mastodon/components/dismissable_banner.jsx create mode 100644 app/javascript/mastodon/components/dismissable_banner.tsx diff --git a/app/javascript/mastodon/components/dismissable_banner.jsx b/app/javascript/mastodon/components/dismissable_banner.jsx deleted file mode 100644 index 5aecc88b1..000000000 --- a/app/javascript/mastodon/components/dismissable_banner.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { injectIntl, defineMessages } from 'react-intl'; - -import { bannerSettings } from 'mastodon/settings'; - -import { IconButton } from './icon_button'; - -const messages = defineMessages({ - dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' }, -}); - -class DismissableBanner extends PureComponent { - - static propTypes = { - id: PropTypes.string.isRequired, - children: PropTypes.node, - intl: PropTypes.object.isRequired, - }; - - state = { - visible: !bannerSettings.get(this.props.id), - }; - - handleDismiss = () => { - const { id } = this.props; - this.setState({ visible: false }, () => bannerSettings.set(id, true)); - }; - - render () { - const { visible } = this.state; - - if (!visible) { - return null; - } - - const { children, intl } = this.props; - - return ( -
-
- {children} -
- -
- -
-
- ); - } - -} - -export default injectIntl(DismissableBanner); diff --git a/app/javascript/mastodon/components/dismissable_banner.tsx b/app/javascript/mastodon/components/dismissable_banner.tsx new file mode 100644 index 000000000..d5cdb0750 --- /dev/null +++ b/app/javascript/mastodon/components/dismissable_banner.tsx @@ -0,0 +1,47 @@ +import type { PropsWithChildren } from 'react'; +import { useCallback, useState } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import { bannerSettings } from 'mastodon/settings'; + +import { IconButton } from './icon_button'; + +const messages = defineMessages({ + dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' }, +}); + +interface Props { + id: string; +} + +export const DismissableBanner: React.FC> = ({ + id, + children, +}) => { + const [visible, setVisible] = useState(!bannerSettings.get(id)); + const intl = useIntl(); + + const handleDismiss = useCallback(() => { + setVisible(false); + bannerSettings.set(id, true); + }, [id]); + + if (!visible) { + return null; + } + + return ( +
+
{children}
+ +
+ +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/community_timeline/index.jsx b/app/javascript/mastodon/features/community_timeline/index.jsx index 7e3b9babe..2d94cabed 100644 --- a/app/javascript/mastodon/features/community_timeline/index.jsx +++ b/app/javascript/mastodon/features/community_timeline/index.jsx @@ -7,7 +7,7 @@ import { Helmet } from 'react-helmet'; import { connect } from 'react-redux'; -import DismissableBanner from 'mastodon/components/dismissable_banner'; +import { DismissableBanner } from 'mastodon/components/dismissable_banner'; import { domain } from 'mastodon/initial_state'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; diff --git a/app/javascript/mastodon/features/explore/links.jsx b/app/javascript/mastodon/features/explore/links.jsx index 49c667f02..8b199bf47 100644 --- a/app/javascript/mastodon/features/explore/links.jsx +++ b/app/javascript/mastodon/features/explore/links.jsx @@ -7,7 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { fetchTrendingLinks } from 'mastodon/actions/trends'; -import DismissableBanner from 'mastodon/components/dismissable_banner'; +import { DismissableBanner } from 'mastodon/components/dismissable_banner'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import Story from './components/story'; diff --git a/app/javascript/mastodon/features/explore/statuses.jsx b/app/javascript/mastodon/features/explore/statuses.jsx index eb2fe777a..3271929db 100644 --- a/app/javascript/mastodon/features/explore/statuses.jsx +++ b/app/javascript/mastodon/features/explore/statuses.jsx @@ -9,7 +9,7 @@ import { connect } from 'react-redux'; import { debounce } from 'lodash'; import { fetchTrendingStatuses, expandTrendingStatuses } from 'mastodon/actions/trends'; -import DismissableBanner from 'mastodon/components/dismissable_banner'; +import { DismissableBanner } from 'mastodon/components/dismissable_banner'; import StatusList from 'mastodon/components/status_list'; import { getStatusList } from 'mastodon/selectors'; diff --git a/app/javascript/mastodon/features/explore/tags.jsx b/app/javascript/mastodon/features/explore/tags.jsx index f558b48a6..1a4d25969 100644 --- a/app/javascript/mastodon/features/explore/tags.jsx +++ b/app/javascript/mastodon/features/explore/tags.jsx @@ -7,7 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { fetchTrendingHashtags } from 'mastodon/actions/trends'; -import DismissableBanner from 'mastodon/components/dismissable_banner'; +import { DismissableBanner } from 'mastodon/components/dismissable_banner'; import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; diff --git a/app/javascript/mastodon/features/firehose/index.jsx b/app/javascript/mastodon/features/firehose/index.jsx index 9ba4fd5b2..e5b47d3fe 100644 --- a/app/javascript/mastodon/features/firehose/index.jsx +++ b/app/javascript/mastodon/features/firehose/index.jsx @@ -10,7 +10,7 @@ import { addColumn } from 'mastodon/actions/columns'; import { changeSetting } from 'mastodon/actions/settings'; import { connectPublicStream, connectCommunityStream } from 'mastodon/actions/streaming'; import { expandPublicTimeline, expandCommunityTimeline } from 'mastodon/actions/timelines'; -import DismissableBanner from 'mastodon/components/dismissable_banner'; +import { DismissableBanner } from 'mastodon/components/dismissable_banner'; import initialState, { domain } from 'mastodon/initial_state'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; diff --git a/app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx b/app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx index a6993c641..2af85b6d5 100644 --- a/app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx +++ b/app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx @@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl'; import { Link } from 'react-router-dom'; import background from 'mastodon/../images/friends-cropped.png'; -import DismissableBanner from 'mastodon/components/dismissable_banner'; +import { DismissableBanner } from 'mastodon/components/dismissable_banner'; export const ExplorePrompt = () => ( diff --git a/app/javascript/mastodon/features/public_timeline/index.jsx b/app/javascript/mastodon/features/public_timeline/index.jsx index 352baa833..3bfb25ba7 100644 --- a/app/javascript/mastodon/features/public_timeline/index.jsx +++ b/app/javascript/mastodon/features/public_timeline/index.jsx @@ -7,7 +7,7 @@ import { Helmet } from 'react-helmet'; import { connect } from 'react-redux'; -import DismissableBanner from 'mastodon/components/dismissable_banner'; +import { DismissableBanner } from 'mastodon/components/dismissable_banner'; import { domain } from 'mastodon/initial_state'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; From e4cfe4b3db2e71cef43c7b8f7ce42ba46f7aa0b5 Mon Sep 17 00:00:00 2001 From: Kurtis Rainbolt-Greene Date: Sat, 8 Jul 2023 10:45:36 -0700 Subject: [PATCH 0014/1942] 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 0015/1942] 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 0016/1942] 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 0017/1942] 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 0018/1942] 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 0019/1942] 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; - } -} From cf33028f350cfe292931d905cf396e4dc1b669ab Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sat, 8 Jul 2023 14:03:38 -0400 Subject: [PATCH 0020/1942] Admin mailer parameterization (#25759) --- app/mailers/admin_mailer.rb | 45 +++++++++++-------- app/models/trends.rb | 2 +- app/models/user.rb | 2 +- app/services/appeal_service.rb | 2 +- app/services/report_service.rb | 2 +- config/i18n-tasks.yml | 1 + .../api/v1/reports_controller_spec.rb | 4 +- .../disputes/appeals_controller_spec.rb | 4 +- spec/mailers/admin_mailer_spec.rb | 8 ++-- spec/mailers/previews/admin_mailer_preview.rb | 6 +-- 10 files changed, 40 insertions(+), 36 deletions(-) diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index bc6d87ae6..5baf9b38a 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -6,45 +6,52 @@ class AdminMailer < ApplicationMailer helper :accounts helper :languages - def new_report(recipient, report) - @report = report - @me = recipient - @instance = Rails.configuration.x.local_domain + before_action :process_params + before_action :set_instance + + default to: -> { @me.user_email } + + def new_report(report) + @report = report locale_for_account(@me) do - mail to: @me.user_email, subject: I18n.t('admin_mailer.new_report.subject', instance: @instance, id: @report.id) + mail subject: default_i18n_subject(instance: @instance, id: @report.id) end end - def new_appeal(recipient, appeal) - @appeal = appeal - @me = recipient - @instance = Rails.configuration.x.local_domain + def new_appeal(appeal) + @appeal = appeal locale_for_account(@me) do - mail to: @me.user_email, subject: I18n.t('admin_mailer.new_appeal.subject', instance: @instance, username: @appeal.account.username) + mail subject: default_i18n_subject(instance: @instance, username: @appeal.account.username) end end - def new_pending_account(recipient, user) - @account = user.account - @me = recipient - @instance = Rails.configuration.x.local_domain + def new_pending_account(user) + @account = user.account locale_for_account(@me) do - mail to: @me.user_email, subject: I18n.t('admin_mailer.new_pending_account.subject', instance: @instance, username: @account.username) + mail subject: default_i18n_subject(instance: @instance, username: @account.username) end end - def new_trends(recipient, links, tags, statuses) + def new_trends(links, tags, statuses) @links = links @tags = tags @statuses = statuses - @me = recipient - @instance = Rails.configuration.x.local_domain locale_for_account(@me) do - mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trends.subject', instance: @instance) + mail subject: default_i18n_subject(instance: @instance) end end + + private + + def process_params + @me = params[:recipient] + end + + def set_instance + @instance = Rails.configuration.x.local_domain + end end diff --git a/app/models/trends.rb b/app/models/trends.rb index d07d62b71..7ca51e0b3 100644 --- a/app/models/trends.rb +++ b/app/models/trends.rb @@ -35,7 +35,7 @@ module Trends return if links_requiring_review.empty? && tags_requiring_review.empty? && statuses_requiring_review.empty? User.those_who_can(:manage_taxonomies).includes(:account).find_each do |user| - AdminMailer.new_trends(user.account, links_requiring_review, tags_requiring_review, statuses_requiring_review).deliver_later! if user.allows_trends_review_emails? + AdminMailer.with(recipient: user.account).new_trends(links_requiring_review, tags_requiring_review, statuses_requiring_review).deliver_later! if user.allows_trends_review_emails? end end diff --git a/app/models/user.rb b/app/models/user.rb index 5ee14bbda..fa445af81 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -475,7 +475,7 @@ class User < ApplicationRecord User.those_who_can(:manage_users).includes(:account).find_each do |u| next unless u.allows_pending_account_emails? - AdminMailer.new_pending_account(u.account, self).deliver_later + AdminMailer.with(recipient: u.account).new_pending_account(self).deliver_later end end diff --git a/app/services/appeal_service.rb b/app/services/appeal_service.rb index 399a053d6..ef052e354 100644 --- a/app/services/appeal_service.rb +++ b/app/services/appeal_service.rb @@ -23,7 +23,7 @@ class AppealService < BaseService def notify_staff! User.those_who_can(:manage_appeals).includes(:account).each do |u| - AdminMailer.new_appeal(u.account, @appeal).deliver_later if u.allows_appeal_emails? + AdminMailer.with(recipient: u.account).new_appeal(@appeal).deliver_later if u.allows_appeal_emails? end end end diff --git a/app/services/report_service.rb b/app/services/report_service.rb index 3444e1dfa..39ebd5cd8 100644 --- a/app/services/report_service.rb +++ b/app/services/report_service.rb @@ -40,7 +40,7 @@ class ReportService < BaseService User.those_who_can(:manage_reports).includes(:account).each do |u| LocalNotificationWorker.perform_async(u.account_id, @report.id, 'Report', 'admin.report') - AdminMailer.new_report(u.account, @report).deliver_later if u.allows_report_emails? + AdminMailer.with(recipient: u.account).new_report(@report).deliver_later if u.allows_report_emails? end end diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 035a0e999..cb00a62d5 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -63,6 +63,7 @@ ignore_unused: - 'admin_mailer.new_appeal.actions.*' - 'statuses.attached.*' - 'move_handler.carry_{mutes,blocks}_over_text' + - 'admin_mailer.*.subject' - 'notification_mailer.*' - 'imports.overwrite_preambles.{following,blocking,muting,domain_blocking,bookmarks}_html' - 'imports.preambles.{following,blocking,muting,domain_blocking,bookmarks}_html' diff --git a/spec/controllers/api/v1/reports_controller_spec.rb b/spec/controllers/api/v1/reports_controller_spec.rb index 01b7e4a71..f923ff079 100644 --- a/spec/controllers/api/v1/reports_controller_spec.rb +++ b/spec/controllers/api/v1/reports_controller_spec.rb @@ -23,8 +23,6 @@ RSpec.describe Api::V1::ReportsController do let(:rule_ids) { nil } before do - allow(AdminMailer).to receive(:new_report) - .and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil)) post :create, params: { status_ids: [status.id], account_id: target_account.id, comment: 'reasons', category: category, rule_ids: rule_ids, forward: forward } end @@ -41,7 +39,7 @@ RSpec.describe Api::V1::ReportsController do end it 'sends e-mails to admins' do - expect(AdminMailer).to have_received(:new_report).with(admin.account, Report) + expect(ActionMailer::Base.deliveries.first.to).to eq([admin.email]) end context 'when a status does not belong to the reported account' do diff --git a/spec/controllers/disputes/appeals_controller_spec.rb b/spec/controllers/disputes/appeals_controller_spec.rb index a0f9c7b91..c8444a2a9 100644 --- a/spec/controllers/disputes/appeals_controller_spec.rb +++ b/spec/controllers/disputes/appeals_controller_spec.rb @@ -14,13 +14,11 @@ RSpec.describe Disputes::AppealsController do let(:strike) { Fabricate(:account_warning, target_account: current_user.account) } before do - allow(AdminMailer).to receive(:new_appeal) - .and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil)) post :create, params: { strike_id: strike.id, appeal: { text: 'Foo' } } end it 'notifies staff about new appeal' do - expect(AdminMailer).to have_received(:new_appeal).with(admin.account, Appeal.last) + expect(ActionMailer::Base.deliveries.first.to).to eq([admin.email]) end it 'redirects back to the strike page' do diff --git a/spec/mailers/admin_mailer_spec.rb b/spec/mailers/admin_mailer_spec.rb index 8e2eec40f..9123804a4 100644 --- a/spec/mailers/admin_mailer_spec.rb +++ b/spec/mailers/admin_mailer_spec.rb @@ -7,7 +7,7 @@ RSpec.describe AdminMailer do let(:sender) { Fabricate(:account, username: 'John') } let(:recipient) { Fabricate(:account, username: 'Mike') } let(:report) { Fabricate(:report, account: sender, target_account: recipient) } - let(:mail) { described_class.new_report(recipient, report) } + let(:mail) { described_class.with(recipient: recipient).new_report(report) } before do recipient.user.update(locale: :en) @@ -27,7 +27,7 @@ RSpec.describe AdminMailer do describe '.new_appeal' do let(:appeal) { Fabricate(:appeal) } let(:recipient) { Fabricate(:account, username: 'Kurt') } - let(:mail) { described_class.new_appeal(recipient, appeal) } + let(:mail) { described_class.with(recipient: recipient).new_appeal(appeal) } before do recipient.user.update(locale: :en) @@ -47,7 +47,7 @@ RSpec.describe AdminMailer do describe '.new_pending_account' do let(:recipient) { Fabricate(:account, username: 'Barklums') } let(:user) { Fabricate(:user) } - let(:mail) { described_class.new_pending_account(recipient, user) } + let(:mail) { described_class.with(recipient: recipient).new_pending_account(user) } before do recipient.user.update(locale: :en) @@ -69,7 +69,7 @@ RSpec.describe AdminMailer do let(:links) { [] } let(:statuses) { [] } let(:tags) { [] } - let(:mail) { described_class.new_trends(recipient, links, tags, statuses) } + let(:mail) { described_class.with(recipient: recipient).new_trends(links, tags, statuses) } before do recipient.user.update(locale: :en) diff --git a/spec/mailers/previews/admin_mailer_preview.rb b/spec/mailers/previews/admin_mailer_preview.rb index 9572768cd..bc8f0193b 100644 --- a/spec/mailers/previews/admin_mailer_preview.rb +++ b/spec/mailers/previews/admin_mailer_preview.rb @@ -5,16 +5,16 @@ class AdminMailerPreview < ActionMailer::Preview # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_pending_account def new_pending_account - AdminMailer.new_pending_account(Account.first, User.pending.first) + AdminMailer.with(recipient: Account.first).new_pending_account(User.pending.first) end # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_trends def new_trends - AdminMailer.new_trends(Account.first, PreviewCard.joins(:trend).limit(3), Tag.limit(3), Status.joins(:trend).where(reblog_of_id: nil).limit(3)) + AdminMailer.with(recipient: Account.first).new_trends(PreviewCard.joins(:trend).limit(3), Tag.limit(3), Status.joins(:trend).where(reblog_of_id: nil).limit(3)) end # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_appeal def new_appeal - AdminMailer.new_appeal(Account.first, Appeal.first) + AdminMailer.with(recipient: Account.first).new_appeal(Appeal.first) end end From d6b387a0c4172ecd7cb742d3b97f294552a6fdb1 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sat, 8 Jul 2023 14:04:21 -0400 Subject: [PATCH 0021/1942] Remove unused `NotificationMailer#digest` preview (#25719) --- spec/mailers/previews/notification_mailer_preview.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/spec/mailers/previews/notification_mailer_preview.rb b/spec/mailers/previews/notification_mailer_preview.rb index bc41662a1..214161881 100644 --- a/spec/mailers/previews/notification_mailer_preview.rb +++ b/spec/mailers/previews/notification_mailer_preview.rb @@ -32,9 +32,4 @@ class NotificationMailerPreview < ActionMailer::Preview r = Status.where.not(reblog_of_id: nil).first NotificationMailer.reblog(r.reblog.account, Notification.find_by(activity: r)) end - - # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/digest - def digest - NotificationMailer.digest(Account.first, since: 90.days.ago) - end end From 338a0e70ccd2d5526e54fd67f99819e80643b45a Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 8 Jul 2023 20:05:33 +0200 Subject: [PATCH 0022/1942] Change label and design of sensitive and unavailable media in web UI (#25712) --- .../mastodon/components/media_gallery.jsx | 10 ++++-- app/javascript/mastodon/locales/en.json | 4 ++- .../styles/mastodon/components.scss | 33 +++++++++---------- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/app/javascript/mastodon/components/media_gallery.jsx b/app/javascript/mastodon/components/media_gallery.jsx index 1044b729b..e3c0065c9 100644 --- a/app/javascript/mastodon/components/media_gallery.jsx +++ b/app/javascript/mastodon/components/media_gallery.jsx @@ -321,7 +321,10 @@ class MediaGallery extends PureComponent { if (uncached) { spoilerButton = ( ); } else if (visible) { @@ -329,7 +332,10 @@ class MediaGallery extends PureComponent { } else { spoilerButton = ( ); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 8705e6cd6..edecaf60f 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -618,6 +618,8 @@ "status.history.created": "{name} created {date}", "status.history.edited": "{name} edited {date}", "status.load_more": "Load more", + "status.media.open": "Click to open", + "status.media.show": "Click to show", "status.media_hidden": "Media hidden", "status.mention": "Mention @{name}", "status.more": "More", @@ -648,7 +650,7 @@ "status.title.with_attachments": "{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}", "status.translate": "Translate", "status.translated_from_with": "Translated from {lang} using {provider}", - "status.uncached_media_warning": "Not available", + "status.uncached_media_warning": "Preview not available", "status.unmute_conversation": "Unmute conversation", "status.unpin": "Unpin from profile", "subscribed_languages.lead": "Only posts in selected languages will appear on your home and list timelines after the change. Select none to receive posts in all languages.", diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 434a2f542..0d816ba8d 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -4213,34 +4213,31 @@ a.status-card.compact:hover { } &__overlay { - display: block; - background: transparent; + display: flex; + align-items: center; + justify-content: center; + background: rgba($black, 0.5); width: 100%; height: 100%; + padding: 0; + margin: 0; border: 0; + border-radius: 4px; &__label { - display: inline-block; - background: rgba($base-overlay-background, 0.5); - border-radius: 8px; - padding: 8px 12px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-direction: column; color: $primary-text-color; font-weight: 500; font-size: 14px; } - &:hover, - &:focus, - &:active { - .spoiler-button__overlay__label { - background: rgba($base-overlay-background, 0.8); - } - } - - &:disabled { - .spoiler-button__overlay__label { - background: rgba($base-overlay-background, 0.5); - } + &__action { + font-weight: 400; + font-size: 13px; } } } From 610cf6c3713e414995ea1a57110db400ccb88dd2 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 8 Jul 2023 20:16:48 +0200 Subject: [PATCH 0023/1942] Fix trend calculation working on too many items at a time (#25835) --- app/models/trends/links.rb | 26 +++++++++++++++++++------- app/models/trends/statuses.rb | 26 +++++++++++++++++++------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/app/models/trends/links.rb b/app/models/trends/links.rb index c94f7c023..fcbdb1a5f 100644 --- a/app/models/trends/links.rb +++ b/app/models/trends/links.rb @@ -3,6 +3,8 @@ class Trends::Links < Trends::Base PREFIX = 'trending_links' + BATCH_SIZE = 100 + self.default_options = { threshold: 5, review_threshold: 3, @@ -67,8 +69,21 @@ class Trends::Links < Trends::Base end def refresh(at_time = Time.now.utc) - preview_cards = PreviewCard.where(id: (recently_used_ids(at_time) + PreviewCardTrend.pluck(:preview_card_id)).uniq) - calculate_scores(preview_cards, at_time) + # First, recalculate scores for links that were trending previously. We split the queries + # to avoid having to load all of the IDs into Ruby just to send them back into Postgres + PreviewCard.where(id: PreviewCardTrend.select(:preview_card_id)).find_in_batches(batch_size: BATCH_SIZE) do |preview_cards| + calculate_scores(preview_cards, at_time) + end + + # Then, calculate scores for links that were used today. There are potentially some + # duplicate items here that we might process one more time, but that should be fine + PreviewCard.where(id: recently_used_ids(at_time)).find_in_batches(batch_size: BATCH_SIZE) do |preview_cards| + calculate_scores(preview_cards, at_time) + end + + # Now that all trends have up-to-date scores, and all the ones below the threshold have + # been removed, we can recalculate their positions + PreviewCardTrend.connection.exec_update('UPDATE preview_card_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM preview_card_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE preview_card_trends.id = t0.id') end def request_review @@ -139,10 +154,7 @@ class Trends::Links < Trends::Base to_insert = items.filter { |(score, _)| score >= options[:decay_threshold] } to_delete = items.filter { |(score, _)| score < options[:decay_threshold] } - PreviewCardTrend.transaction do - PreviewCardTrend.upsert_all(to_insert.map { |(score, preview_card)| { preview_card_id: preview_card.id, score: score, language: preview_card.language, allowed: preview_card.trendable? || false } }, unique_by: :preview_card_id) if to_insert.any? - PreviewCardTrend.where(preview_card_id: to_delete.map { |(_, preview_card)| preview_card.id }).delete_all if to_delete.any? - PreviewCardTrend.connection.exec_update('UPDATE preview_card_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM preview_card_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE preview_card_trends.id = t0.id') - end + PreviewCardTrend.upsert_all(to_insert.map { |(score, preview_card)| { preview_card_id: preview_card.id, score: score, language: preview_card.language, allowed: preview_card.trendable? || false } }, unique_by: :preview_card_id) if to_insert.any? + PreviewCardTrend.where(preview_card_id: to_delete.map { |(_, preview_card)| preview_card.id }).delete_all if to_delete.any? end end diff --git a/app/models/trends/statuses.rb b/app/models/trends/statuses.rb index 84bff9c02..5cd352a6f 100644 --- a/app/models/trends/statuses.rb +++ b/app/models/trends/statuses.rb @@ -3,6 +3,8 @@ class Trends::Statuses < Trends::Base PREFIX = 'trending_statuses' + BATCH_SIZE = 100 + self.default_options = { threshold: 5, review_threshold: 3, @@ -58,8 +60,21 @@ class Trends::Statuses < Trends::Base end def refresh(at_time = Time.now.utc) - statuses = Status.where(id: (recently_used_ids(at_time) + StatusTrend.pluck(:status_id)).uniq).includes(:status_stat, :account) - calculate_scores(statuses, at_time) + # First, recalculate scores for statuses that were trending previously. We split the queries + # to avoid having to load all of the IDs into Ruby just to send them back into Postgres + Status.where(id: StatusTrend.select(:status_id)).includes(:status_stat, :account).find_in_batches(batch_size: BATCH_SIZE) do |statuses| + calculate_scores(statuses, at_time) + end + + # Then, calculate scores for statuses that were used today. There are potentially some + # duplicate items here that we might process one more time, but that should be fine + Status.where(id: recently_used_ids(at_time)).includes(:status_stat, :account).find_in_batches(batch_size: BATCH_SIZE) do |statuses| + calculate_scores(statuses, at_time) + end + + # Now that all trends have up-to-date scores, and all the ones below the threshold have + # been removed, we can recalculate their positions + StatusTrend.connection.exec_update('UPDATE status_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM status_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE status_trends.id = t0.id') end def request_review @@ -117,10 +132,7 @@ class Trends::Statuses < Trends::Base to_insert = items.filter { |(score, _)| score >= options[:decay_threshold] } to_delete = items.filter { |(score, _)| score < options[:decay_threshold] } - StatusTrend.transaction do - StatusTrend.upsert_all(to_insert.map { |(score, status)| { status_id: status.id, account_id: status.account_id, score: score, language: status.language, allowed: status.trendable? || false } }, unique_by: :status_id) if to_insert.any? - StatusTrend.where(status_id: to_delete.map { |(_, status)| status.id }).delete_all if to_delete.any? - StatusTrend.connection.exec_update('UPDATE status_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM status_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE status_trends.id = t0.id') - end + StatusTrend.upsert_all(to_insert.map { |(score, status)| { status_id: status.id, account_id: status.account_id, score: score, language: status.language, allowed: status.trendable? || false } }, unique_by: :status_id) if to_insert.any? + StatusTrend.where(status_id: to_delete.map { |(_, status)| status.id }).delete_all if to_delete.any? end end From a1f5188c8c4c98149ce157f9a9a596874f2b46dd Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 10 Jul 2023 03:06:09 +0200 Subject: [PATCH 0024/1942] Change feed merge, unmerge and regeneration workers to use a replica (#25849) --- app/workers/merge_worker.rb | 9 ++++++++- app/workers/regeneration_worker.rb | 9 +++++++-- app/workers/unmerge_worker.rb | 9 ++++++++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb index e526d2887..50cfcc3f0 100644 --- a/app/workers/merge_worker.rb +++ b/app/workers/merge_worker.rb @@ -5,7 +5,14 @@ class MergeWorker include Redisable def perform(from_account_id, into_account_id) - FeedManager.instance.merge_into_home(Account.find(from_account_id), Account.find(into_account_id)) + ApplicationRecord.connected_to(role: :primary) do + @from_account = Account.find(from_account_id) + @into_account = Account.find(into_account_id) + end + + ApplicationRecord.connected_to(role: :read, prevent_writes: true) do + FeedManager.instance.merge_into_home(@from_account, @into_account) + end rescue ActiveRecord::RecordNotFound true ensure diff --git a/app/workers/regeneration_worker.rb b/app/workers/regeneration_worker.rb index 5c13c894f..5ac095e65 100644 --- a/app/workers/regeneration_worker.rb +++ b/app/workers/regeneration_worker.rb @@ -6,8 +6,13 @@ class RegenerationWorker sidekiq_options lock: :until_executed def perform(account_id, _ = :home) - account = Account.find(account_id) - PrecomputeFeedService.new.call(account) + ApplicationRecord.connected_to(role: :primary) do + @account = Account.find(account_id) + end + + ApplicationRecord.connected_to(role: :read, prevent_writes: true) do + PrecomputeFeedService.new.call(@account) + end rescue ActiveRecord::RecordNotFound true end diff --git a/app/workers/unmerge_worker.rb b/app/workers/unmerge_worker.rb index 1a23faae5..f911ea2f9 100644 --- a/app/workers/unmerge_worker.rb +++ b/app/workers/unmerge_worker.rb @@ -6,7 +6,14 @@ class UnmergeWorker sidekiq_options queue: 'pull' def perform(from_account_id, into_account_id) - FeedManager.instance.unmerge_from_home(Account.find(from_account_id), Account.find(into_account_id)) + ApplicationRecord.connected_to(role: :primary) do + @from_account = Account.find(from_account_id) + @into_account = Account.find(into_account_id) + end + + ApplicationRecord.connected_to(role: :read, prevent_writes: true) do + FeedManager.instance.unmerge_from_home(@from_account, @into_account) + end rescue ActiveRecord::RecordNotFound true end From f3fca78756c02fb19f75a2a712d80fa712d32229 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Sun, 9 Jul 2023 21:06:22 -0400 Subject: [PATCH 0025/1942] Refactor `NotificationMailer` to use parameterization (#25718) --- app/mailers/notification_mailer.rb | 71 +++++++++---------- app/services/notify_service.rb | 7 +- spec/mailers/notification_mailer_spec.rb | 21 ++++-- .../previews/notification_mailer_preview.rb | 29 +++++--- 4 files changed, 73 insertions(+), 55 deletions(-) diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 7cd3bab1a..277612366 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -1,83 +1,76 @@ # frozen_string_literal: true class NotificationMailer < ApplicationMailer - helper :accounts - helper :statuses + helper :accounts, + :statuses, + :routing - helper RoutingHelper + before_action :process_params + before_action :set_status, only: [:mention, :favourite, :reblog] + before_action :set_account, only: [:follow, :favourite, :reblog, :follow_request] - def mention(recipient, notification) - @me = recipient - @user = recipient.user - @type = 'mention' - @status = notification.target_status + default to: -> { email_address_with_name(@user.email, @me.username) } + def mention return unless @user.functional? && @status.present? locale_for_account(@me) do thread_by_conversation(@status.conversation) - mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.mention.subject', name: @status.account.acct) + mail subject: default_i18n_subject(name: @status.account.acct) end end - def follow(recipient, notification) - @me = recipient - @user = recipient.user - @type = 'follow' - @account = notification.from_account - + def follow return unless @user.functional? locale_for_account(@me) do - mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.follow.subject', name: @account.acct) + mail subject: default_i18n_subject(name: @account.acct) end end - def favourite(recipient, notification) - @me = recipient - @user = recipient.user - @type = 'favourite' - @account = notification.from_account - @status = notification.target_status - + def favourite return unless @user.functional? && @status.present? locale_for_account(@me) do thread_by_conversation(@status.conversation) - mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.favourite.subject', name: @account.acct) + mail subject: default_i18n_subject(name: @account.acct) end end - def reblog(recipient, notification) - @me = recipient - @user = recipient.user - @type = 'reblog' - @account = notification.from_account - @status = notification.target_status - + def reblog return unless @user.functional? && @status.present? locale_for_account(@me) do thread_by_conversation(@status.conversation) - mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.reblog.subject', name: @account.acct) + mail subject: default_i18n_subject(name: @account.acct) end end - def follow_request(recipient, notification) - @me = recipient - @user = recipient.user - @type = 'follow_request' - @account = notification.from_account - + def follow_request return unless @user.functional? locale_for_account(@me) do - mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct) + mail subject: default_i18n_subject(name: @account.acct) end end private + def process_params + @notification = params[:notification] + @me = params[:recipient] + @user = @me.user + @type = action_name + end + + def set_status + @status = @notification.target_status + end + + def set_account + @account = @notification.from_account + end + def thread_by_conversation(conversation) return if conversation.nil? diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index ad9e6e3d6..06b48d558 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -162,7 +162,12 @@ class NotifyService < BaseService end def send_email! - NotificationMailer.public_send(@notification.type, @recipient, @notification).deliver_later(wait: 2.minutes) if NotificationMailer.respond_to?(@notification.type) + return unless NotificationMailer.respond_to?(@notification.type) + + NotificationMailer + .with(recipient: @recipient, notification: @notification) + .public_send(@notification.type) + .deliver_later(wait: 2.minutes) end def email_needed? diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb index bf364b625..3efb97cb1 100644 --- a/spec/mailers/notification_mailer_spec.rb +++ b/spec/mailers/notification_mailer_spec.rb @@ -23,7 +23,8 @@ RSpec.describe NotificationMailer do describe 'mention' do let(:mention) { Mention.create!(account: receiver.account, status: foreign_status) } - let(:mail) { described_class.mention(receiver.account, Notification.create!(account: receiver.account, activity: mention)) } + let(:notification) { Notification.create!(account: receiver.account, activity: mention) } + let(:mail) { prepared_mailer_for(receiver.account).mention } include_examples 'localized subject', 'notification_mailer.mention.subject', name: 'bob' @@ -40,7 +41,8 @@ RSpec.describe NotificationMailer do describe 'follow' do let(:follow) { sender.follow!(receiver.account) } - let(:mail) { described_class.follow(receiver.account, Notification.create!(account: receiver.account, activity: follow)) } + let(:notification) { Notification.create!(account: receiver.account, activity: follow) } + let(:mail) { prepared_mailer_for(receiver.account).follow } include_examples 'localized subject', 'notification_mailer.follow.subject', name: 'bob' @@ -56,7 +58,8 @@ RSpec.describe NotificationMailer do describe 'favourite' do let(:favourite) { Favourite.create!(account: sender, status: own_status) } - let(:mail) { described_class.favourite(own_status.account, Notification.create!(account: receiver.account, activity: favourite)) } + let(:notification) { Notification.create!(account: receiver.account, activity: favourite) } + let(:mail) { prepared_mailer_for(own_status.account).favourite } include_examples 'localized subject', 'notification_mailer.favourite.subject', name: 'bob' @@ -73,7 +76,8 @@ RSpec.describe NotificationMailer do describe 'reblog' do let(:reblog) { Status.create!(account: sender, reblog: own_status) } - let(:mail) { described_class.reblog(own_status.account, Notification.create!(account: receiver.account, activity: reblog)) } + let(:notification) { Notification.create!(account: receiver.account, activity: reblog) } + let(:mail) { prepared_mailer_for(own_status.account).reblog } include_examples 'localized subject', 'notification_mailer.reblog.subject', name: 'bob' @@ -90,7 +94,8 @@ RSpec.describe NotificationMailer do describe 'follow_request' do let(:follow_request) { Fabricate(:follow_request, account: sender, target_account: receiver.account) } - let(:mail) { described_class.follow_request(receiver.account, Notification.create!(account: receiver.account, activity: follow_request)) } + let(:notification) { Notification.create!(account: receiver.account, activity: follow_request) } + let(:mail) { prepared_mailer_for(receiver.account).follow_request } include_examples 'localized subject', 'notification_mailer.follow_request.subject', name: 'bob' @@ -103,4 +108,10 @@ RSpec.describe NotificationMailer do expect(mail.body.encoded).to match('bob has requested to follow you') end end + + private + + def prepared_mailer_for(recipient) + described_class.with(recipient: recipient, notification: notification) + end end diff --git a/spec/mailers/previews/notification_mailer_preview.rb b/spec/mailers/previews/notification_mailer_preview.rb index 214161881..a63c20c27 100644 --- a/spec/mailers/previews/notification_mailer_preview.rb +++ b/spec/mailers/previews/notification_mailer_preview.rb @@ -5,31 +5,40 @@ class NotificationMailerPreview < ActionMailer::Preview # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/mention def mention - m = Mention.last - NotificationMailer.mention(m.account, Notification.find_by(activity: m)) + activity = Mention.last + mailer_for(activity.account, activity).mention end # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/follow def follow - f = Follow.last - NotificationMailer.follow(f.target_account, Notification.find_by(activity: f)) + activity = Follow.last + mailer_for(activity.target_account, activity).follow end # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/follow_request def follow_request - f = Follow.last - NotificationMailer.follow_request(f.target_account, Notification.find_by(activity: f)) + activity = Follow.last + mailer_for(activity.target_account, activity).follow_request end # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/favourite def favourite - f = Favourite.last - NotificationMailer.favourite(f.status.account, Notification.find_by(activity: f)) + activity = Favourite.last + mailer_for(activity.status.account, activity).favourite end # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/reblog def reblog - r = Status.where.not(reblog_of_id: nil).first - NotificationMailer.reblog(r.reblog.account, Notification.find_by(activity: r)) + activity = Status.where.not(reblog_of_id: nil).first + mailer_for(activity.reblog.account, activity).reblog + end + + private + + def mailer_for(account, activity) + NotificationMailer.with( + recipient: account, + notification: Notification.find_by(activity: activity) + ) end end From c27b82a43763b44b0b2a2929b9cde588260581b4 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 10 Jul 2023 18:26:56 +0200 Subject: [PATCH 0026/1942] Add `forward_to_domains` parameter to `POST /api/v1/reports` (#25866) --- app/controllers/api/v1/reports_controller.rb | 2 +- .../mastodon/features/report/comment.jsx | 150 +++++++++++------- .../features/ui/components/report_modal.jsx | 34 ++-- .../styles/mastodon/components.scss | 1 + app/services/report_service.rb | 22 ++- spec/services/report_service_spec.rb | 24 ++- 6 files changed, 153 insertions(+), 80 deletions(-) diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index 8ff6c8fe5..300c9faa3 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -23,6 +23,6 @@ class Api::V1::ReportsController < Api::BaseController end def report_params - params.permit(:account_id, :comment, :category, :forward, status_ids: [], rule_ids: []) + params.permit(:account_id, :comment, :category, :forward, forward_to_domains: [], status_ids: [], rule_ids: []) end end diff --git a/app/javascript/mastodon/features/report/comment.jsx b/app/javascript/mastodon/features/report/comment.jsx index 4888b76bc..98ac4caa0 100644 --- a/app/javascript/mastodon/features/report/comment.jsx +++ b/app/javascript/mastodon/features/report/comment.jsx @@ -1,87 +1,121 @@ import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; -import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; + +import { OrderedSet, List as ImmutableList } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { shallowEqual } from 'react-redux'; +import { createSelector } from 'reselect'; import Toggle from 'react-toggle'; +import { fetchAccount } from 'mastodon/actions/accounts'; import Button from 'mastodon/components/button'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; const messages = defineMessages({ placeholder: { id: 'report.placeholder', defaultMessage: 'Type or paste additional comments' }, }); -class Comment extends PureComponent { +const selectRepliedToAccountIds = createSelector( + [ + (state) => state.get('statuses'), + (_, statusIds) => statusIds, + ], + (statusesMap, statusIds) => statusIds.map((statusId) => statusesMap.getIn([statusId, 'in_reply_to_account_id'])), + { + resultEqualityCheck: shallowEqual, + } +); - static propTypes = { - onSubmit: PropTypes.func.isRequired, - comment: PropTypes.string.isRequired, - onChangeComment: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - isSubmitting: PropTypes.bool, - forward: PropTypes.bool, - isRemote: PropTypes.bool, - domain: PropTypes.string, - onChangeForward: PropTypes.func.isRequired, - }; +const Comment = ({ comment, domain, statusIds, isRemote, isSubmitting, selectedDomains, onSubmit, onChangeComment, onToggleDomain }) => { + const intl = useIntl(); - handleClick = () => { - const { onSubmit } = this.props; - onSubmit(); - }; + const dispatch = useAppDispatch(); + const loadedRef = useRef(false); - handleChange = e => { - const { onChangeComment } = this.props; - onChangeComment(e.target.value); - }; + const handleClick = useCallback(() => onSubmit(), [onSubmit]); + const handleChange = useCallback((e) => onChangeComment(e.target.value), [onChangeComment]); + const handleToggleDomain = useCallback(e => onToggleDomain(e.target.value, e.target.checked), [onToggleDomain]); - handleKeyDown = e => { + const handleKeyDown = useCallback((e) => { if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { - this.handleClick(); + handleClick(); } - }; + }, [handleClick]); - handleForwardChange = e => { - const { onChangeForward } = this.props; - onChangeForward(e.target.checked); - }; + // Memoize accountIds since we don't want it to trigger `useEffect` on each render + const accountIds = useAppSelector((state) => domain ? selectRepliedToAccountIds(state, statusIds) : ImmutableList()); - render () { - const { comment, isRemote, forward, domain, isSubmitting, intl } = this.props; + // While we could memoize `availableDomains`, it is pretty inexpensive to recompute + const accountsMap = useAppSelector((state) => state.get('accounts')); + const availableDomains = domain ? OrderedSet([domain]).union(accountIds.map((accountId) => accountsMap.getIn([accountId, 'acct'], '').split('@')[1]).filter(domain => !!domain)) : OrderedSet(); - return ( - <> -

+ useEffect(() => { + if (loadedRef.current) { + return; + } -