From e5e977c24fc31d7c609fc523f76ddfcc64006307 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Fri, 25 Jul 2025 03:18:46 -0400 Subject: [PATCH 1/7] Fix `Style/GuardClause` in worker rescues (#35508) --- app/workers/mention_resolve_worker.rb | 6 +----- app/workers/redownload_avatar_worker.rb | 6 +----- app/workers/redownload_header_worker.rb | 6 +----- app/workers/redownload_media_worker.rb | 6 +----- app/workers/remote_account_refresh_worker.rb | 6 +----- 5 files changed, 5 insertions(+), 25 deletions(-) diff --git a/app/workers/mention_resolve_worker.rb b/app/workers/mention_resolve_worker.rb index 8c5938aeaf..d14adb3cf3 100644 --- a/app/workers/mention_resolve_worker.rb +++ b/app/workers/mention_resolve_worker.rb @@ -22,11 +22,7 @@ class MentionResolveWorker rescue Mastodon::UnexpectedResponseError => e response = e.response - if response_error_unsalvageable?(response) - # Give up - else - raise e - end + raise(e) unless response_error_unsalvageable?(response) end private diff --git a/app/workers/redownload_avatar_worker.rb b/app/workers/redownload_avatar_worker.rb index df17b7718d..c4c659f73e 100644 --- a/app/workers/redownload_avatar_worker.rb +++ b/app/workers/redownload_avatar_worker.rb @@ -20,10 +20,6 @@ class RedownloadAvatarWorker rescue Mastodon::UnexpectedResponseError => e response = e.response - if response_error_unsalvageable?(response) - # Give up - else - raise e - end + raise(e) unless response_error_unsalvageable?(response) end end diff --git a/app/workers/redownload_header_worker.rb b/app/workers/redownload_header_worker.rb index 3b142ec5f9..2d600e2964 100644 --- a/app/workers/redownload_header_worker.rb +++ b/app/workers/redownload_header_worker.rb @@ -20,10 +20,6 @@ class RedownloadHeaderWorker rescue Mastodon::UnexpectedResponseError => e response = e.response - if response_error_unsalvageable?(response) - # Give up - else - raise e - end + raise(e) unless response_error_unsalvageable?(response) end end diff --git a/app/workers/redownload_media_worker.rb b/app/workers/redownload_media_worker.rb index 343caa32c2..5342ec0b2d 100644 --- a/app/workers/redownload_media_worker.rb +++ b/app/workers/redownload_media_worker.rb @@ -20,10 +20,6 @@ class RedownloadMediaWorker rescue Mastodon::UnexpectedResponseError => e response = e.response - if response_error_unsalvageable?(response) - # Give up - else - raise e - end + raise(e) unless response_error_unsalvageable?(response) end end diff --git a/app/workers/remote_account_refresh_worker.rb b/app/workers/remote_account_refresh_worker.rb index 9632936b54..5a4cbdf726 100644 --- a/app/workers/remote_account_refresh_worker.rb +++ b/app/workers/remote_account_refresh_worker.rb @@ -15,10 +15,6 @@ class RemoteAccountRefreshWorker rescue Mastodon::UnexpectedResponseError => e response = e.response - if response_error_unsalvageable?(response) - # Give up - else - raise e - end + raise(e) unless response_error_unsalvageable?(response) end end From 960f6932194ed2d0dc19909b302e94d8eb1c7d88 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Fri, 25 Jul 2025 03:21:08 -0400 Subject: [PATCH 2/7] Use `field` partial in admin account show view (#35503) --- app/helpers/home_helper.rb | 4 ++-- app/views/admin/accounts/_field.html.haml | 9 +++++++++ app/views/admin/accounts/show.html.haml | 20 ++++++-------------- spec/helpers/home_helper_spec.rb | 20 ++++++++------------ 4 files changed, 25 insertions(+), 28 deletions(-) create mode 100644 app/views/admin/accounts/_field.html.haml diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb index c5b83326db..7b9d3f4fc1 100644 --- a/app/helpers/home_helper.rb +++ b/app/helpers/home_helper.rb @@ -49,8 +49,8 @@ module HomeHelper end end - def custom_field_classes(field) - if field.verified? + def field_verified_class(verified) + if verified 'verified' else 'emojify' diff --git a/app/views/admin/accounts/_field.html.haml b/app/views/admin/accounts/_field.html.haml new file mode 100644 index 0000000000..ce8d80785e --- /dev/null +++ b/app/views/admin/accounts/_field.html.haml @@ -0,0 +1,9 @@ +-# locals: (field:, account:) +%dl + %dt.emojify{ title: field.name } + = prerender_custom_emojis(h(field.name), account.emojis) + %dd{ title: field.value, class: field_verified_class(field.verified?) } + - if field.verified? + %span.verified__mark{ title: t('accounts.link_verified_on', date: l(field.verified_at)) } + = material_symbol 'check' + = prerender_custom_emojis(account_field_value_format(field, with_rel_me: false), account.emojis) diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index f148b9a082..977967c58f 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -7,25 +7,17 @@ = render 'application/card', account: @account -- account = @account -- fields = account.fields -- unless fields.empty? && account.note.blank? +- if @account.fields? || @account.note? .admin-account-bio - - unless fields.empty? + - if @account.fields? %div .account__header__fields - - fields.each do |field| - %dl - %dt.emojify{ title: field.name }= prerender_custom_emojis(h(field.name), account.emojis) - %dd{ title: field.value, class: custom_field_classes(field) } - - if field.verified? - %span.verified__mark{ title: t('accounts.link_verified_on', date: l(field.verified_at)) } - = material_symbol 'check' - = prerender_custom_emojis(account_field_value_format(field, with_rel_me: false), account.emojis) + = render partial: 'field', collection: @account.fields, locals: { account: @account } - - if account.note.present? + - if @account.note? %div - .account__header__content.emojify= prerender_custom_emojis(account_bio_format(account), account.emojis) + .account__header__content.emojify + = prerender_custom_emojis(account_bio_format(@account), @account.emojis) = render 'admin/accounts/counters', account: @account diff --git a/spec/helpers/home_helper_spec.rb b/spec/helpers/home_helper_spec.rb index c3fbff4e8b..a8f6d99f03 100644 --- a/spec/helpers/home_helper_spec.rb +++ b/spec/helpers/home_helper_spec.rb @@ -75,23 +75,19 @@ RSpec.describe HomeHelper do end end - describe 'custom_field_classes' do - context 'with a verified field' do - let(:field) { instance_double(Account::Field, verified?: true) } + describe 'field_verified_class' do + subject { helper.field_verified_class(verified) } - it 'returns verified string' do - result = helper.custom_field_classes(field) - expect(result).to eq 'verified' - end + context 'with a verified field' do + let(:verified) { true } + + it { is_expected.to eq('verified') } end context 'with a non-verified field' do - let(:field) { instance_double(Account::Field, verified?: false) } + let(:verified) { false } - it 'returns verified string' do - result = helper.custom_field_classes(field) - expect(result).to eq 'emojify' - end + it { is_expected.to eq('emojify') } end end From 2e35defeec7d402902eecea316dddd078d3e123c Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Fri, 25 Jul 2025 03:22:05 -0400 Subject: [PATCH 3/7] Update rubocop to version 1.79.0 (#35502) --- .rubocop_todo.yml | 2 +- Gemfile.lock | 6 ++++-- app/controllers/api/v1/admin/tags_controller.rb | 1 + app/lib/emoji_formatter.rb | 4 +++- spec/controllers/concerns/accountable_concern_spec.rb | 1 + spec/routing/accounts_routing_spec.rb | 2 ++ spec/validators/existing_username_validator_spec.rb | 1 + spec/validators/language_validator_spec.rb | 1 + spec/validators/unreserved_username_validator_spec.rb | 1 + spec/validators/url_validator_spec.rb | 1 + 10 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 4ec92f3412..9e69426fcf 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp` -# using RuboCop version 1.77.0. +# using RuboCop version 1.79.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new diff --git a/Gemfile.lock b/Gemfile.lock index ca95bbfdcf..d0472d538c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -765,7 +765,7 @@ GEM rspec-mocks (~> 3.0) sidekiq (>= 5, < 9) rspec-support (3.13.4) - rubocop (1.78.0) + rubocop (1.79.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -773,8 +773,9 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.45.1, < 2.0) + rubocop-ast (>= 1.46.0, < 2.0) ruby-progressbar (~> 1.7) + tsort (>= 0.2.0) unicode-display_width (>= 2.4.0, < 4.0) rubocop-ast (1.46.0) parser (>= 3.3.7.2) @@ -880,6 +881,7 @@ GEM bindata (~> 2.4) openssl (> 2.0) openssl-signature_algorithm (~> 1.0) + tsort (0.2.0) tty-color (0.6.0) tty-cursor (0.7.1) tty-prompt (0.23.1) diff --git a/app/controllers/api/v1/admin/tags_controller.rb b/app/controllers/api/v1/admin/tags_controller.rb index 283383acb4..dd272120e2 100644 --- a/app/controllers/api/v1/admin/tags_controller.rb +++ b/app/controllers/api/v1/admin/tags_controller.rb @@ -2,6 +2,7 @@ class Api::V1::Admin::TagsController < Api::BaseController include Authorization + before_action -> { authorize_if_got_token! :'admin:read' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:write' }, only: :update diff --git a/app/lib/emoji_formatter.rb b/app/lib/emoji_formatter.rb index c193df9bb6..a5f5103fff 100644 --- a/app/lib/emoji_formatter.rb +++ b/app/lib/emoji_formatter.rb @@ -45,7 +45,9 @@ class EmojiFormatter i += 1 if inside_shortname && text[i] == ':' - inside_shortname = false + # https://github.com/rubocop/rubocop/issues/14383 + # False positive in line below, remove disable when resolved + inside_shortname = false # rubocop:disable Lint/UselessAssignment shortcode = text[(shortname_start_index + 1)..(i - 1)] char_after = text[i + 1] diff --git a/spec/controllers/concerns/accountable_concern_spec.rb b/spec/controllers/concerns/accountable_concern_spec.rb index cd06d872bb..e68090fdc2 100644 --- a/spec/controllers/concerns/accountable_concern_spec.rb +++ b/spec/controllers/concerns/accountable_concern_spec.rb @@ -6,6 +6,7 @@ RSpec.describe AccountableConcern do let(:hoge_class) do Class.new do include AccountableConcern + attr_reader :current_account def initialize(current_account) diff --git a/spec/routing/accounts_routing_spec.rb b/spec/routing/accounts_routing_spec.rb index 8ff711a681..bb0bf082bd 100644 --- a/spec/routing/accounts_routing_spec.rb +++ b/spec/routing/accounts_routing_spec.rb @@ -49,6 +49,7 @@ RSpec.describe 'Routes under accounts/' do context 'with local username encoded at' do include RSpec::Rails::RequestExampleGroup + let(:username) { 'alice' } it 'routes /%40:username' do @@ -140,6 +141,7 @@ RSpec.describe 'Routes under accounts/' do context 'with remote username encoded at' do include RSpec::Rails::RequestExampleGroup + let(:username) { 'alice%40example.com' } let(:username_decoded) { 'alice@example.com' } diff --git a/spec/validators/existing_username_validator_spec.rb b/spec/validators/existing_username_validator_spec.rb index 25ecb1fbcd..ab5be52453 100644 --- a/spec/validators/existing_username_validator_spec.rb +++ b/spec/validators/existing_username_validator_spec.rb @@ -6,6 +6,7 @@ RSpec.describe ExistingUsernameValidator do let(:record_class) do Class.new do include ActiveModel::Validations + attr_accessor :contact, :friends def self.name diff --git a/spec/validators/language_validator_spec.rb b/spec/validators/language_validator_spec.rb index 19e55f3467..d19b33f27f 100644 --- a/spec/validators/language_validator_spec.rb +++ b/spec/validators/language_validator_spec.rb @@ -6,6 +6,7 @@ RSpec.describe LanguageValidator do let(:record_class) do Class.new do include ActiveModel::Validations + attr_accessor :locale validates :locale, language: true diff --git a/spec/validators/unreserved_username_validator_spec.rb b/spec/validators/unreserved_username_validator_spec.rb index ad1092109d..67a2921885 100644 --- a/spec/validators/unreserved_username_validator_spec.rb +++ b/spec/validators/unreserved_username_validator_spec.rb @@ -6,6 +6,7 @@ RSpec.describe UnreservedUsernameValidator do let(:record_class) do Class.new do include ActiveModel::Validations + attr_accessor :username validates_with UnreservedUsernameValidator diff --git a/spec/validators/url_validator_spec.rb b/spec/validators/url_validator_spec.rb index 2297dddaa0..55c0347d18 100644 --- a/spec/validators/url_validator_spec.rb +++ b/spec/validators/url_validator_spec.rb @@ -6,6 +6,7 @@ RSpec.describe URLValidator do let(:record_class) do Class.new do include ActiveModel::Validations + attr_accessor :profile validates :profile, url: true From d950298d2915a98701b0d8026a551c69bcf99f1d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Jul 2025 07:29:48 +0000 Subject: [PATCH 4/7] New Crowdin Translations (automated) (#35514) Co-authored-by: GitHub Actions --- app/javascript/mastodon/locales/br.json | 2 + app/javascript/mastodon/locales/de.json | 6 +- app/javascript/mastodon/locales/el.json | 4 +- app/javascript/mastodon/locales/en-GB.json | 2 +- app/javascript/mastodon/locales/ga.json | 2 + app/javascript/mastodon/locales/kk.json | 1 + app/javascript/mastodon/locales/pt-PT.json | 2 + app/javascript/mastodon/locales/sc.json | 81 ++++++++++++++++++++++ app/javascript/mastodon/locales/uk.json | 2 + config/locales/fr-CA.yml | 1 + config/locales/fr.yml | 1 + config/locales/ru.yml | 53 +++++++------- config/locales/simple_form.el.yml | 4 +- 13 files changed, 127 insertions(+), 34 deletions(-) diff --git a/app/javascript/mastodon/locales/br.json b/app/javascript/mastodon/locales/br.json index b0e21ab3be..8882ff916e 100644 --- a/app/javascript/mastodon/locales/br.json +++ b/app/javascript/mastodon/locales/br.json @@ -558,6 +558,8 @@ "status.bookmark": "Ouzhpennañ d'ar sinedoù", "status.cancel_reblog_private": "Nac'hañ ar skignadenn", "status.cannot_reblog": "Ar c'hannad-se na c'hall ket bezañ skignet", + "status.context.load_new_replies": "Respontoù nevez zo", + "status.context.loading": "O kerc'hat muioc'h a respontoù", "status.copy": "Eilañ liamm ar c'hannad", "status.delete": "Dilemel", "status.detailed_status": "Gwel kaozeadenn munudek", diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index 0bd451d64b..75f99fef4c 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -43,7 +43,7 @@ "account.followers": "Follower", "account.followers.empty": "Diesem Profil folgt noch niemand.", "account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Follower}}", - "account.followers_you_know_counter": "{counter} bekannt", + "account.followers_you_know_counter": "{counter} Follower kennst Du", "account.following": "Folge ich", "account.following_counter": "{count, plural, one {{counter} Folge ich} other {{counter} Folge ich}}", "account.follows.empty": "Dieses Profil folgt noch niemandem.", @@ -63,7 +63,7 @@ "account.mute_short": "Stummschalten", "account.muted": "Stummgeschaltet", "account.muting": "Stummgeschaltet", - "account.mutual": "Ihr folgt einander", + "account.mutual": "Ihr folgt euch", "account.no_bio": "Keine Beschreibung verfügbar.", "account.open_original_page": "Ursprüngliche Seite öffnen", "account.posts": "Beiträge", @@ -225,7 +225,7 @@ "confirmations.discard_draft.edit.title": "Änderungen an diesem Beitrag verwerfen?", "confirmations.discard_draft.post.cancel": "Entwurf fortsetzen", "confirmations.discard_draft.post.message": "Beim Fortfahren wird der gerade verfasste Beitrag verworfen.", - "confirmations.discard_draft.post.title": "Beitragsentwurf verwerfen?", + "confirmations.discard_draft.post.title": "Entwurf verwerfen?", "confirmations.discard_edit_media.confirm": "Verwerfen", "confirmations.discard_edit_media.message": "Du hast Änderungen an der Medienbeschreibung oder -vorschau vorgenommen, die noch nicht gespeichert sind. Trotzdem verwerfen?", "confirmations.follow_to_list.confirm": "Folgen und zur Liste hinzufügen", diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json index 8223d5d200..b18897576c 100644 --- a/app/javascript/mastodon/locales/el.json +++ b/app/javascript/mastodon/locales/el.json @@ -612,7 +612,7 @@ "notification.moderation_warning.action_suspend": "Ο λογαριασμός σου έχει ανασταλεί.", "notification.own_poll": "Η δημοσκόπησή σου έληξε", "notification.poll": "Μία ψηφοφορία στην οποία συμμετείχες έχει τελειώσει", - "notification.reblog": "Ο/Η {name} ενίσχυσε τη δημοσίευσή σου", + "notification.reblog": "Ο/Η {name} ενίσχυσε την ανάρτηση σου", "notification.reblog.name_and_others_with_link": "{name} και {count, plural, one {# ακόμη} other {# ακόμη}} ενίσχυσαν την ανάρτησή σου", "notification.relationships_severance_event": "Χάθηκε η σύνδεση με το {name}", "notification.relationships_severance_event.account_suspension": "Ένας διαχειριστής από το {from} ανέστειλε το {target}, πράγμα που σημαίνει ότι δεν μπορείς πλέον να λαμβάνεις ενημερώσεις από αυτούς ή να αλληλεπιδράς μαζί τους.", @@ -845,6 +845,8 @@ "status.bookmark": "Σελιδοδείκτης", "status.cancel_reblog_private": "Ακύρωση ενίσχυσης", "status.cannot_reblog": "Αυτή η ανάρτηση δεν μπορεί να ενισχυθεί", + "status.context.load_new_replies": "Νέες απαντήσεις διαθέσιμες", + "status.context.loading": "Γίνεται έλεγχος για περισσότερες απαντήσεις", "status.continued_thread": "Συνεχιζόμενο νήματος", "status.copy": "Αντιγραφή συνδέσμου ανάρτησης", "status.delete": "Διαγραφή", diff --git a/app/javascript/mastodon/locales/en-GB.json b/app/javascript/mastodon/locales/en-GB.json index 4f3a649f0b..441cfee6d1 100644 --- a/app/javascript/mastodon/locales/en-GB.json +++ b/app/javascript/mastodon/locales/en-GB.json @@ -612,7 +612,7 @@ "notification.moderation_warning.action_suspend": "Your account has been suspended.", "notification.own_poll": "Your poll has ended", "notification.poll": "A poll you voted in has ended", - "notification.reblog": "{name} boosted your status", + "notification.reblog": "{name} boosted your post", "notification.reblog.name_and_others_with_link": "{name} and {count, plural, one {# other} other {# others}} boosted your post", "notification.relationships_severance_event": "Lost connections with {name}", "notification.relationships_severance_event.account_suspension": "An admin from {from} has suspended {target}, which means you can no longer receive updates from them or interact with them.", diff --git a/app/javascript/mastodon/locales/ga.json b/app/javascript/mastodon/locales/ga.json index bca4ed8d1e..2c0b709bdc 100644 --- a/app/javascript/mastodon/locales/ga.json +++ b/app/javascript/mastodon/locales/ga.json @@ -845,6 +845,8 @@ "status.bookmark": "Leabharmharcanna", "status.cancel_reblog_private": "Dímhol", "status.cannot_reblog": "Ní féidir an phostáil seo a mholadh", + "status.context.load_new_replies": "Freagraí nua ar fáil", + "status.context.loading": "Ag seiceáil le haghaidh tuilleadh freagraí", "status.continued_thread": "Snáithe ar lean", "status.copy": "Cóipeáil an nasc chuig an bpostáil", "status.delete": "Scrios", diff --git a/app/javascript/mastodon/locales/kk.json b/app/javascript/mastodon/locales/kk.json index f0e981adb3..be0025fdb2 100644 --- a/app/javascript/mastodon/locales/kk.json +++ b/app/javascript/mastodon/locales/kk.json @@ -1,6 +1,7 @@ { "about.blocks": "Модерацияланған серверлер", "about.contact": "Байланыс:", + "about.default_locale": "Әдепкі", "about.disclaimer": "Mastodon деген тегін, бастапқы коды ашық бағдарламалық жасақтама және Mastodon gGmbH-тің сауда маркасы.", "about.domain_blocks.no_reason_available": "Себеп қолжетімсіз", "about.domain_blocks.preamble": "Mastodon әдетте сізге Fediverse'тің кез келген серверінің қолданушыларының контентін көріп, олармен байланысуға мүмкіндік береді. Осы белгілі серверде жасалған ережеден тыс жағдайлар міне.", diff --git a/app/javascript/mastodon/locales/pt-PT.json b/app/javascript/mastodon/locales/pt-PT.json index 15ccc7cb32..6cc740f755 100644 --- a/app/javascript/mastodon/locales/pt-PT.json +++ b/app/javascript/mastodon/locales/pt-PT.json @@ -845,6 +845,8 @@ "status.bookmark": "Guardar nos marcadores", "status.cancel_reblog_private": "Retirar impulso", "status.cannot_reblog": "Esta publicação não pode ser impulsionada", + "status.context.load_new_replies": "Novas respostas disponíveis", + "status.context.loading": "A verificar por mais respostas", "status.continued_thread": "Continuação da conversa", "status.copy": "Copiar hiperligação da publicação", "status.delete": "Eliminar", diff --git a/app/javascript/mastodon/locales/sc.json b/app/javascript/mastodon/locales/sc.json index fb856ba8a1..8dc3e597c1 100644 --- a/app/javascript/mastodon/locales/sc.json +++ b/app/javascript/mastodon/locales/sc.json @@ -1,6 +1,7 @@ { "about.blocks": "Serbidores moderados", "about.contact": "Cuntatu:", + "about.default_locale": "Predefinidu", "about.disclaimer": "Mastodon est software de còdighe lìberu e unu màrchiu de Mastodon gGmbH.", "about.domain_blocks.no_reason_available": "Peruna resone a disponimentu", "about.domain_blocks.preamble": "Mastodon ti permitit de bìdere su cuntenutu de utentes de cale si siat àteru serbidore de su fediversu. Custas sunt etzetziones fatas in custu serbidore ispetzìficu.", @@ -8,6 +9,7 @@ "about.domain_blocks.silenced.title": "Limitadu", "about.domain_blocks.suspended.explanation": "Perunu datu de custu serbidore at a èssere protzessadu, immagasinadu o cuncambiadu; est impossìbile duncas cale si siat interatzione o comunicatzione cun is utentes de custu serbidore.", "about.domain_blocks.suspended.title": "Suspèndidu", + "about.language_label": "Idioma", "about.not_available": "Custa informatzione no est istada posta a disponimentu in custu serbidore.", "about.powered_by": "Rete sotziale detzentralizada impulsada dae {mastodon}", "about.rules": "Règulas de su serbidore", @@ -19,13 +21,21 @@ "account.block_domain": "Bloca su domìniu {domain}", "account.block_short": "Bloca", "account.blocked": "Blocadu", + "account.blocking": "Blocadu", "account.cancel_follow_request": "Annulla sa sighidura", "account.copy": "Còpia su ligòngiu a su profilu", "account.direct": "Mèntova a @{name} in privadu", "account.disable_notifications": "Non mi notìfiches prus cando @{name} pùblichet messàgios", + "account.domain_blocking": "Blocamus su domìniu", "account.edit_profile": "Modìfica profilu", "account.enable_notifications": "Notìfica·mi cando @{name} pùblicat messàgios", "account.endorse": "Cussìgia in su profilu tuo", + "account.familiar_followers_many": "Sighidu dae {name1}, {name2} e {othersCount, plural,one {un'àtera persone chi connosches} other {àteras # persones chi connosches}}", + "account.familiar_followers_one": "Sighidu dae {name1}", + "account.familiar_followers_two": "Sighidu dae {name1} e {name2}", + "account.featured": "In evidèntzia", + "account.featured.accounts": "Profilos", + "account.featured.hashtags": "Etichetas", "account.featured_tags.last_status_at": "Ùrtima publicatzione in su {date}", "account.featured_tags.last_status_never": "Peruna publicatzione", "account.follow": "Sighi", @@ -33,9 +43,11 @@ "account.followers": "Sighiduras", "account.followers.empty": "Nemos sighit ancora custa persone.", "account.followers_counter": "{count, plural, one {{counter} sighidura} other {{counter} sighiduras}}", + "account.followers_you_know_counter": "{counter} chi connosches", "account.following": "Sighende", "account.following_counter": "{count, plural, one {sighende a {counter}} other {sighende a {counter}}}", "account.follows.empty": "Custa persone non sighit ancora a nemos.", + "account.follows_you": "Ti sighit", "account.go_to_profile": "Bae a su profilu", "account.hide_reblogs": "Cua is cumpartziduras de @{name}", "account.in_memoriam": "In memoriam.", @@ -50,18 +62,22 @@ "account.mute_notifications_short": "Pone is notìficas a sa muda", "account.mute_short": "A sa muda", "account.muted": "A sa muda", + "account.muting": "A sa muda", "account.no_bio": "Peruna descritzione frunida.", "account.open_original_page": "Aberi sa pàgina originale", "account.posts": "Publicatziones", "account.posts_with_replies": "Publicatziones e rispostas", + "account.remove_from_followers": "Cantzella a {name} dae is sighiduras", "account.report": "Signala @{name}", "account.requested": "Abetende s'aprovatzione. Incarca pro annullare sa rechesta de sighidura", "account.requested_follow": "{name} at dimandadu de ti sighire", + "account.requests_to_follow_you": "Rechestas de sighidura", "account.share": "Cumpartzi su profilu de @{name}", "account.show_reblogs": "Ammustra is cumpartziduras de @{name}", "account.statuses_counter": "{count, plural, one {{counter} publicatzione} other {{counter} publicatziones}}", "account.unblock": "Isbloca a @{name}", "account.unblock_domain": "Isbloca su domìniu {domain}", + "account.unblock_domain_short": "Isbloca", "account.unblock_short": "Isbloca", "account.unendorse": "Non cussiges in su profilu", "account.unfollow": "Non sigas prus", @@ -83,7 +99,22 @@ "alert.unexpected.message": "Ddoe est istada una faddina.", "alert.unexpected.title": "Oh!", "alt_text_badge.title": "Testu alternativu", + "alt_text_modal.add_alt_text": "Agiunghe testu alternativu", + "alt_text_modal.add_text_from_image": "Agiunghe testu dae un'immàgine", + "alt_text_modal.cancel": "Annulla", + "alt_text_modal.change_thumbnail": "Càmbia sa miniadura", + "alt_text_modal.done": "Fatu", "announcement.announcement": "Annùntziu", + "annual_report.summary.archetype.booster": "Semper a s'ùrtima", + "annual_report.summary.followers.followers": "sighiduras", + "annual_report.summary.followers.total": "{count} totale", + "annual_report.summary.highlighted_post.possessive": "de {name}", + "annual_report.summary.most_used_app.most_used_app": "aplicatzione prus impreada", + "annual_report.summary.most_used_hashtag.most_used_hashtag": "eticheta prus impreada", + "annual_report.summary.most_used_hashtag.none": "Peruna", + "annual_report.summary.new_posts.new_posts": "publicatziones noas", + "annual_report.summary.percentile.we_wont_tell_bernie": "No dd'amus a nàrrere a Bernie.", + "annual_report.summary.thanks": "Gràtzias de èssere parte de Mastodon!", "attachments_list.unprocessed": "(non protzessadu)", "audio.hide": "Cua s'àudio", "block_modal.remote_users_caveat": "Amus a pedire a su serbidore {domain} de rispetare sa detzisione tua. Nointames custu, su rispetu no est garantidu ca unos cantos serbidores diant pòdere gestire is blocos de manera diferente. Is publicatzione pùblicas diant pòdere ancora èssere visìbiles a is utentes chi no ant fatu s'atzessu.", @@ -107,6 +138,7 @@ "bundle_column_error.routing.body": "Impossìbile agatare sa pàgina rechesta. Seguru chi s'URL in sa barra de indiritzos est curretu?", "bundle_column_error.routing.title": "404", "bundle_modal_error.close": "Serra", + "bundle_modal_error.message": "Faddina in su carrigamentu de custu ischermu.", "bundle_modal_error.retry": "Torra·bi a proare", "closed_registrations.other_server_instructions": "Dae chi Mastodon est detzentralizadu, podes creare unu contu in un'àteru serbidore e interagire cun custu.", "closed_registrations_modal.description": "Sa creatzione de contos in {domain} no est possìbile in custu momentu, però tene in cunsideru chi non tenes bisòngiu de unu contu ispetzìficu in {domain} pro impreare Mastodon.", @@ -116,13 +148,16 @@ "column.blocks": "Persones blocadas", "column.bookmarks": "Sinnalibros", "column.community": "Lìnia de tempus locale", + "column.create_list": "Crea una lista", "column.direct": "Mentziones privadas", "column.directory": "Nàviga in is profilos", "column.domain_blocks": "Domìnios blocados", + "column.edit_list": "Modifica sa lista", "column.favourites": "Preferidos", "column.firehose": "Publicatziones in direta", "column.follow_requests": "Rechestas de sighidura", "column.home": "Printzipale", + "column.list_members": "Gesti is persones de sa lista", "column.lists": "Listas", "column.mutes": "Persones a sa muda", "column.notifications": "Notìficas", @@ -135,6 +170,7 @@ "column_header.pin": "Apica", "column_header.show_settings": "Ammustra is cunfiguratziones", "column_header.unpin": "Boga dae pitzu", + "column_search.cancel": "Annulla", "community.column_settings.local_only": "Isceti locale", "community.column_settings.media_only": "Isceti multimediale", "community.column_settings.remote_only": "Isceti remotu", @@ -152,6 +188,7 @@ "compose_form.poll.duration": "Longària de su sondàgiu", "compose_form.poll.multiple": "Sèberu mùltiplu", "compose_form.poll.option_placeholder": "Optzione {number}", + "compose_form.poll.single": "Sèberu ùnicu", "compose_form.poll.switch_to_multiple": "Muda su sondàgiu pro permìtere multi-optziones", "compose_form.poll.switch_to_single": "Muda su sondàgiu pro permìtere un'optzione isceti", "compose_form.poll.type": "Istile", @@ -169,6 +206,8 @@ "confirmations.delete_list.confirm": "Cantzella", "confirmations.delete_list.message": "Seguru chi boles cantzellare custa lista in manera permanente?", "confirmations.delete_list.title": "Cantzellare sa lista?", + "confirmations.discard_draft.confirm": "Iscarta e sighi", + "confirmations.discard_draft.edit.cancel": "Sighi cun s'editzione", "confirmations.discard_edit_media.confirm": "Iscarta", "confirmations.discard_edit_media.message": "Tenes modìficas non sarvadas a is descritziones o a is anteprimas de is cuntenutos, ddas boles iscartare su matessi?", "confirmations.logout.confirm": "Essi·nche", @@ -256,6 +295,7 @@ "explore.trending_links": "Noas", "explore.trending_statuses": "Publicatziones", "explore.trending_tags": "Etichetas", + "featured_carousel.slide": "{index} de {total}", "filter_modal.added.context_mismatch_title": "Su cuntestu non currispondet.", "filter_modal.added.expired_title": "Filtru iscadidu.", "filter_modal.added.review_and_configure_title": "Cunfiguratziones de filtru", @@ -294,8 +334,12 @@ "footer.privacy_policy": "Polìtica de riservadesa", "footer.source_code": "Ammustra su còdighe de orìgine", "footer.status": "Istadu", + "footer.terms_of_service": "Cunditziones de su servìtziu", "generic.saved": "Sarvadu", "getting_started.heading": "Comente cumintzare", + "hashtag.admin_moderation": "Aberi s'interfache de moderatzione pro #{name}", + "hashtag.browse": "Nàviga in is publicatziones de #{hashtag}", + "hashtag.browse_from_account": "Nàviga in is publicatziones de @{name} in #{hashtag}", "hashtag.column_header.tag_mode.all": "e {additional}", "hashtag.column_header.tag_mode.any": "o {additional}", "hashtag.column_header.tag_mode.none": "sena {additional}", @@ -308,7 +352,10 @@ "hashtag.counter_by_accounts": "{count, plural, one {{counter} partetzipante} other {{counter} partetzipantes}}", "hashtag.counter_by_uses": "{count, plural, one {{counter} publicatzione} other {{counter} publicatziones}}", "hashtag.counter_by_uses_today": "{count, plural, one {{counter} publicatzione} other {{counter} publicatziones}} oe", + "hashtag.feature": "In evidèntzia in su profilu", "hashtag.follow": "Sighi su hashtag", + "hashtag.mute": "Pone #{hashtag} a sa muda", + "hashtag.unfeature": "No ddu pòngias in evidèntzia in su profilu", "hashtag.unfollow": "Non sigas prus s'eticheta", "hashtags.and_other": "… e {count, plural, one {un'àteru} other {àteros #}}", "hints.profiles.posts_may_be_missing": "Podet èssere chi ammanchent tzertas publicatziones de custu profilu.", @@ -324,9 +371,14 @@ "ignore_notifications_modal.filter_instead": "Opuru filtra", "ignore_notifications_modal.filter_to_act_users": "As a pòdere ancora atzetare, refudare o sinnalare a utentes", "ignore_notifications_modal.filter_to_avoid_confusion": "Filtrare agiudat a evitare possìbiles confusiones", + "info_button.label": "Agiudu", + "interaction_modal.go": "Sighi", + "interaction_modal.no_account_yet": "Non tenes galu perunu contu?", + "interaction_modal.on_another_server": "In un'àteru serbidore", "interaction_modal.on_this_server": "In custu serbidore", "interaction_modal.title.follow": "Sighi a {name}", "interaction_modal.title.reply": "Risponde a sa publicatzione de {name}", + "interaction_modal.username_prompt": "Pro es., {example}", "intervals.full.days": "{number, plural, one {# die} other {# dies}}", "intervals.full.hours": "{number, plural, one {# ora} other {# oras}}", "intervals.full.minutes": "{number, plural, one {# minutu} other {# minutos}}", @@ -339,6 +391,7 @@ "keyboard_shortcuts.direct": "pro abèrrere sa colunna de mèntovos privados", "keyboard_shortcuts.down": "Move in bàsciu in sa lista", "keyboard_shortcuts.enter": "Aberi una publicatzione", + "keyboard_shortcuts.favourite": "Publicatzione preferida", "keyboard_shortcuts.favourites": "Aberi sa lista de preferidos", "keyboard_shortcuts.federated": "Aberi sa lìnia de tempus federada", "keyboard_shortcuts.heading": "Incurtzaduras de tecladu", @@ -361,18 +414,25 @@ "keyboard_shortcuts.toggle_hidden": "Ammustra o cua su testu de is AC", "keyboard_shortcuts.toggle_sensitivity": "Ammustra/cua elementos multimediales", "keyboard_shortcuts.toot": "Cumintza a iscrìere una publicatzione noa", + "keyboard_shortcuts.translate": "pro tradùere una publicatzione", "keyboard_shortcuts.unfocus": "Essi de s'àrea de cumpositzione de testu o de chirca", "keyboard_shortcuts.up": "Move in susu in sa lista", "lightbox.close": "Serra", "lightbox.next": "Imbeniente", "lightbox.previous": "Pretzedente", + "lightbox.zoom_in": "Ismànnia finas a sa mannària atuale", "limited_account_hint.title": "Custu profilu est istadu cuadu dae sa moderatzione de {domain}.", + "link_preview.author": "Dae {name}", "link_preview.shares": "{count, plural, one {{counter} publicatzione} other {{counter} publicatziones}}", "lists.delete": "Cantzella sa lista", "lists.edit": "Modìfica sa lista", + "lists.remove_member": "Cantzella", "lists.replies_policy.followed": "Cale si siat persone chi sighis", "lists.replies_policy.list": "Persones de sa lista", "lists.replies_policy.none": "Nemos", + "lists.save": "Sarva", + "lists.search": "Chirca", + "lists.show_replies_to": "Include rispostas dae gente de sa lista a", "load_pending": "{count, plural, one {# elementu nou} other {# elementos noos}}", "loading_indicator.label": "Carrighende…", "media_gallery.hide": "Cua", @@ -387,8 +447,10 @@ "mute_modal.you_wont_see_mentions": "No as a bìdere is publicatziones chi mèntovent a custa persone.", "mute_modal.you_wont_see_posts": "At a pòdere bìdere is publicatziones tuas, però tue no as a bìdere cussas suas.", "navigation_bar.about": "Informatziones", + "navigation_bar.account_settings": "Crae e seguresa", "navigation_bar.administration": "Amministratzione", "navigation_bar.advanced_interface": "Aberi s'interfache web avantzada", + "navigation_bar.automated_deletion": "Cantzelladura automàtica de publicatziones", "navigation_bar.blocks": "Persones blocadas", "navigation_bar.bookmarks": "Sinnalibros", "navigation_bar.direct": "Mentziones privadas", @@ -398,13 +460,18 @@ "navigation_bar.follow_requests": "Rechestas de sighidura", "navigation_bar.followed_tags": "Etichetas sighidas", "navigation_bar.follows_and_followers": "Gente chi sighis e sighiduras", + "navigation_bar.import_export": "Importatzione e esportatzione", "navigation_bar.lists": "Listas", + "navigation_bar.live_feed_local": "Canale in direta (locale)", + "navigation_bar.live_feed_public": "Canale in direta (pùblicu)", "navigation_bar.logout": "Essi", "navigation_bar.moderation": "Moderatzione", + "navigation_bar.more": "Àteru", "navigation_bar.mutes": "Persones a sa muda", "navigation_bar.opened_in_classic_interface": "Publicatziones, contos e àteras pàginas ispetzìficas sunt abertas in manera predefinida in s'interfache web clàssica.", "navigation_bar.preferences": "Preferèntzias", "navigation_bar.search": "Chirca", + "navigation_bar.search_trends": "Chirca / in tendèntzia", "not_signed_in_indicator.not_signed_in": "Ti depes identificare pro atzèdere a custa resursa.", "notification.admin.report": "{name} at sinnaladu a {target}", "notification.admin.report_account": "{name} at sinnaladu {count, plural, one {una publicatzione} other {# publicatziones}} dae {target} pro {category}", @@ -461,15 +528,19 @@ "notification_requests.minimize_banner": "Mìnima su bànner de notìficas filtradas", "notification_requests.notifications_from": "Notìficas dae {name}", "notification_requests.title": "Notìficas filtradas", + "notification_requests.view": "Mustra notìficas", "notifications.clear": "Lìmpia notìficas", "notifications.clear_confirmation": "Seguru chi boles isboidare in manera permanente totu is notìficas tuas?", + "notifications.clear_title": "Boles cantzellare is notìficas?", "notifications.column_settings.admin.report": "Informes noos:", + "notifications.column_settings.admin.sign_up": "Registros noos:", "notifications.column_settings.alert": "Notìficas de iscrivania", "notifications.column_settings.favourite": "Preferidos:", "notifications.column_settings.filter_bar.advanced": "Ammustra totu is categorias", "notifications.column_settings.filter_bar.category": "Barra de filtru lestru", "notifications.column_settings.follow": "Sighiduras noas:", "notifications.column_settings.follow_request": "Rechestas noas de sighidura:", + "notifications.column_settings.group": "Grupu", "notifications.column_settings.mention": "Mèntovos:", "notifications.column_settings.poll": "Resurtados de su sondàgiu:", "notifications.column_settings.push": "Notìficas push", @@ -493,6 +564,8 @@ "notifications.permission_denied": "Is notìficas de iscrivania non sunt a disponimentu pro neghe de rechestas de permissu chi sunt istadas dennegadas in antis", "notifications.permission_denied_alert": "Is notìficas de iscrivania non podent èssere abilitadas, ca su permissu de su navigadore est istadu dennegadu in antis", "notifications.permission_required": "Is notìficas de iscrivania no sunt a disponimentu ca ammancat su permissu rechèdidu.", + "notifications.policy.accept": "Atzeta", + "notifications.policy.accept_hint": "Mustra in is notìficas", "notifications.policy.filter_new_accounts.hint": "Creadu {days, plural, one {erisero} other {in is ùrtimas # dies}}", "notifications.policy.filter_new_accounts_title": "Contos noos", "notifications.policy.filter_not_followers_title": "Gente chi non ti sighit", @@ -501,8 +574,15 @@ "notifications_permission_banner.enable": "Abilita is notìficas de iscrivania", "notifications_permission_banner.how_to_control": "Pro retzire notìficas cando Mastodon no est abertu, abilita is notìficas de iscrivania. Podes controllare cun pretzisione is castas de interatziones chi ingendrant notìficas de iscrivania pro mèdiu de su butone {icon} in subra, cando sunt abilitadas.", "notifications_permission_banner.title": "Non ti perdas mai nudda", + "onboarding.follows.back": "A coa", + "onboarding.follows.done": "Fatu", + "onboarding.follows.search": "Chirca", + "onboarding.follows.title": "Sighi a gente pro cumintzare", "onboarding.profile.display_name": "Nòmine visìbile", "onboarding.profile.note": "Biografia", + "onboarding.profile.save_and_continue": "Sarva e sighi", + "onboarding.profile.title": "Cunfiguratzione de profilu", + "onboarding.profile.upload_avatar": "Càrriga una fotografia de profilu", "picture_in_picture.restore": "Torra·ddu a ue fiat", "poll.closed": "Serradu", "poll.refresh": "Atualiza", @@ -597,6 +677,7 @@ "search_results.hashtags": "Etichetas", "search_results.see_all": "Bide totu", "search_results.statuses": "Publicatziones", + "search_results.title": "Chirca \"{q}\"", "server_banner.about_active_users": "Gente chi at impreadu custu serbidore is ùrtimas 30 dies (Utentes cun Atividade a su Mese)", "server_banner.active_users": "utentes ativos", "server_banner.administered_by": "Amministradu dae:", diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json index e956bd05e7..2e118f3589 100644 --- a/app/javascript/mastodon/locales/uk.json +++ b/app/javascript/mastodon/locales/uk.json @@ -815,6 +815,8 @@ "status.bookmark": "Додати до закладок", "status.cancel_reblog_private": "Скасувати поширення", "status.cannot_reblog": "Цей допис не може бути поширений", + "status.context.load_new_replies": "Доступні нові відповіді", + "status.context.loading": "Перевірка додаткових відповідей", "status.continued_thread": "Продовження у потоці", "status.copy": "Копіювати посилання на допис", "status.delete": "Видалити", diff --git a/config/locales/fr-CA.yml b/config/locales/fr-CA.yml index bb68494118..a866debe45 100644 --- a/config/locales/fr-CA.yml +++ b/config/locales/fr-CA.yml @@ -319,6 +319,7 @@ fr-CA: create: Créer une annonce title: Nouvelle annonce preview: + disclaimer: Étant donné que les utilisateurs ne peuvent pas s'en retirer, les notifications par courriel devraient être limitées à des annonces importantes telles que des violations de données personnelles ou des notifications de fermeture de serveur. explanation_html: 'L''e-mail sera envoyé à %{display_count} utilisateurs. Le texte suivant sera inclus :' title: Aperçu de la notification d'annonce publish: Publier diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 6fcdb5b972..c171a9ed73 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -319,6 +319,7 @@ fr: create: Créer une annonce title: Nouvelle annonce preview: + disclaimer: Étant donné que les utilisateurs ne peuvent pas s'en retirer, les notifications par courriel devraient être limitées à des annonces importantes telles que des violations de données personnelles ou des notifications de fermeture de serveur. explanation_html: 'L''e-mail sera envoyé à %{display_count} utilisateurs. Le texte suivant sera inclus :' title: Aperçu de la notification d'annonce publish: Publier diff --git a/config/locales/ru.yml b/config/locales/ru.yml index f961e0fba4..35f79c67c0 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -1393,7 +1393,7 @@ ru: featured_tags: add_new: Добавить errors: - limit: Вы уже добавили максимальное число хештегов + limit: Вы достигли максимального количества хештегов, которые можно рекомендовать в профиле hint_html: "Рекомендуйте самые важные для вас хештеги в своём профиле. Это отличный инструмент для того, чтобы держать подписчиков в курсе ваших долгосрочных проектов и творческих работ. Рекомендации хештегов заметны в вашем профиле и предоставляют быстрый доступ к вашим постам." filters: contexts: @@ -1552,16 +1552,16 @@ ru: many: Вы собираетесь игнорировать %{count} пользователей из файла %{filename}. one: Вы собираетесь игнорировать %{count} пользователя из файла %{filename}. other: Вы собираетесь игнорировать %{count} пользователей из файла %{filename}. - preface: Вы можете загрузить некоторые данные, например, списки людей, на которых Вы подписаны или которых блокируете, в Вашу учётную запись на этом узле из файлов, экспортированных с другого узла. - recent_imports: Недавно импортированное + preface: Вы можете перенести прежде экспортированные с другого сервера данные, такие как блокируемые вами пользователи и ваши подписки. + recent_imports: История импорта states: - finished: Готово - in_progress: В процессе - scheduled: Запланировано - unconfirmed: Неподтвержденный - status: Статус - success: Ваши данные были успешно загружены и будут обработаны с должной скоростью - time_started: Началось в + finished: Завершён + in_progress: Выполняется + scheduled: Запланирован + unconfirmed: Не подтверждён + status: Состояние + success: Ваши данные были загружены и в скором времени будут обработаны + time_started: Начат titles: blocking: Импорт списка заблокированных пользователей bookmarks: Импорт закладок @@ -1572,7 +1572,7 @@ ru: type: Тип импорта type_groups: constructive: Подписки и закладки - destructive: Блокировки и игнорируемые + destructive: Чёрный список types: blocking: Заблокированные пользователи bookmarks: Закладки @@ -1583,7 +1583,7 @@ ru: upload: Загрузить invites: delete: Удалить - expired: Истекло + expired: Срок действия истёк expires_in: '1800': 30 минут '21600': 6 часов @@ -1592,33 +1592,32 @@ ru: '604800': 1 неделя '86400': 1 день expires_in_prompt: Бессрочно - generate: Сгенерировать + generate: Создать ссылку invalid: Это приглашение недействительно - invited_by: 'Вас пригласил(а):' + invited_by: 'Вы были приглашены этим пользователем:' max_uses: few: "%{count} раза" many: "%{count} раз" one: "%{count} раз" - other: "%{count} раза" + other: "%{count} раз" max_uses_prompt: Без ограничения - prompt: Создавайте и делитесь ссылками с другими, чтобы предоставить им доступом к этому узлу. + prompt: Создавайте приглашения и делитесь ими с другими людьми, чтобы они могли зарегистрироваться на этом сервере table: expires_at: Истекает - uses: Исп. - title: Пригласить людей + uses: Регистрации + title: Приглашения lists: errors: - limit: Вы достигли максимального количества пользователей + limit: Вы достигли максимального количества списков login_activities: authentication_methods: - otp: приложение двухфакторной аутентификации - password: пароль - sign_in_token: код безопасности электронной почты - webauthn: ключи безопасности - description_html: Если вы видите неопознанное действие, смените пароль и/или включите двухфакторную авторизацию. - empty: Нет доступной истории входов - failed_sign_in_html: Неудачная попытка входа используя %{method} через %{browser} (%{ip}) - successful_sign_in_html: Успешный вход используя %{method} через %{browser} (%{ip}) + otp: приложения двухфакторной аутентификации + password: пароля + webauthn: электронного ключа + description_html: Если вы заметили действия, которых не совершали, вам следует сменить пароль и включить двухфакторную аутентификацию. + empty: История входов отсутствует + failed_sign_in_html: Неудачная попытка входа при помощи %{method} с IP-адреса %{ip} (%{browser}) + successful_sign_in_html: Вход при помощи %{method} с IP-адреса %{ip} (%{browser}) title: История входов mail_subscriptions: unsubscribe: diff --git a/config/locales/simple_form.el.yml b/config/locales/simple_form.el.yml index cc222cc85e..fd32c08744 100644 --- a/config/locales/simple_form.el.yml +++ b/config/locales/simple_form.el.yml @@ -231,8 +231,8 @@ el: setting_always_send_emails: Πάντα να αποστέλλονται ειδοποίησεις μέσω email setting_auto_play_gif: Αυτόματη αναπαραγωγή των GIF setting_boost_modal: Επιβεβαίωση πριν την προώθηση - setting_default_language: Γλώσσα δημοσιεύσεων - setting_default_privacy: Ιδιωτικότητα δημοσιεύσεων + setting_default_language: Γλώσσα κατά την ανάρτηση + setting_default_privacy: Ιδιωτικότητα αναρτήσεων setting_default_quote_policy: Ποιος μπορεί να παραθέσει setting_default_sensitive: Σημείωση όλων των πολυμέσων ως ευαίσθητου περιεχομένου setting_delete_modal: Επιβεβαίωση πριν τη διαγραφή ενός τουτ From 81da377d8e30bb5b00fefaeb65251bf007134937 Mon Sep 17 00:00:00 2001 From: Roni Laukkarinen Date: Fri, 25 Jul 2025 10:59:49 +0300 Subject: [PATCH 5/7] Fix Vite build failure on Node.js v20 due to undefined file.parentPath (#35509) --- vite.config.mts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/vite.config.mts b/vite.config.mts index f7871ece4d..c8e399e365 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -193,10 +193,11 @@ async function findEntrypoints() { withFileTypes: true, }); const jsExtTest = /\.[jt]sx?$/; + const jsEntrypointsDir = path.resolve(jsRoot, 'entrypoints'); for (const file of jsEntrypoints) { if (file.isFile() && jsExtTest.test(file.name)) { entrypoints[file.name.replace(jsExtTest, '')] = path.resolve( - file.parentPath, + jsEntrypointsDir, file.name, ); } @@ -208,10 +209,11 @@ async function findEntrypoints() { { withFileTypes: true }, ); const scssExtTest = /\.s?css$/; + const scssEntrypointsDir = path.resolve(jsRoot, 'styles/entrypoints'); for (const file of scssEntrypoints) { if (file.isFile() && scssExtTest.test(file.name)) { entrypoints[file.name.replace(scssExtTest, '')] = path.resolve( - file.parentPath, + scssEntrypointsDir, file.name, ); } From 5a88b7f6835d12935be27bb6d4ee21f7201ca2f7 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 25 Jul 2025 14:35:24 +0200 Subject: [PATCH 6/7] Add experimental basic quote post authoring (#35355) --- app/controllers/api/v1/statuses_controller.rb | 13 ++ app/lib/activitypub/activity.rb | 18 +++ app/lib/activitypub/activity/accept.rb | 26 ++++ app/lib/activitypub/activity/reject.rb | 17 +++ app/lib/activitypub/case_transform.rb | 4 +- app/lib/status_cache_hydrator.rb | 2 + .../status/interaction_policy_concern.rb | 63 +++++++++ app/models/status.rb | 8 +- app/policies/status_policy.rb | 5 + .../activitypub/note_serializer.rb | 25 +++- app/serializers/rest/status_serializer.rb | 9 ++ app/services/post_status_service.rb | 13 +- .../activitypub/quote_request_worker.rb | 22 +++ .../status_update_distribution_worker.rb | 4 +- config/locales/en.yml | 1 + spec/lib/activitypub/activity/accept_spec.rb | 125 +++++++++++++++++- spec/lib/activitypub/activity/reject_spec.rb | 22 +++ spec/lib/status_cache_hydrator_spec.rb | 12 ++ .../status/interaction_policy_concern_spec.rb | 42 ++++++ spec/policies/status_policy_spec.rb | 86 ++++++++++++ spec/requests/api/v1/statuses_spec.rb | 21 +++ .../activitypub/note_serializer_spec.rb | 16 +++ spec/services/post_status_service_spec.rb | 8 ++ .../activitypub/quote_request_worker_spec.rb | 30 +++++ .../status_update_distribution_worker_spec.rb | 68 +++++++--- 25 files changed, 619 insertions(+), 41 deletions(-) create mode 100644 app/models/concerns/status/interaction_policy_concern.rb create mode 100644 app/workers/activitypub/quote_request_worker.rb create mode 100644 spec/models/concerns/status/interaction_policy_concern_spec.rb create mode 100644 spec/workers/activitypub/quote_request_worker_spec.rb diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index e25b161afd..f047ba6046 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -10,6 +10,7 @@ class Api::V1::StatusesController < Api::BaseController before_action :set_statuses, only: [:index] before_action :set_status, only: [:show, :context] before_action :set_thread, only: [:create] + before_action :set_quoted_status, only: [:create] before_action :check_statuses_limit, only: [:index] override_rate_limit_headers :create, family: :statuses @@ -76,6 +77,7 @@ class Api::V1::StatusesController < Api::BaseController current_user.account, text: status_params[:status], thread: @thread, + quoted_status: @quoted_status, media_ids: status_params[:media_ids], sensitive: status_params[:sensitive], spoiler_text: status_params[:spoiler_text], @@ -147,6 +149,16 @@ class Api::V1::StatusesController < Api::BaseController render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404 end + def set_quoted_status + return unless Mastodon::Feature.outgoing_quotes_enabled? + + @quoted_status = Status.find(status_params[:quoted_status_id]) if status_params[:quoted_status_id].present? + authorize(@quoted_status, :quote?) if @quoted_status.present? + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError + # TODO: distinguish between non-existing and non-quotable posts + render json: { error: I18n.t('statuses.errors.quoted_status_not_found') }, status: 404 + end + def check_statuses_limit raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT end @@ -163,6 +175,7 @@ class Api::V1::StatusesController < Api::BaseController params.permit( :status, :in_reply_to_id, + :quoted_status_id, :sensitive, :spoiler_text, :visibility, diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 93b45e8018..64ee9acd05 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -116,6 +116,20 @@ class ActivityPub::Activity fetch_remote_original_status end + def quote_from_request_json(json) + quoted_status_uri = value_or_id(json['object']) + quoting_status_uri = value_or_id(json['instrument']) + return if quoting_status_uri.nil? || quoted_status_uri.nil? + + quoting_status = status_from_uri(quoting_status_uri) + return unless quoting_status.present? && quoting_status.quote.present? + + quoted_status = status_from_uri(quoted_status_uri) + return unless quoted_status.present? && quoted_status.account == @account && quoting_status.quote.quoted_status == quoted_status + + quoting_status.quote + end + def dereference_object! return unless @object.is_a?(String) @@ -143,6 +157,10 @@ class ActivityPub::Activity @follow_request_from_object ||= FollowRequest.find_by(target_account: @account, uri: object_uri) unless object_uri.nil? end + def quote_request_from_object + @quote_request_from_object ||= Quote.find_by(quoted_account: @account, activity_uri: object_uri) unless object_uri.nil? + end + def follow_from_object @follow_from_object ||= ::Follow.find_by(target_account: @account, uri: object_uri) unless object_uri.nil? end diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb index 5126e23c6a..144ba9645c 100644 --- a/app/lib/activitypub/activity/accept.rb +++ b/app/lib/activitypub/activity/accept.rb @@ -4,10 +4,13 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity def perform return accept_follow_for_relay if relay_follow? return accept_follow!(follow_request_from_object) unless follow_request_from_object.nil? + return accept_quote!(quote_request_from_object) unless quote_request_from_object.nil? case @object['type'] when 'Follow' accept_embedded_follow + when 'QuoteRequest' + accept_embedded_quote_request end end @@ -31,6 +34,29 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity RemoteAccountRefreshWorker.perform_async(request.target_account_id) if is_first_follow end + def accept_embedded_quote_request + approval_uri = value_or_id(first_of_value(@json['result'])) + return if approval_uri.nil? + + quote = quote_from_request_json(@object) + return unless quote.present? && quote.status.local? + + accept_quote!(quote) + end + + def accept_quote!(quote) + approval_uri = value_or_id(first_of_value(@json['result'])) + return if unsupported_uri_scheme?(approval_uri) || quote.quoted_account != @account || !quote.status.local? + + # NOTE: we are not going through `ActivityPub::VerifyQuoteService` as the `Accept` is as authoritative + # as the stamp, but this means we are not checking the stamp, which may lead to inconsistencies + # in case of an implementation bug + quote.update!(state: :accepted, approval_uri: approval_uri) + + DistributionWorker.perform_async(quote.status_id, { 'update' => true }) + ActivityPub::StatusUpdateDistributionWorker.perform_async(quote.status_id, { 'updated_at' => Time.now.utc.iso8601 }) + end + def accept_follow_for_relay relay.update!(state: :accepted) end diff --git a/app/lib/activitypub/activity/reject.rb b/app/lib/activitypub/activity/reject.rb index 886dddb235..3dafaba188 100644 --- a/app/lib/activitypub/activity/reject.rb +++ b/app/lib/activitypub/activity/reject.rb @@ -5,10 +5,13 @@ class ActivityPub::Activity::Reject < ActivityPub::Activity return reject_follow_for_relay if relay_follow? return follow_request_from_object.reject! unless follow_request_from_object.nil? return UnfollowService.new.call(follow_from_object.account, @account) unless follow_from_object.nil? + return reject_quote!(quote_request_from_object) unless quote_request_from_object.nil? case @object['type'] when 'Follow' reject_embedded_follow + when 'QuoteRequest' + reject_embedded_quote_request end end @@ -29,6 +32,20 @@ class ActivityPub::Activity::Reject < ActivityPub::Activity relay.update!(state: :rejected) end + def reject_embedded_quote_request + quote = quote_from_request_json(@object) + return unless quote.present? && quote.status.local? + + reject_quote!(quoting_status.quote) + end + + def reject_quote!(quote) + return unless quote.quoted_account == @account && quote.status.local? + + # TODO: broadcast an update? + quote.reject! + end + def relay @relay ||= Relay.find_by(follow_activity_id: object_uri) unless object_uri.nil? end diff --git a/app/lib/activitypub/case_transform.rb b/app/lib/activitypub/case_transform.rb index bf5de72210..b9e1d3a62b 100644 --- a/app/lib/activitypub/case_transform.rb +++ b/app/lib/activitypub/case_transform.rb @@ -12,9 +12,7 @@ module ActivityPub::CaseTransform when Hash then value.deep_transform_keys! { |key| camel_lower(key) } when Symbol then camel_lower(value.to_s).to_sym when String - camel_lower_cache[value] ||= if value.start_with?('_:') - "_:#{value.delete_prefix('_:').underscore.camelize(:lower)}" - elsif LanguagesHelper::ISO_639_1_REGIONAL.key?(value.to_sym) + camel_lower_cache[value] ||= if value.start_with?('_misskey') || LanguagesHelper::ISO_639_1_REGIONAL.key?(value.to_sym) value else value.underscore.camelize(:lower) diff --git a/app/lib/status_cache_hydrator.rb b/app/lib/status_cache_hydrator.rb index 674945c403..5260a723b3 100644 --- a/app/lib/status_cache_hydrator.rb +++ b/app/lib/status_cache_hydrator.rb @@ -71,6 +71,8 @@ class StatusCacheHydrator payload[:bookmarked] = Bookmark.exists?(account_id: account_id, status_id: status.id) payload[:pinned] = StatusPin.exists?(account_id: account_id, status_id: status.id) if status.account_id == account_id payload[:filtered] = mapped_applied_custom_filter(account_id, status) + # TODO: performance optimization by not loading `Account` twice + payload[:quote_approval][:current_user] = status.quote_policy_for_account(Account.find_by(id: account_id)) if payload[:quote_approval] payload[:quote] = hydrate_quote_payload(payload[:quote], status.quote, account_id, nested:) if payload[:quote] end diff --git a/app/models/concerns/status/interaction_policy_concern.rb b/app/models/concerns/status/interaction_policy_concern.rb new file mode 100644 index 0000000000..7e7642209d --- /dev/null +++ b/app/models/concerns/status/interaction_policy_concern.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Status::InteractionPolicyConcern + extend ActiveSupport::Concern + + QUOTE_APPROVAL_POLICY_FLAGS = { + unknown: (1 << 0), + public: (1 << 1), + followers: (1 << 2), + followed: (1 << 3), + }.freeze + + def quote_policy_as_keys(kind) + case kind + when :automatic + policy = quote_approval_policy >> 16 + when :manual + policy = quote_approval_policy & 0xFFFF + end + + QUOTE_APPROVAL_POLICY_FLAGS.keys.select { |key| policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[key]) }.map(&:to_s) + end + + # Returns `:automatic`, `:manual`, `:unknown` or `:denied` + def quote_policy_for_account(other_account, preloaded_relations: {}) + return :denied if other_account.nil? + + following_author = nil + + # Post author is always allowed to quote themselves + return :automatic if account_id == other_account.id + + automatic_policy = quote_approval_policy >> 16 + manual_policy = quote_approval_policy & 0xFFFF + + # Checking for public policy first because it's less expensive than looking at mentions + return :automatic if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:public]) + + # Mentioned users are always allowed to quote + if active_mentions.loaded? + return :automatic if active_mentions.any? { |mention| mention.account_id == other_account.id } + elsif active_mentions.exists?(account: other_account) + return :automatic + end + + if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:followers]) + following_author = preloaded_relations[:following] ? preloaded_relations[:following][account_id] : other_account.following?(account) if following_author.nil? + return :automatic if following_author + end + + # We don't know we are allowed by the automatic policy, considering the manual one + return :manual if manual_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:public]) + + if manual_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:followers]) + following_author = preloaded_relations[:following] ? preloaded_relations[:following][account_id] : other_account.following?(account) if following_author.nil? + return :manual if following_author + end + + return :unknown if (automatic_policy | manual_policy).anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:unknown]) + + :denied + end +end diff --git a/app/models/status.rb b/app/models/status.rb index e6e9450264..51150bec49 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -43,16 +43,10 @@ class Status < ApplicationRecord include Status::SnapshotConcern include Status::ThreadingConcern include Status::Visibility + include Status::InteractionPolicyConcern MEDIA_ATTACHMENTS_LIMIT = 4 - QUOTE_APPROVAL_POLICY_FLAGS = { - unknown: (1 << 0), - public: (1 << 1), - followers: (1 << 2), - followed: (1 << 3), - }.freeze - rate_limit by: :account, family: :statuses self.discard_column = :deleted_at diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index 540e266427..d9bb7201c0 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -19,6 +19,11 @@ class StatusPolicy < ApplicationPolicy end end + # This is about requesting a quote post, not validating it + def quote? + record.quote_policy_for_account(current_account, preloaded_relations: @preloaded_relations) != :denied + end + def reblog? !requires_mention? && (!private? || owned?) && show? && !blocking_author? end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 7b29e6d69b..95a869658c 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -3,7 +3,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer include FormattingHelper - context_extensions :atom_uri, :conversation, :sensitive, :voters_count + context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :quotes attributes :id, :type, :summary, :in_reply_to, :published, :url, @@ -30,6 +30,11 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer attribute :voters_count, if: :poll_and_voters_count? + attribute :quote, if: :quote? + attribute :quote, key: :_misskey_quote, if: :quote? + attribute :quote, key: :quote_uri, if: :quote? + attribute :quote_authorization, if: :quote_authorization? + def id ActivityPub::TagManager.instance.uri_for(object) end @@ -194,6 +199,24 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer object.preloadable_poll&.voters_count end + def quote? + object.quote&.present? + end + + def quote_authorization? + object.quote&.approval_uri.present? + end + + def quote + # TODO: handle inlining self-quotes + ActivityPub::TagManager.instance.uri_for(object.quote.quoted_status) + end + + def quote_authorization + # TODO: approval of local quotes may work differently, perhaps? + object.quote.approval_uri + end + class MediaAttachmentSerializer < ActivityPub::Serializer context_extensions :blurhash, :focal_point diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 29e77e7d5b..4ade813211 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -32,6 +32,7 @@ class REST::StatusSerializer < ActiveModel::Serializer has_one :quote, key: :quote, serializer: REST::QuoteSerializer has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer + has_one :quote_approval, if: -> { Mastodon::Feature.outgoing_quotes_enabled? } def quote object.quote if object.quote&.acceptable? @@ -159,6 +160,14 @@ class REST::StatusSerializer < ActiveModel::Serializer object.active_mentions.to_a.sort_by(&:id) end + def quote_approval + { + automatic: object.quote_policy_as_keys(:automatic), + manual: object.quote_policy_as_keys(:manual), + current_user: object.quote_policy_for_account(current_user&.account), + } + end + private def relationships diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index ac4b535ea9..73e78f0047 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -96,13 +96,11 @@ class PostStatusService < BaseService # NOTE: for now this is only for convenience in testing, as we don't support the request flow nor serialize quotes in ActivityPub # we only support incoming quotes so far - status.quote = Quote.new(quoted_status: @quoted_status) - status.quote.accept! if @status.account == @quoted_status.account || @quoted_status.active_mentions.exists?(mentions: { account_id: status.account_id }) - - # TODO: the following has yet to be implemented: - # - handle approval of local users (requires the interactionPolicy PR) - # - produce a QuoteAuthorization for quotes of local users - # - send a QuoteRequest for quotes of remote users + status.quote = Quote.create(quoted_status: @quoted_status, status: status) + if @quoted_status.local? && StatusPolicy.new(@status.account, @quoted_status).quote? + # TODO: produce a QuoteAuthorization + status.quote.accept! + end end def safeguard_mentions!(status) @@ -146,6 +144,7 @@ class PostStatusService < BaseService DistributionWorker.perform_async(@status.id) ActivityPub::DistributionWorker.perform_async(@status.id) PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll + ActivityPub::QuoteRequestWorker.perform_async(@status.quote.id) if @status.quote&.quoted_status.present? && !@status.quote&.quoted_status&.local? end def validate_media! diff --git a/app/workers/activitypub/quote_request_worker.rb b/app/workers/activitypub/quote_request_worker.rb new file mode 100644 index 0000000000..de5e054f69 --- /dev/null +++ b/app/workers/activitypub/quote_request_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ActivityPub::QuoteRequestWorker < ActivityPub::RawDistributionWorker + def perform(quote_id) + @quote = Quote.find(quote_id) + @account = @quote.account + + distribute! + rescue ActiveRecord::RecordNotFound + true + end + + protected + + def inboxes + @inboxes ||= [@quote.quoted_account&.inbox_url].compact + end + + def payload + @payload ||= Oj.dump(serialize_payload(@quote, ActivityPub::QuoteRequestSerializer, signer: @account)) + end +end diff --git a/app/workers/activitypub/status_update_distribution_worker.rb b/app/workers/activitypub/status_update_distribution_worker.rb index a79ede2bf6..7f70fcaecc 100644 --- a/app/workers/activitypub/status_update_distribution_worker.rb +++ b/app/workers/activitypub/status_update_distribution_worker.rb @@ -17,10 +17,10 @@ class ActivityPub::StatusUpdateDistributionWorker < ActivityPub::DistributionWor def activity ActivityPub::ActivityPresenter.new( - id: [ActivityPub::TagManager.instance.uri_for(@status), '#updates/', @status.edited_at.to_i].join, + id: [ActivityPub::TagManager.instance.uri_for(@status), '#updates/', @options[:updated_at]&.to_datetime&.to_i || @status.edited_at.to_i].join, type: 'Update', actor: ActivityPub::TagManager.instance.uri_for(@status.account), - published: @status.edited_at, + published: @options[:updated_at]&.to_datetime || @status.edited_at, to: ActivityPub::TagManager.instance.to(@status), cc: ActivityPub::TagManager.instance.cc(@status), virtual_object: @status diff --git a/config/locales/en.yml b/config/locales/en.yml index 4df63f4c73..204340f504 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1873,6 +1873,7 @@ en: edited_at_html: Edited %{date} errors: in_reply_not_found: The post you are trying to reply to does not appear to exist. + quoted_status_not_found: The post you are trying to quote does not appear to exist. over_character_limit: character limit of %{max} exceeded pin_errors: direct: Posts that are only visible to mentioned users cannot be pinned diff --git a/spec/lib/activitypub/activity/accept_spec.rb b/spec/lib/activitypub/activity/accept_spec.rb index 6d7c05e616..615287389c 100644 --- a/spec/lib/activitypub/activity/accept_spec.rb +++ b/spec/lib/activitypub/activity/accept_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe ActivityPub::Activity::Accept do - let(:sender) { Fabricate(:account) } + let(:sender) { Fabricate(:account, domain: 'example.com') } let(:recipient) { Fabricate(:account) } describe '#perform' do @@ -48,5 +48,128 @@ RSpec.describe ActivityPub::Activity::Accept do end end end + + context 'with a QuoteRequest' do + let(:status) { Fabricate(:status, account: recipient) } + let(:quoted_status) { Fabricate(:status, account: sender) } + let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status) } + let(:approval_uri) { "https://#{sender.domain}/approvals/1" } + + let(:json) do + { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + { + QuoteRequest: 'https://w3id.org/fep/044f#QuoteRequest', + }, + ], + id: 'foo', + type: 'Accept', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: { + id: quote.activity_uri, + type: 'QuoteRequest', + actor: ActivityPub::TagManager.instance.uri_for(recipient), + object: ActivityPub::TagManager.instance.uri_for(quoted_status), + instrument: ActivityPub::TagManager.instance.uri_for(status), + }, + result: approval_uri, + }.with_indifferent_access + end + + it 'marks the quote as approved and distribute an update' do + expect { subject.perform } + .to change { quote.reload.accepted? }.from(false).to(true) + .and change { quote.reload.approval_uri }.to(approval_uri) + expect(DistributionWorker) + .to have_enqueued_sidekiq_job(status.id, { 'update' => true }) + expect(ActivityPub::StatusUpdateDistributionWorker) + .to have_enqueued_sidekiq_job(status.id, { 'updated_at' => be_a(String) }) + end + + context 'when the quoted status is not from the sender of the Accept' do + let(:quoted_status) { Fabricate(:status, account: Fabricate(:account, domain: 'example.com')) } + + it 'does not mark the quote as approved and does not distribute an update' do + expect { subject.perform } + .to not_change { quote.reload.accepted? }.from(false) + .and not_change { quote.reload.approval_uri }.from(nil) + expect(DistributionWorker) + .to_not have_enqueued_sidekiq_job(status.id, { 'update' => true }) + expect(ActivityPub::StatusUpdateDistributionWorker) + .to_not have_enqueued_sidekiq_job(status.id, anything) + end + end + + context 'when the quoting status is from an unrelated user' do + let(:status) { Fabricate(:status, account: Fabricate(:account, domain: 'foobar.com')) } + + it 'does not mark the quote as approved and does not distribute an update' do + expect { subject.perform } + .to not_change { quote.reload.accepted? }.from(false) + .and not_change { quote.reload.approval_uri }.from(nil) + expect(DistributionWorker) + .to_not have_enqueued_sidekiq_job(status.id, { 'update' => true }) + expect(ActivityPub::StatusUpdateDistributionWorker) + .to_not have_enqueued_sidekiq_job(status.id, anything) + end + end + + context 'when approval_uri is missing' do + let(:approval_uri) { nil } + + it 'does not mark the quote as approved and does not distribute an update' do + expect { subject.perform } + .to not_change { quote.reload.accepted? }.from(false) + .and not_change { quote.reload.approval_uri }.from(nil) + expect(DistributionWorker) + .to_not have_enqueued_sidekiq_job(status.id, { 'update' => true }) + expect(ActivityPub::StatusUpdateDistributionWorker) + .to_not have_enqueued_sidekiq_job(status.id, anything) + end + end + + context 'when the QuoteRequest is referenced by its identifier' do + let(:json) do + { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + { + QuoteRequest: 'https://w3id.org/fep/044f#QuoteRequest', + }, + ], + id: 'foo', + type: 'Accept', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: quote.activity_uri, + result: approval_uri, + }.with_indifferent_access + end + + it 'marks the quote as approved and distribute an update' do + expect { subject.perform } + .to change { quote.reload.accepted? }.from(false).to(true) + .and change { quote.reload.approval_uri }.to(approval_uri) + expect(DistributionWorker) + .to have_enqueued_sidekiq_job(status.id, { 'update' => true }) + expect(ActivityPub::StatusUpdateDistributionWorker) + .to have_enqueued_sidekiq_job(status.id, { 'updated_at' => be_a(String) }) + end + + context 'when approval_uri is missing' do + let(:approval_uri) { nil } + + it 'does not mark the quote as approved and does not distribute an update' do + expect { subject.perform } + .to not_change { quote.reload.accepted? }.from(false) + .and not_change { quote.reload.approval_uri }.from(nil) + expect(DistributionWorker) + .to_not have_enqueued_sidekiq_job(status.id, { 'update' => true }) + expect(ActivityPub::StatusUpdateDistributionWorker) + .to_not have_enqueued_sidekiq_job(status.id, anything) + end + end + end + end end end diff --git a/spec/lib/activitypub/activity/reject_spec.rb b/spec/lib/activitypub/activity/reject_spec.rb index 1afb0cd403..ee8557f123 100644 --- a/spec/lib/activitypub/activity/reject_spec.rb +++ b/spec/lib/activitypub/activity/reject_spec.rb @@ -125,5 +125,27 @@ RSpec.describe ActivityPub::Activity::Reject do expect(relay.reload.rejected?).to be true end end + + context 'with a QuoteRequest' do + let(:status) { Fabricate(:status, account: recipient) } + let(:quoted_status) { Fabricate(:status, account: sender) } + let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, activity_uri: 'https://abc-123/456') } + let(:approval_uri) { "https://#{sender.domain}/approvals/1" } + + let(:object_json) do + { + id: 'https://abc-123/456', + type: 'QuoteRequest', + actor: ActivityPub::TagManager.instance.uri_for(recipient), + object: ActivityPub::TagManager.instance.uri_for(quoted_status), + instrument: ActivityPub::TagManager.instance.uri_for(status), + }.with_indifferent_access + end + + it 'marks the quote as rejected' do + expect { subject.perform } + .to change { quote.reload.rejected? }.from(false).to(true) + end + end end end diff --git a/spec/lib/status_cache_hydrator_spec.rb b/spec/lib/status_cache_hydrator_spec.rb index e56393da1d..f450997976 100644 --- a/spec/lib/status_cache_hydrator_spec.rb +++ b/spec/lib/status_cache_hydrator_spec.rb @@ -28,6 +28,18 @@ RSpec.describe StatusCacheHydrator do end end + context 'when handling a status with a quote policy', feature: :outgoing_quotes do + let(:status) { Fabricate(:status, quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16) } + + before do + account.follow!(status.account) + end + + it 'renders the same attributes as a full render' do + expect(subject).to eql(compare_to_hash) + end + end + context 'when handling a filtered status' do let(:status) { Fabricate(:status, text: 'this toot is about that banned word') } diff --git a/spec/models/concerns/status/interaction_policy_concern_spec.rb b/spec/models/concerns/status/interaction_policy_concern_spec.rb new file mode 100644 index 0000000000..af42f2bba3 --- /dev/null +++ b/spec/models/concerns/status/interaction_policy_concern_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Status::InteractionPolicyConcern do + let(:status) { Fabricate(:status, quote_approval_policy: (0b0101 << 16) | 0b0010) } + + describe '#quote_policy_as_keys' do + it 'returns the expected values' do + expect(status.quote_policy_as_keys(:automatic)).to eq ['unknown', 'followers'] + expect(status.quote_policy_as_keys(:manual)).to eq ['public'] + end + end + + describe '#quote_policy_for_account' do + let(:account) { Fabricate(:account) } + + context 'when the account is not following the user' do + it 'returns :manual because of the public entry in the manual policy' do + expect(status.quote_policy_for_account(account)).to eq :manual + end + end + + context 'when the account is following the user' do + before do + account.follow!(status.account) + end + + it 'returns :automatic because of the followers entry in the automatic policy' do + expect(status.quote_policy_for_account(account)).to eq :automatic + end + end + + context 'when the account falls into the unknown bucket' do + let(:status) { Fabricate(:status, quote_approval_policy: (0b0001 << 16) | 0b0100) } + + it 'returns :automatic because of the followers entry in the automatic policy' do + expect(status.quote_policy_for_account(account)).to eq :unknown + end + end + end +end diff --git a/spec/policies/status_policy_spec.rb b/spec/policies/status_policy_spec.rb index 69c0bad026..6362297045 100644 --- a/spec/policies/status_policy_spec.rb +++ b/spec/policies/status_policy_spec.rb @@ -86,6 +86,92 @@ RSpec.describe StatusPolicy, type: :model do end end + context 'with the permission of quote?' do + permissions :quote? do + it 'grants access when direct and account is viewer' do + status.visibility = :direct + + expect(subject).to permit(status.account, status) + end + + it 'grants access when direct and viewer is mentioned' do + status.visibility = :direct + status.mentions = [Fabricate(:mention, account: alice)] + + expect(subject).to permit(alice, status) + end + + it 'grants access when direct and non-owner viewer is mentioned and mentions are loaded' do + status.visibility = :direct + status.mentions = [Fabricate(:mention, account: bob)] + status.active_mentions.load + + expect(subject).to permit(bob, status) + end + + it 'denies access when direct and viewer is not mentioned' do + viewer = Fabricate(:account) + status.visibility = :direct + + expect(subject).to_not permit(viewer, status) + end + + it 'denies access when private and viewer is not mentioned' do + viewer = Fabricate(:account) + status.visibility = :private + + expect(subject).to_not permit(viewer, status) + end + + it 'grants access when private and viewer is mentioned' do + status.visibility = :private + status.mentions = [Fabricate(:mention, account: bob)] + + expect(subject).to permit(bob, status) + end + + it 'denies access when private and non-viewer is mentioned' do + viewer = Fabricate(:account) + status.visibility = :private + status.mentions = [Fabricate(:mention, account: bob)] + + expect(subject).to_not permit(viewer, status) + end + + it 'denies access when private and account is following viewer' do + follow = Fabricate(:follow) + status.visibility = :private + status.account = follow.target_account + + expect(subject).to_not permit(follow.account, status) + end + + it 'denies access when public but policy does not allow anyone' do + viewer = Fabricate(:account) + expect(subject).to_not permit(viewer, status) + end + + it 'grants access when public and policy allows everyone' do + status.quote_approval_policy = Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] + viewer = Fabricate(:account) + expect(subject).to permit(viewer, status) + end + + it 'denies access when public and policy allows followers but viewer is not one' do + status.quote_approval_policy = Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] + viewer = Fabricate(:account) + expect(subject).to_not permit(viewer, status) + end + + it 'grants access when public and policy allows followers and viewer is one' do + status.quote_approval_policy = Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] + viewer = Fabricate(:account) + viewer.follow!(status.account) + expect(subject).to permit(viewer, status) + end + end + end + context 'with the permission of reblog?' do permissions :reblog? do it 'denies access when private' do diff --git a/spec/requests/api/v1/statuses_spec.rb b/spec/requests/api/v1/statuses_spec.rb index 285fa93655..ac15ae2462 100644 --- a/spec/requests/api/v1/statuses_spec.rb +++ b/spec/requests/api/v1/statuses_spec.rb @@ -158,6 +158,27 @@ RSpec.describe '/api/v1/statuses' do end end + context 'with a self-quote post', feature: :outgoing_quotes do + let(:quoted_status) { Fabricate(:status, account: user.account) } + let(:params) do + { + status: 'Hello world, this is a self-quote', + quoted_status_id: quoted_status.id, + } + end + + it 'returns a quote post, as well as rate limit headers', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(response.content_type) + .to start_with('application/json') + expect(response.parsed_body[:quote]).to be_present + expect(response.headers['X-RateLimit-Limit']).to eq RateLimiter::FAMILIES[:statuses][:limit].to_s + expect(response.headers['X-RateLimit-Remaining']).to eq (RateLimiter::FAMILIES[:statuses][:limit] - 1).to_s + end + end + context 'with a safeguard' do let!(:alice) { Fabricate(:account, username: 'alice') } let!(:bob) { Fabricate(:account, username: 'bob') } diff --git a/spec/serializers/activitypub/note_serializer_spec.rb b/spec/serializers/activitypub/note_serializer_spec.rb index a6976193b2..d1af3f068f 100644 --- a/spec/serializers/activitypub/note_serializer_spec.rb +++ b/spec/serializers/activitypub/note_serializer_spec.rb @@ -41,4 +41,20 @@ RSpec.describe ActivityPub::NoteSerializer do .and(not_include(reply_by_other_first.uri)) # Replies from others .and(not_include(reply_by_account_visibility_direct.uri)) # Replies with direct visibility end + + context 'with a quote' do + let(:quoted_status) { Fabricate(:status) } + let(:approval_uri) { 'https://example.com/foo/bar' } + let!(:quote) { Fabricate(:quote, status: parent, quoted_status: quoted_status, approval_uri: approval_uri) } + + it 'has the expected shape' do + expect(subject).to include({ + 'type' => 'Note', + 'quote' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status), + 'quoteUri' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status), + '_misskey_quote' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status), + 'quoteAuthorization' => approval_uri, + }) + end + end end diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index 8836b9e0a6..7e47506a9f 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -291,6 +291,14 @@ RSpec.describe PostStatusService do ) end + it 'correctly requests a quote for remote posts' do + account = Fabricate(:account) + quoted_status = Fabricate(:status, account: Fabricate(:account, domain: 'example.com')) + + expect { subject.call(account, text: 'test', quoted_status: quoted_status) } + .to enqueue_sidekiq_job(ActivityPub::QuoteRequestWorker) + end + it 'returns existing status when used twice with idempotency key' do account = Fabricate(:account) status1 = subject.call(account, text: 'test', idempotency: 'meepmeep') diff --git a/spec/workers/activitypub/quote_request_worker_spec.rb b/spec/workers/activitypub/quote_request_worker_spec.rb new file mode 100644 index 0000000000..3d0131baaa --- /dev/null +++ b/spec/workers/activitypub/quote_request_worker_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::QuoteRequestWorker do + subject { described_class.new } + + let(:quoted_account) { Fabricate(:account, inbox_url: 'http://example.com', domain: 'example.com') } + let(:quoted_status) { Fabricate(:status, account: quoted_account) } + let(:status) { Fabricate(:status, text: 'foo') } + let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, activity_uri: 'TODO') } # TODO: activity URI + + describe '#perform' do + it 'sends the expected QuoteRequest activity' do + subject.perform(quote.id) + + expect(ActivityPub::DeliveryWorker) + .to have_enqueued_sidekiq_job(match_object_shape, quote.account_id, 'http://example.com', {}) + end + + def match_object_shape + match_json_values( + type: 'QuoteRequest', + actor: ActivityPub::TagManager.instance.uri_for(quote.account), + object: ActivityPub::TagManager.instance.uri_for(quoted_status), + instrument: anything # TODO: inline post in request? + ) + end + end +end diff --git a/spec/workers/activitypub/status_update_distribution_worker_spec.rb b/spec/workers/activitypub/status_update_distribution_worker_spec.rb index e9a70d11d1..58d11db41c 100644 --- a/spec/workers/activitypub/status_update_distribution_worker_spec.rb +++ b/spec/workers/activitypub/status_update_distribution_worker_spec.rb @@ -9,36 +9,64 @@ RSpec.describe ActivityPub::StatusUpdateDistributionWorker do let(:follower) { Fabricate(:account, protocol: :activitypub, inbox_url: 'http://example.com', domain: 'example.com') } describe '#perform' do - before do - follower.follow!(status.account) - - status.snapshot! - status.text = 'bar' - status.edited_at = Time.now.utc - status.snapshot! - status.save! - end - - context 'with public status' do + context 'with an explicitly edited status' do before do - status.update(visibility: :public) + follower.follow!(status.account) + + status.snapshot! + status.text = 'bar' + status.edited_at = Time.now.utc + status.snapshot! + status.save! end - it 'delivers to followers' do - expect_push_bulk_to_match(ActivityPub::DeliveryWorker, [[match_json_values(type: 'Update'), status.account.id, 'http://example.com', anything]]) do - subject.perform(status.id) + context 'with public status' do + before do + status.update(visibility: :public) + end + + it 'delivers to followers' do + expect { subject.perform(status.id) } + .to enqueue_sidekiq_job(ActivityPub::DeliveryWorker).with(match_json_values(type: 'Update'), status.account_id, 'http://example.com', anything) + end + end + + context 'with private status' do + before do + status.update(visibility: :private) + end + + it 'delivers to followers' do + expect { subject.perform(status.id) } + .to enqueue_sidekiq_job(ActivityPub::DeliveryWorker).with(match_json_values(type: 'Update'), status.account_id, 'http://example.com', anything) end end end - context 'with private status' do + context 'with an implicitly edited status' do before do - status.update(visibility: :private) + follower.follow!(status.account) end - it 'delivers to followers' do - expect_push_bulk_to_match(ActivityPub::DeliveryWorker, [[match_json_values(type: 'Update'), status.account.id, 'http://example.com', anything]]) do - subject.perform(status.id) + context 'with public status' do + before do + status.update(visibility: :public) + end + + it 'delivers to followers' do + expect { subject.perform(status.id, { 'updated_at' => Time.now.utc.iso8601 }) } + .to enqueue_sidekiq_job(ActivityPub::DeliveryWorker).with(match_json_values(type: 'Update'), status.account_id, 'http://example.com', anything) + end + end + + context 'with private status' do + before do + status.update(visibility: :private) + end + + it 'delivers to followers' do + expect { subject.perform(status.id, { 'updated_at' => Time.now.utc.iso8601 }) } + .to enqueue_sidekiq_job(ActivityPub::DeliveryWorker).with(match_json_values(type: 'Update'), status.account_id, 'http://example.com', anything) end end end From e93efe0e131481635e88d7ad114ef66148626f90 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 25 Jul 2025 18:38:21 +0200 Subject: [PATCH 7/7] Fix unnecessary duplication in vite code for finding entrypoints (#35515) --- vite.config.mts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/vite.config.mts b/vite.config.mts index c8e399e365..30c0741aaa 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -189,11 +189,11 @@ async function findEntrypoints() { const entrypoints: Record = {}; // First, JS entrypoints - const jsEntrypoints = await readdir(path.resolve(jsRoot, 'entrypoints'), { + const jsEntrypointsDir = path.resolve(jsRoot, 'entrypoints'); + const jsEntrypoints = await readdir(jsEntrypointsDir, { withFileTypes: true, }); const jsExtTest = /\.[jt]sx?$/; - const jsEntrypointsDir = path.resolve(jsRoot, 'entrypoints'); for (const file of jsEntrypoints) { if (file.isFile() && jsExtTest.test(file.name)) { entrypoints[file.name.replace(jsExtTest, '')] = path.resolve( @@ -204,12 +204,11 @@ async function findEntrypoints() { } // Next, SCSS entrypoints - const scssEntrypoints = await readdir( - path.resolve(jsRoot, 'styles/entrypoints'), - { withFileTypes: true }, - ); - const scssExtTest = /\.s?css$/; const scssEntrypointsDir = path.resolve(jsRoot, 'styles/entrypoints'); + const scssEntrypoints = await readdir(scssEntrypointsDir, { + withFileTypes: true, + }); + const scssExtTest = /\.s?css$/; for (const file of scssEntrypoints) { if (file.isFile() && scssExtTest.test(file.name)) { entrypoints[file.name.replace(scssExtTest, '')] = path.resolve(