From 327eed00767a08217c09addeddfed97c9b91c95f Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 21 Jun 2022 15:16:22 +0200 Subject: Fix suspicious sign-in mails never being sent (#18599) * Add tests * Fix suspicious sign-in mails never being sent --- app/controllers/auth/sessions_controller.rb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) (limited to 'app') diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index c4c8151e3..f9a55eb4b 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -7,11 +7,18 @@ class Auth::SessionsController < Devise::SessionsController skip_before_action :require_functional! skip_before_action :update_user_sign_in + prepend_before_action :check_suspicious!, only: [:create] + include TwoFactorAuthenticationConcern before_action :set_instance_presenter, only: [:new] before_action :set_body_classes + def check_suspicious! + user = find_user + @login_is_suspicious = suspicious_sign_in?(user) unless user.nil? + end + def create super do |resource| # We only need to call this if this hasn't already been @@ -142,7 +149,7 @@ class Auth::SessionsController < Devise::SessionsController user_agent: request.user_agent ) - UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if suspicious_sign_in?(user) + UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious end def suspicious_sign_in?(user) -- cgit From 47f2ff617ded1c3cc523e6bb803e26a0e6dd4103 Mon Sep 17 00:00:00 2001 From: tateisu Date: Thu, 23 Jun 2022 08:44:27 +0900 Subject: use Notification::TYPES for api push subscription alerts (#18709) --- app/controllers/api/v1/push/subscriptions_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app') diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb index 47f2e6440..7148d63a4 100644 --- a/app/controllers/api/v1/push/subscriptions_controller.rb +++ b/app/controllers/api/v1/push/subscriptions_controller.rb @@ -52,6 +52,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController def data_params return {} if params[:data].blank? - params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status]) + params.require(:data).permit(:policy, alerts: Notification::TYPES) end end -- cgit From 65ad58984808c482de60894f4210a5595bd96eb2 Mon Sep 17 00:00:00 2001 From: mayaeh Date: Fri, 24 Jun 2022 06:08:52 +0900 Subject: Fixed the setting page's logo that is not displayed on the smartphone (#18710) --- app/javascript/styles/mastodon/admin.scss | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'app') diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index f83d6424a..66e2997f1 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -75,6 +75,13 @@ $content-width: 840px; height: 100px; } + .logo--wordmark { + display: inherit; + margin: inherit; + width: inherit; + height: 20px; + } + @media screen and (max-width: $no-columns-breakpoint) { & > a:first-child { display: none; -- cgit From 6c2d3038f421a94171594c70d490daeec1e1a2d7 Mon Sep 17 00:00:00 2001 From: Jeong Arm Date: Fri, 24 Jun 2022 06:09:32 +0900 Subject: Fix CDN_HOST not affected on full_asset_url (#18662) * Fix CDN_HOST not affected to assets url * Fix typo --- app/helpers/routing_helper.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'app') diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb index f95f46a56..0d5a8505a 100644 --- a/app/helpers/routing_helper.rb +++ b/app/helpers/routing_helper.rb @@ -16,7 +16,11 @@ module RoutingHelper def full_asset_url(source, **options) source = ActionController::Base.helpers.asset_url(source, **options) unless use_storage? - URI.join(root_url, source).to_s + URI.join(asset_host, source).to_s + end + + def asset_host + Rails.configuration.action_controller.asset_host || root_url end def full_pack_url(source, **options) -- cgit From 9c571a95db125aa2cbf31d1406d4327032ce9111 Mon Sep 17 00:00:00 2001 From: Takeshi Umeda Date: Fri, 24 Jun 2022 06:10:03 +0900 Subject: Fix missing , (#18660) --- app/lib/activitypub/parser/media_attachment_parser.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app') diff --git a/app/lib/activitypub/parser/media_attachment_parser.rb b/app/lib/activitypub/parser/media_attachment_parser.rb index 30bea1f0e..656be84b7 100644 --- a/app/lib/activitypub/parser/media_attachment_parser.rb +++ b/app/lib/activitypub/parser/media_attachment_parser.rb @@ -50,7 +50,7 @@ class ActivityPub::Parser::MediaAttachmentParser components = begin blurhash = @json['blurhash'] - if blurhash.present? && /^[\w#$%*+-.:;=?@\[\]^{|}~]+$/.match?(blurhash) + if blurhash.present? && /^[\w#$%*+,-.:;=?@\[\]^{|}~]+$/.match?(blurhash) Blurhash.components(blurhash) end end -- cgit From 35588d09e2fb720542f45fcc3a75c9c7c9f8d0a4 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 23 Jun 2022 23:12:01 +0200 Subject: Add /api/v1/admin/domain_allows (#18668) - `GET /api/v1/admin/domain_allows` lists allowed domains - `GET /api/v1/admin/domain_allows/:id` shows one by ID - `DELETE /api/v1/admin/domain_allows/:id` deletes a given domain from the list of allowed domains - `POST /api/v1/admin/domain_allows` to allow a new domain: if that domain is already allowed, the existing DomainAllow will be returned --- .../api/v1/admin/domain_allows_controller.rb | 95 +++++++++++++++++ app/models/domain_allow.rb | 1 + app/policies/domain_allow_policy.rb | 8 ++ .../rest/admin/domain_allow_serializer.rb | 9 ++ config/routes.rb | 1 + .../api/v1/admin/domain_allows_controller_spec.rb | 118 +++++++++++++++++++++ 6 files changed, 232 insertions(+) create mode 100644 app/controllers/api/v1/admin/domain_allows_controller.rb create mode 100644 app/serializers/rest/admin/domain_allow_serializer.rb create mode 100644 spec/controllers/api/v1/admin/domain_allows_controller_spec.rb (limited to 'app') diff --git a/app/controllers/api/v1/admin/domain_allows_controller.rb b/app/controllers/api/v1/admin/domain_allows_controller.rb new file mode 100644 index 000000000..838978ddb --- /dev/null +++ b/app/controllers/api/v1/admin/domain_allows_controller.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +class Api::V1::Admin::DomainAllowsController < Api::BaseController + include Authorization + include AccountableConcern + + LIMIT = 100 + + before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_allows' }, only: [:index, :show] + before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_allows' }, except: [:index, :show] + before_action :require_staff! + before_action :set_domain_allows, only: :index + before_action :set_domain_allow, only: [:show, :destroy] + + after_action :insert_pagination_headers, only: :index + + PAGINATION_PARAMS = %i(limit).freeze + + def create + authorize :domain_allow, :create? + + @domain_allow = DomainAllow.find_by(resource_params) + + if @domain_allow.nil? + @domain_allow = DomainAllow.create!(resource_params) + log_action :create, @domain_allow + end + + render json: @domain_allow, serializer: REST::Admin::DomainAllowSerializer + end + + def index + authorize :domain_allow, :index? + render json: @domain_allows, each_serializer: REST::Admin::DomainAllowSerializer + end + + def show + authorize @domain_allow, :show? + render json: @domain_allow, serializer: REST::Admin::DomainAllowSerializer + end + + def destroy + authorize @domain_allow, :destroy? + UnallowDomainService.new.call(@domain_allow) + log_action :destroy, @domain_allow + render json: @domain_allow, serializer: REST::Admin::DomainAllowSerializer + end + + private + + def set_domain_allows + @domain_allows = filtered_domain_allows.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + end + + def set_domain_allow + @domain_allow = DomainAllow.find(params[:id]) + end + + def filtered_domain_allows + # TODO: no filtering yet + DomainAllow.all + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_admin_domain_allows_url(pagination_params(max_id: pagination_max_id)) if records_continue? + end + + def prev_path + api_v1_admin_domain_allows_url(pagination_params(min_id: pagination_since_id)) unless @domain_allows.empty? + end + + def pagination_max_id + @domain_allows.last.id + end + + def pagination_since_id + @domain_allows.first.id + end + + def records_continue? + @domain_allows.size == limit_param(LIMIT) + end + + def pagination_params(core_params) + params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params) + end + + def resource_params + params.permit(:domain) + end +end diff --git a/app/models/domain_allow.rb b/app/models/domain_allow.rb index 4b0a89c18..6aa9267fe 100644 --- a/app/models/domain_allow.rb +++ b/app/models/domain_allow.rb @@ -11,6 +11,7 @@ # class DomainAllow < ApplicationRecord + include Paginable include DomainNormalizable include DomainMaterializable diff --git a/app/policies/domain_allow_policy.rb b/app/policies/domain_allow_policy.rb index 5030453bb..7a5b5d780 100644 --- a/app/policies/domain_allow_policy.rb +++ b/app/policies/domain_allow_policy.rb @@ -1,6 +1,14 @@ # frozen_string_literal: true class DomainAllowPolicy < ApplicationPolicy + def index? + admin? + end + + def show? + admin? + end + def create? admin? end diff --git a/app/serializers/rest/admin/domain_allow_serializer.rb b/app/serializers/rest/admin/domain_allow_serializer.rb new file mode 100644 index 000000000..ebdf33815 --- /dev/null +++ b/app/serializers/rest/admin/domain_allow_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class REST::Admin::DomainAllowSerializer < ActiveModel::Serializer + attributes :id, :domain, :created_at + + def id + object.id.to_s + end +end diff --git a/config/routes.rb b/config/routes.rb index 87833539f..1b9c50799 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -570,6 +570,7 @@ Rails.application.routes.draw do end end + resources :domain_allows, only: [:index, :show, :create, :destroy] resources :domain_blocks, only: [:index, :show, :update, :create, :destroy] namespace :trends do diff --git a/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb b/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb new file mode 100644 index 000000000..edee3ab6c --- /dev/null +++ b/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb @@ -0,0 +1,118 @@ +require 'rails_helper' + +RSpec.describe Api::V1::Admin::DomainAllowsController, type: :controller do + render_views + + let(:role) { 'admin' } + let(:user) { Fabricate(:user, role: role) } + let(:scopes) { 'admin:read admin:write' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + shared_examples 'forbidden for wrong scope' do |wrong_scope| + let(:scopes) { wrong_scope } + + it 'returns http forbidden' do + expect(response).to have_http_status(403) + end + end + + shared_examples 'forbidden for wrong role' do |wrong_role| + let(:role) { wrong_role } + + it 'returns http forbidden' do + expect(response).to have_http_status(403) + end + end + + describe 'GET #index' do + let!(:domain_allow) { Fabricate(:domain_allow) } + + before do + get :index + end + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', 'moderator' + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'returns the expected domain allows' do + json = body_as_json + expect(json.length).to eq 1 + expect(json[0][:id].to_i).to eq domain_allow.id + end + end + + describe 'GET #show' do + let!(:domain_allow) { Fabricate(:domain_allow) } + + before do + get :show, params: { id: domain_allow.id } + end + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', 'moderator' + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'returns expected domain name' do + json = body_as_json + expect(json[:domain]).to eq domain_allow.domain + end + end + + describe 'DELETE #destroy' do + let!(:domain_allow) { Fabricate(:domain_allow) } + + before do + delete :destroy, params: { id: domain_allow.id } + end + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', 'moderator' + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'deletes the block' do + expect(DomainAllow.find_by(id: domain_allow.id)).to be_nil + end + end + + describe 'POST #create' do + let!(:domain_allow) { Fabricate(:domain_allow, domain: 'example.com') } + + before do + post :create, params: { domain: 'foo.bar.com' } + end + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', 'moderator' + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'returns expected domain name' do + json = body_as_json + expect(json[:domain]).to eq 'foo.bar.com' + end + + it 'creates a domain block' do + expect(DomainAllow.find_by(domain: 'foo.bar.com')).to_not be_nil + end + end +end -- cgit From 829b978a89f71ecc76286be510d5ab70642ff014 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 26 Jun 2022 05:08:08 +0200 Subject: New Crowdin updates (#18671) * New translations en.json (Esperanto) * New translations en.json (Esperanto) * New translations en.json (Esperanto) * New translations en.yml (Korean) * New translations simple_form.en.yml (Korean) * New translations activerecord.en.yml (Korean) * New translations en.yml (German) * New translations en.yml (Dutch) * New translations simple_form.en.yml (Dutch) * New translations activerecord.en.yml (Dutch) * New translations en.yml (Dutch) * New translations en.yml (Icelandic) * New translations en.yml (Icelandic) * New translations simple_form.en.yml (Icelandic) * New translations activerecord.en.yml (Icelandic) * New translations en.yml (Thai) * New translations en.json (Thai) * New translations simple_form.en.yml (Thai) * New translations activerecord.en.yml (Thai) * New translations activerecord.en.yml (Indonesian) * New translations en.yml (Turkish) * New translations en.json (Galician) * New translations en.yml (Slovenian) * New translations en.yml (Galician) * New translations en.json (Esperanto) * New translations en.yml (Esperanto) * New translations en.yml (Esperanto) * New translations en.json (Esperanto) * New translations en.json (Esperanto) * New translations en.yml (Galician) * New translations en.json (Esperanto) * New translations en.json (Esperanto) * New translations en.json (Esperanto) * New translations simple_form.en.yml (Galician) * New translations en.json (Esperanto) * New translations en.json (Esperanto) * New translations en.yml (Esperanto) * New translations en.yml (Esperanto) * New translations en.json (Esperanto) * New translations en.yml (Ukrainian) * New translations en.yml (Ukrainian) * Run `yarn manage:translations` * Run `bundle exec i18n-tasks normalize` Co-authored-by: Yamagishi Kazutoshi --- app/javascript/mastodon/locales/eo.json | 120 ++++++++++++++++---------------- app/javascript/mastodon/locales/gl.json | 2 +- app/javascript/mastodon/locales/th.json | 2 +- config/locales/activerecord.id.yml | 8 +++ config/locales/activerecord.is.yml | 8 +++ config/locales/activerecord.ko.yml | 8 +++ config/locales/activerecord.nl.yml | 8 +++ config/locales/activerecord.th.yml | 8 +++ config/locales/eo.yml | 16 ++--- config/locales/gl.yml | 4 +- config/locales/is.yml | 12 ++++ config/locales/ko.yml | 11 +++ config/locales/nl.yml | 5 +- config/locales/simple_form.gl.yml | 4 +- config/locales/simple_form.is.yml | 6 ++ config/locales/simple_form.ko.yml | 6 ++ config/locales/simple_form.nl.yml | 4 ++ config/locales/simple_form.th.yml | 4 ++ config/locales/th.yml | 15 ++++ config/locales/tr.yml | 3 + config/locales/uk.yml | 16 +++++ 21 files changed, 195 insertions(+), 75 deletions(-) (limited to 'app') diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json index 236d25496..999f34be6 100644 --- a/app/javascript/mastodon/locales/eo.json +++ b/app/javascript/mastodon/locales/eo.json @@ -7,13 +7,13 @@ "account.block_domain": "Bloki domajnon {domain}", "account.blocked": "Blokita", "account.browse_more_on_origin_server": "Vidi pli ĉe la originala profilo", - "account.cancel_follow_request": "Nuligi peton de sekvado", + "account.cancel_follow_request": "Nuligi la demandon de sekvado", "account.direct": "Rekte mesaĝi @{name}", "account.disable_notifications": "Ĉesu sciigi min kiam @{name} mesaĝi", "account.domain_blocked": "Domajno blokita", - "account.edit_profile": "Redakti profilon", - "account.enable_notifications": "Sciigi min kiam @{name} mesaĝi", - "account.endorse": "Montri en profilo", + "account.edit_profile": "Redakti la profilon", + "account.enable_notifications": "Sciigi min kiam @{name} mesaĝas", + "account.endorse": "Rekomendi ĉe via profilo", "account.follow": "Sekvi", "account.followers": "Sekvantoj", "account.followers.empty": "Ankoraŭ neniu sekvas tiun uzanton.", @@ -22,7 +22,7 @@ "account.following_counter": "{count, plural, one {{counter} Sekvato} other {{counter} Sekvatoj}}", "account.follows.empty": "Tiu uzanto ankoraŭ ne sekvas iun.", "account.follows_you": "Sekvas vin", - "account.hide_reblogs": "Kaŝi plusendojn de @{name}", + "account.hide_reblogs": "Kaŝi la plusendojn de @{name}", "account.joined": "Kuniĝis {date}", "account.link_verified_on": "La posedanto de tiu ligilo estis kontrolita je {date}", "account.locked_info": "La privateco de tiu konto estas elektita kiel fermita. La posedanto povas mane akcepti tiun, kiu povas sekvi rin.", @@ -34,7 +34,7 @@ "account.muted": "Silentigita", "account.posts": "Mesaĝoj", "account.posts_with_replies": "Mesaĝoj kaj respondoj", - "account.report": "Signali @{name}", + "account.report": "Raporti @{name}", "account.requested": "Atendo de aprobo. Alklaku por nuligi peton de sekvado", "account.share": "Kundividi la profilon de @{name}", "account.show_reblogs": "Montri la plusendojn de @{name}", @@ -42,62 +42,62 @@ "account.unblock": "Malbloki @{name}", "account.unblock_domain": "Malbloki {domain}", "account.unblock_short": "Malbloki", - "account.unendorse": "Ne montri en profilo", + "account.unendorse": "Ne rekomendi ĉe la profilo", "account.unfollow": "Ne plu sekvi", - "account.unmute": "Malsilentigi @{name}", - "account.unmute_notifications": "Malsilentigi sciigojn de @{name}", - "account.unmute_short": "Malsilentigi", - "account_note.placeholder": "Alklaku por aldoni noton", + "account.unmute": "Ne plu silentigi @{name}", + "account.unmute_notifications": "Reebligi la sciigojn de @{name}", + "account.unmute_short": "Ne plu silentigi", + "account_note.placeholder": "Klaku por aldoni noton", "admin.dashboard.daily_retention": "User retention rate by day after sign-up", "admin.dashboard.monthly_retention": "User retention rate by month after sign-up", "admin.dashboard.retention.average": "Averaĝa", - "admin.dashboard.retention.cohort": "Registriĝo monato", + "admin.dashboard.retention.cohort": "Monato de registriĝo", "admin.dashboard.retention.cohort_size": "Novaj uzantoj", "alert.rate_limited.message": "Bonvolu reprovi post {retry_time, time, medium}.", "alert.rate_limited.title": "Mesaĝkvante limigita", "alert.unexpected.message": "Neatendita eraro okazis.", - "alert.unexpected.title": "Ups!", + "alert.unexpected.title": "Aj!", "announcement.announcement": "Anonco", "attachments_list.unprocessed": "(neprilaborita)", "autosuggest_hashtag.per_week": "{count} semajne", "boost_modal.combo": "Vi povas premi {combo} por preterpasi sekvafoje", "bundle_column_error.body": "Io misfunkciis en la ŝargado de ĉi tiu elemento.", - "bundle_column_error.retry": "Bonvolu reprovi", - "bundle_column_error.title": "Reta eraro", + "bundle_column_error.retry": "Provu refoje", + "bundle_column_error.title": "Eraro de reto", "bundle_modal_error.close": "Fermi", "bundle_modal_error.message": "Io misfunkciis en la ŝargado de ĉi tiu elemento.", - "bundle_modal_error.retry": "Bonvolu reprovi", + "bundle_modal_error.retry": "Provu refoje", "column.blocks": "Blokitaj uzantoj", "column.bookmarks": "Legosignoj", "column.community": "Loka templinio", "column.direct": "Rektaj mesaĝoj", "column.directory": "Trarigardi profilojn", "column.domain_blocks": "Blokitaj domajnoj", - "column.favourites": "Stelumoj", + "column.favourites": "Preferaĵoj", "column.follow_requests": "Demandoj de sekvado", "column.home": "Hejmo", "column.lists": "Listoj", "column.mutes": "Silentigitaj uzantoj", "column.notifications": "Sciigoj", "column.pins": "Alpinglitaj mesaĝoj", - "column.public": "Fratara templinio", + "column.public": "Federata templinio", "column_back_button.label": "Reveni", - "column_header.hide_settings": "Kaŝi agordojn", + "column_header.hide_settings": "Kaŝi la agordojn", "column_header.moveLeft_settings": "Movi kolumnon maldekstren", "column_header.moveRight_settings": "Movi kolumnon dekstren", "column_header.pin": "Alpingli", - "column_header.show_settings": "Montri agordojn", + "column_header.show_settings": "Montri la agordojn", "column_header.unpin": "Depingli", - "column_subheading.settings": "Agordado", + "column_subheading.settings": "Agordoj", "community.column_settings.local_only": "Nur loka", "community.column_settings.media_only": "Nur aŭdovidaĵoj", - "community.column_settings.remote_only": "Nur malproksima", + "community.column_settings.remote_only": "Nur fora", "compose.language.change": "Ŝanĝi lingvon", "compose.language.search": "Serĉi lingvojn...", "compose_form.direct_message_warning_learn_more": "Lerni pli", - "compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.", + "compose_form.encryption_warning": "La mesaĵoj en Mastodono ne estas ĉifrita de tutvojo. Ne kundividu sentemajn informojn ĉe Mastodono.", "compose_form.hashtag_warning": "Ĉi tiu mesaĝo ne estos listigita per ajna kradvorto. Nur publikaj mesaĝoj estas serĉeblaj per kradvortoj.", - "compose_form.lock_disclaimer": "Via konta ne estas {locked}. Iu ajn povas sekvi vin por vidi viajn mesaĝojn, kiuj estas nur por sekvantoj.", + "compose_form.lock_disclaimer": "Via konto ne estas {locked}. Iu ajn povas sekvi vin por vidi viajn mesaĝojn nur al la sekvantoj.", "compose_form.lock_disclaimer.lock": "ŝlosita", "compose_form.placeholder": "Kion vi pensas?", "compose_form.poll.add_option": "Aldoni elekteblon", @@ -116,7 +116,7 @@ "compose_form.spoiler.unmarked": "Teksto ne kaŝita", "compose_form.spoiler_placeholder": "Skribu vian averton ĉi tie", "confirmation_modal.cancel": "Nuligi", - "confirmations.block.block_and_report": "Bloki kaj signali", + "confirmations.block.block_and_report": "Bloki kaj raporti", "confirmations.block.confirm": "Bloki", "confirmations.block.message": "Ĉu vi certas, ke vi volas bloki {name}?", "confirmations.delete.confirm": "Forigi", @@ -124,7 +124,7 @@ "confirmations.delete_list.confirm": "Forigi", "confirmations.delete_list.message": "Ĉu vi certas, ke vi volas porĉiame forigi ĉi tiun liston?", "confirmations.discard_edit_media.confirm": "Ne konservi", - "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?", + "confirmations.discard_edit_media.message": "Vi havas nekonservitan ŝanĝon de la priskribo aŭ de la antaŭvido de aŭdvidaĵo, ĉu vi forigu ĝin?", "confirmations.domain_block.confirm": "Bloki la tutan domajnon", "confirmations.domain_block.message": "Ĉu vi vere, vere certas, ke vi volas tute bloki {domain}? Plej ofte, trafa blokado kaj silentigado sufiĉas kaj preferindas. Vi ne vidos enhavon de tiu domajno en publika templinio aŭ en viaj sciigoj. Viaj sekvantoj de tiu domajno estos forigitaj.", "confirmations.logout.confirm": "Adiaŭi", @@ -133,7 +133,7 @@ "confirmations.mute.explanation": "Ĉi-tio kaŝos mesaĝojn el ili kaj mesaĝojn kiuj mencias ilin, sed ili ankoraŭ rajtos vidi viajn mesaĝojn kaj sekvi vin.", "confirmations.mute.message": "Ĉu vi certas, ke vi volas silentigi {name}?", "confirmations.redraft.confirm": "Forigi kaj reskribi", - "confirmations.redraft.message": "Ĉu vi certas ke vi volas forigi tiun mesaĝon kaj reskribi ĝin? Ĉiuj diskonigoj kaj stelumoj estos perditaj, kaj respondoj al la originala mesaĝo estos senparentaj.", + "confirmations.redraft.message": "Ĉu vi certas ke vi volas forigi kaj reskribi la mesaĝon? Ĝiaj preferitaĵoj kaj ĝiaj plusendoj estos perditaj, kaj la respondoj al la originala mesaĝo estos orfaj.", "confirmations.reply.confirm": "Respondi", "confirmations.reply.message": "Respondi nun anstataŭigos la mesaĝon, kiun vi nun skribas. Ĉu vi certas, ke vi volas daŭrigi?", "confirmations.unfollow.confirm": "Ne plu sekvi", @@ -172,8 +172,8 @@ "empty_column.direct": "Vi ankoraŭ ne havas rektan mesaĝon. Kiam vi sendos aŭ ricevos iun, ĝi aperos ĉi tie.", "empty_column.domain_blocks": "Ankoraŭ neniu domajno estas blokita.", "empty_column.explore_statuses": "Nenio tendencas nun. Rekontrolu poste!", - "empty_column.favourited_statuses": "Vi ankoraŭ ne stelumis mesaĝon. Kiam vi stelumos iun, tiu aperos ĉi tie.", - "empty_column.favourites": "Ankoraŭ neniu stelumis tiun mesaĝon. Kiam iu faros tion, tiu aperos ĉi tie.", + "empty_column.favourited_statuses": "Vi ankoraŭ ne havas mesaĝon en la preferaĵoj. Kiam vi aldonas ion, ĝi aperos ĉi tie.", + "empty_column.favourites": "Ankoraŭ neniu preferis la mesaĝon. Kiam iu faros ĉi tion, ili aperos ĉi tie.", "empty_column.follow_recommendations": "Ŝajnas, ke neniuj sugestoj povis esti generitaj por vi. Vi povas provi uzi serĉon por serĉi homojn, kiujn vi eble konas, aŭ esplori tendencajn kradvortojn.", "empty_column.follow_requests": "Vi ne ankoraŭ havas iun peton de sekvado. Kiam vi ricevos unu, ĝi aperos ĉi tie.", "empty_column.hashtag": "Ankoraŭ estas nenio per ĉi tiu kradvorto.", @@ -198,10 +198,10 @@ "explore.trending_tags": "Kradvortoj", "follow_recommendations.done": "Farita", "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.", - "follow_recommendations.lead": "La mesaĝoj de personoj kiujn vi sekvas, aperos kronologie en via abonfluo. Ne timu erari, vi povas ĉesi sekvi facile iam ajn!", + "follow_recommendations.lead": "La mesaĝoj de personoj kiujn vi sekvas, kronologie aperos en via hejma templinio. Ne timu erari, vi povas ĉesi sekvi facile iam ajn!", "follow_request.authorize": "Rajtigi", "follow_request.reject": "Rifuzi", - "follow_requests.unlocked_explanation": "Kvankam via konto ne estas ŝlosita, la teamo de {domain} pensis ke vi eble volas kontroli la demandojn de sekvado de ĉi tiuj kontoj permane.", + "follow_requests.unlocked_explanation": "Kvankam via konto ne estas ŝlosita, la teamo de {domain} pensis ke vi eble volas permane kontroli la demandojn de sekvado de ĉi tiuj kontoj.", "generic.saved": "Konservita", "getting_started.developers": "Programistoj", "getting_started.directory": "Profilujo", @@ -237,9 +237,9 @@ "keyboard_shortcuts.direct": "malfermi la kolumnon de rektaj mesaĝoj", "keyboard_shortcuts.down": "iri suben en la listo", "keyboard_shortcuts.enter": "malfermi mesaĝon", - "keyboard_shortcuts.favourite": "stelumi", - "keyboard_shortcuts.favourites": "malfermi la liston de stelumoj", - "keyboard_shortcuts.federated": "Malfermi la frataran templinion", + "keyboard_shortcuts.favourite": "Aldoni la mesaĝon al preferaĵoj", + "keyboard_shortcuts.favourites": "Malfermi la liston de preferaĵoj", + "keyboard_shortcuts.federated": "Malfermi la federatan templinion", "keyboard_shortcuts.heading": "Klavaraj mallongigoj", "keyboard_shortcuts.home": "Malfermi la hejman templinion", "keyboard_shortcuts.hotkey": "Rapidklavo", @@ -279,7 +279,7 @@ "lists.replies_policy.followed": "Iu sekvanta uzanto", "lists.replies_policy.list": "Membroj de la listo", "lists.replies_policy.none": "Neniu", - "lists.replies_policy.title": "Montri respondon al:", + "lists.replies_policy.title": "Montri respondojn al:", "lists.search": "Serĉi inter la homoj, kiujn vi sekvas", "lists.subheading": "Viaj listoj", "load_pending": "{count,plural, one {# nova elemento} other {# novaj elementoj}}", @@ -312,10 +312,10 @@ "navigation_bar.personal": "Persone", "navigation_bar.pins": "Alpinglitaj mesaĝoj", "navigation_bar.preferences": "Preferoj", - "navigation_bar.public_timeline": "Fratara templinio", + "navigation_bar.public_timeline": "Federata templinio", "navigation_bar.security": "Sekureco", "notification.admin.sign_up": "{name} registris", - "notification.favourite": "{name} stelumis vian mesaĝon", + "notification.favourite": "{name} preferis vian mesaĝon", "notification.follow": "{name} eksekvis vin", "notification.follow_request": "{name} petis sekvi vin", "notification.mention": "{name} menciis vin", @@ -328,10 +328,10 @@ "notifications.clear_confirmation": "Ĉu vi certas, ke vi volas porĉiame forviŝi ĉiujn viajn sciigojn?", "notifications.column_settings.admin.sign_up": "Novaj registriĝoj:", "notifications.column_settings.alert": "Retumilaj sciigoj", - "notifications.column_settings.favourite": "Stelumoj:", + "notifications.column_settings.favourite": "Preferaĵoj:", "notifications.column_settings.filter_bar.advanced": "Montri ĉiujn kategoriojn", "notifications.column_settings.filter_bar.category": "Rapida filtra breto", - "notifications.column_settings.filter_bar.show_bar": "Montru filtrilon", + "notifications.column_settings.filter_bar.show_bar": "Montri la breton de filtrilo", "notifications.column_settings.follow": "Novaj sekvantoj:", "notifications.column_settings.follow_request": "Novaj petoj de sekvado:", "notifications.column_settings.mention": "Mencioj:", @@ -346,7 +346,7 @@ "notifications.column_settings.update": "Redaktoj:", "notifications.filter.all": "Ĉiuj", "notifications.filter.boosts": "Plusendoj", - "notifications.filter.favourites": "Stelumoj", + "notifications.filter.favourites": "Preferaĵoj", "notifications.filter.follows": "Sekvoj", "notifications.filter.mentions": "Mencioj", "notifications.filter.polls": "Balotenketaj rezultoj", @@ -381,7 +381,7 @@ "privacy.unlisted.short": "Nelistigita", "refresh": "Refreŝigu", "regeneration_indicator.label": "Ŝargado…", - "regeneration_indicator.sublabel": "Via hejma fluo pretiĝas!", + "regeneration_indicator.sublabel": "Via abonfluo estas preparata!", "relative_time.days": "{number}t", "relative_time.full.days": "{number, plural, one {# day} other {# days}} ago", "relative_time.full.hours": "{number, plural, one {# hour} other {# hours}} ago", @@ -397,18 +397,18 @@ "report.block": "Bloki", "report.block_explanation": "You will not see their posts. They will not be able to see your posts or follow you. They will be able to tell that they are blocked.", "report.categories.other": "Aliaj", - "report.categories.spam": "Spamo", + "report.categories.spam": "Trudaĵo", "report.categories.violation": "Content violates one or more server rules", "report.category.subtitle": "Elektu la plej bonan kongruon", "report.category.title": "Diru al ni kio okazas pri ĉi tiu {type}", "report.category.title_account": "profilo", "report.category.title_status": "afiŝo", "report.close": "Farita", - "report.comment.title": "Is there anything else you think we should know?", + "report.comment.title": "Ĉu estas io alia kion vi pensas ke ni devas scii?", "report.forward": "Plusendi al {target}", - "report.forward_hint": "La konto estas en alia servilo. Ĉu sendi sennomigitan kopion de la signalo ankaŭ tien?", + "report.forward_hint": "La konto estas de alia servilo. Ĉu vi volas sendi anoniman kopion de la informo ankaŭ al tie?", "report.mute": "Silentigi", - "report.mute_explanation": "Vi ne vidos iliajn afiŝojn. Ili ankoraŭ povas sekvi vin kaj vidi viajn afiŝojn, kaj ne scios ke si estas silentigitaj.", + "report.mute_explanation": "Vi ne vidos iliajn afiŝojn. Ili ankoraŭ povas sekvi vin kaj vidi viajn afiŝojn, kaj ne scios ke ili estas silentigitaj.", "report.next": "Sekva", "report.placeholder": "Pliaj komentoj", "report.reasons.dislike": "Mi ne ŝatas ĝin", @@ -417,20 +417,20 @@ "report.reasons.other_description": "La problemo ne taŭgas en aliaj kategorioj", "report.reasons.spam": "Ĝi estas trudaĵo", "report.reasons.spam_description": "Malicious links, fake engagement, or repetitive replies", - "report.reasons.violation": "Ĝi malrespektas servilajn regulojn", + "report.reasons.violation": "Ĝi malobservas la regulojn de la servilo", "report.reasons.violation_description": "You are aware that it breaks specific rules", "report.rules.subtitle": "Elektu ĉiujn, kiuj validas", "report.rules.title": "Kiuj reguloj estas malobservataj?", "report.statuses.subtitle": "Elektu ĉiujn, kiuj validas", "report.statuses.title": "Are there any posts that back up this report?", "report.submit": "Sendi", - "report.target": "Signali {target}", + "report.target": "Raporto pri {target}", "report.thanks.take_action": "Here are your options for controlling what you see on Mastodon:", "report.thanks.take_action_actionable": "While we review this, you can take action against @{name}:", "report.thanks.title": "Ĉu vi ne volas vidi ĉi tion?", "report.thanks.title_actionable": "Dankon pro raporti, ni esploros ĉi tion.", "report.unfollow": "Malsekvi @{name}", - "report.unfollow_explanation": "Vi estas sekvanta ĉi tiun konton. Por ne plu vidi ties afiŝojn en via hejma templinio, malsekvu ilin.", + "report.unfollow_explanation": "Vi sekvas ĉi tiun konton. Por ne plu vidi ĝiajn abonfluojn en via hejma templinio, ĉesu sekvi ĝin.", "search.placeholder": "Serĉi", "search_popout.search_format": "Detala serĉo", "search_popout.tips.full_text": "Simplaj tekstoj montras la mesaĝojn, kiujn vi skribis, stelumis, diskonigis, aŭ en kiuj vi estis menciita, sed ankaŭ kongruajn uzantnomojn, montratajn nomojn, kaj kradvortojn.", @@ -459,7 +459,7 @@ "status.edited": "Redaktita {date}", "status.edited_x_times": "Redactita {count, plural, one {{count} fojon} other {{count} fojojn}}", "status.embed": "Enkorpigi", - "status.favourite": "Stelumi", + "status.favourite": "Preferaĵo", "status.filtered": "Filtrita", "status.history.created": "{name} kreis {date}", "status.history.edited": "{name} redaktis {date}", @@ -469,8 +469,8 @@ "status.more": "Pli", "status.mute": "Silentigi @{name}", "status.mute_conversation": "Silentigi konversacion", - "status.open": "Grandigi ĉi tiun mesaĝon", - "status.pin": "Alpingli profile", + "status.open": "Disvolvi la mesaĝon", + "status.pin": "Alpingli al la profilo", "status.pinned": "Alpinglita mesaĝo", "status.read_more": "Legi pli", "status.reblog": "Plusendi", @@ -481,20 +481,20 @@ "status.remove_bookmark": "Forigi legosignon", "status.reply": "Respondi", "status.replyAll": "Respondi al la fadeno", - "status.report": "Signali @{name}", + "status.report": "Raporti @{name}", "status.sensitive_warning": "Tikla enhavo", - "status.share": "Diskonigi", - "status.show_less": "Malgrandigi", - "status.show_less_all": "Malgrandigi ĉiujn", - "status.show_more": "Grandigi", - "status.show_more_all": "Malfoldi ĉiun", - "status.show_thread": "Montri la fadenon", + "status.share": "Kundividi", + "status.show_less": "Montri malpli", + "status.show_less_all": "Montri malpli ĉiun", + "status.show_more": "Montri pli", + "status.show_more_all": "Montri pli ĉiun", + "status.show_thread": "Montri la mesaĝaron", "status.uncached_media_warning": "Nedisponebla", "status.unmute_conversation": "Malsilentigi la konversacion", "status.unpin": "Depingli de profilo", "suggestions.dismiss": "Forigi la proponon", "suggestions.header": "Vi povus interesiĝi pri…", - "tabs_bar.federated_timeline": "Fratara templinio", + "tabs_bar.federated_timeline": "Federata", "tabs_bar.home": "Hejmo", "tabs_bar.local_timeline": "Loka templinio", "tabs_bar.notifications": "Sciigoj", @@ -539,7 +539,7 @@ "video.close": "Fermi la videon", "video.download": "Elŝuti dosieron", "video.exit_fullscreen": "Eksigi plenekrana", - "video.expand": "Grandigi la videon", + "video.expand": "Pligrandigi la videon", "video.fullscreen": "Igi plenekrana", "video.hide": "Kaŝi la videon", "video.mute": "Silentigi", diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json index 0a6aac287..01fd9a567 100644 --- a/app/javascript/mastodon/locales/gl.json +++ b/app/javascript/mastodon/locales/gl.json @@ -8,7 +8,7 @@ "account.blocked": "Bloqueada", "account.browse_more_on_origin_server": "Busca máis no perfil orixinal", "account.cancel_follow_request": "Desbotar solicitude de seguimento", - "account.direct": "Mensaxe directa @{name}", + "account.direct": "Mensaxe directa a @{name}", "account.disable_notifications": "Deixar de notificarme cando @{name} publica", "account.domain_blocked": "Dominio agochado", "account.edit_profile": "Editar perfil", diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json index 5bc08533e..b4e85be06 100644 --- a/app/javascript/mastodon/locales/th.json +++ b/app/javascript/mastodon/locales/th.json @@ -106,7 +106,7 @@ "compose_form.poll.remove_option": "เอาตัวเลือกนี้ออก", "compose_form.poll.switch_to_multiple": "เปลี่ยนการสำรวจความคิดเห็นเป็นอนุญาตหลายตัวเลือก", "compose_form.poll.switch_to_single": "เปลี่ยนการสำรวจความคิดเห็นเป็นอนุญาตตัวเลือกเดี่ยว", - "compose_form.publish": "Publish", + "compose_form.publish": "เผยแพร่", "compose_form.publish_loud": "{publish}!", "compose_form.save_changes": "บันทึกการเปลี่ยนแปลง", "compose_form.sensitive.hide": "{count, plural, other {ทำเครื่องหมายสื่อว่าละเอียดอ่อน}}", diff --git a/config/locales/activerecord.id.yml b/config/locales/activerecord.id.yml index dc90c1322..88fdb3f75 100644 --- a/config/locales/activerecord.id.yml +++ b/config/locales/activerecord.id.yml @@ -21,6 +21,14 @@ id: username: invalid: hanya boleh berisi huruf, angka, dan garis bawah reserved: sudah dipesan + admin/webhook: + attributes: + url: + invalid: bukan URL valid + doorkeeper/application: + attributes: + website: + invalid: bukan URL valid status: attributes: reblog: diff --git a/config/locales/activerecord.is.yml b/config/locales/activerecord.is.yml index 8ecb02e74..75c18c6a3 100644 --- a/config/locales/activerecord.is.yml +++ b/config/locales/activerecord.is.yml @@ -21,6 +21,14 @@ is: username: invalid: má aðeins innihalda bókstafi, tölur og undirstrik reserved: er frátekið + admin/webhook: + attributes: + url: + invalid: er ekki gild vefslóð + doorkeeper/application: + attributes: + website: + invalid: er ekki gild vefslóð status: attributes: reblog: diff --git a/config/locales/activerecord.ko.yml b/config/locales/activerecord.ko.yml index 07f8a39f7..4db8d73af 100644 --- a/config/locales/activerecord.ko.yml +++ b/config/locales/activerecord.ko.yml @@ -21,6 +21,14 @@ ko: username: invalid: 영문자, 숫자, _만 사용 가능 reserved: 이미 예약되어 있습니다 + admin/webhook: + attributes: + url: + invalid: 올바른 URL이 아닙니다 + doorkeeper/application: + attributes: + website: + invalid: 올바른 URL이 아닙니다 status: attributes: reblog: diff --git a/config/locales/activerecord.nl.yml b/config/locales/activerecord.nl.yml index b5a122001..9d3adf290 100644 --- a/config/locales/activerecord.nl.yml +++ b/config/locales/activerecord.nl.yml @@ -21,6 +21,14 @@ nl: username: invalid: alleen letters, nummers en underscores reserved: gereserveerd + admin/webhook: + attributes: + url: + invalid: is een ongeldige URL + doorkeeper/application: + attributes: + website: + invalid: is een ongeldige URL status: attributes: reblog: diff --git a/config/locales/activerecord.th.yml b/config/locales/activerecord.th.yml index 26604ba5f..60908144f 100644 --- a/config/locales/activerecord.th.yml +++ b/config/locales/activerecord.th.yml @@ -21,6 +21,14 @@ th: username: invalid: ต้องมีเฉพาะตัวอักษร, ตัวเลข และขีดล่างเท่านั้น reserved: ถูกสงวนไว้ + admin/webhook: + attributes: + url: + invalid: ไม่ใช่ URL ที่ถูกต้อง + doorkeeper/application: + attributes: + website: + invalid: ไม่ใช่ URL ที่ถูกต้อง status: attributes: reblog: diff --git a/config/locales/eo.yml b/config/locales/eo.yml index d287fb590..9e1eaaffa 100644 --- a/config/locales/eo.yml +++ b/config/locales/eo.yml @@ -22,9 +22,9 @@ eo: federation_hint_html: Per konto ĉe %{instance}, vi povos sekvi homojn ĉe iu ajn Mastodon nodo kaj preter. get_apps: Provu telefonan aplikaĵon hosted_on: "%{domain} estas nodo de Mastodon" - instance_actor_flash: | - Ĉi tiu konto estas virtuala ulo uzata por reprezenti la servilon mem kaj ne iun apartan uzanton. - Ĝi estas uzata por frataraj celoj kaj ĝi ne devus esti blokita krom se vi volas bloki la tutan servilon, tiuokaze vi devus uzi domajnan blokadon. + instance_actor_flash: 'Ĉi tiu konto estas virtuala aganto uzata por reprezenti la servilon mem kaj neniun individuan uzanton. Ĝi estas uzata por celoj de la federaĵo kaj devas ne esti brokita se vi ne volas bloki la tutan servilon, tiuokaze vi devas uzi blokadon de domajno. + + ' learn_more: Lerni pli logout_before_registering: Vi jam salutis. privacy_policy: Privateca politiko @@ -179,8 +179,8 @@ eo: sensitized: markita tikla shared_inbox_url: URL de kunhavigita leterkesto show: - created_reports: Kreitaj signaloj - targeted_reports: Signalitaj de aliaj + created_reports: Kreitaj raportoj + targeted_reports: Raporitaj de alia silence: Kaŝi silenced: Silentigita statuses: Mesaĝoj @@ -1164,10 +1164,10 @@ eo: one: "%{count} voĉdono" other: "%{count} voĉdonoj" vote: Voĉdoni - show_more: Malfoldi + show_more: Montri pli show_newer: Montri pli novajn show_older: Montri pli malnovajn - show_thread: Montri la fadenon + show_thread: Montri la mesaĝaron sign_in_to_participate: Ensaluti por partopreni en la konversacio title: "%{name}: “%{quote}”" visibilities: @@ -1255,7 +1255,7 @@ eo: review_preferences_action: Ŝanĝi preferojn review_preferences_step: Estu certa ke vi agordis viajn preferojn, kiel kiujn retmesaĝojn vi ŝatus ricevi, aŭ kiun dekomencan privatecan nivelon vi ŝatus ke viaj mesaĝoj havu. Se tio ne ĝenas vin, vi povas ebligi aŭtomatan ekigon de GIF-oj. subject: Bonvenon en Mastodon - tip_federated_timeline: La fratara templinio estas antaŭvido de la reto de Mastodon. Sed ĝi enhavas nur homojn, kiuj estas sekvataj de aliaj homoj de via nodo, do ĝi ne estas kompleta. + tip_federated_timeline: La federata templinio estas rekta vido de la reto de Mastodon. Sed ĝi inkluzivas nur personojn kiujn via najbaroj abonas, do ĝi ne estas kompleta. tip_following: Vi dekomence sekvas la administrantojn de via servilo. Por trovi pli da interesaj homoj, rigardu la lokan kaj frataran templiniojn. tip_local_timeline: La loka templinio estas antaŭvido de la homoj en %{instance}. Ĉi tiuj estas viaj apudaj najbaroj! tip_mobile_webapp: Se via telefona retumilo proponas al vi aldoni Mastodon al via hejma ekrano, vi povas ricevi puŝsciigojn. Tio multmaniere funkcias kiel operaciuma aplikaĵo! diff --git a/config/locales/gl.yml b/config/locales/gl.yml index 5b303b08f..10454b5fd 100644 --- a/config/locales/gl.yml +++ b/config/locales/gl.yml @@ -941,7 +941,7 @@ gl: warning: Ten moito tino con estos datos. Non os compartas nunca con ninguén! your_token: O seu testemuño de acceso auth: - apply_for_account: Solicite un convite + apply_for_account: Solicita un convite change_password: Contrasinal checkbox_agreement_html: Acepto as regras do servidor e os termos do servizo checkbox_agreement_without_rules_html: Acepto os termos do servizo @@ -954,7 +954,7 @@ gl: didnt_get_confirmation: Non recibiches as instruccións de confirmación? dont_have_your_security_key: "¿Non tes a túa chave de seguridade?" forgot_password: Non lembras o contrasinal? - invalid_reset_password_token: O testemuño para restablecer o contrasinal non é válido ou caducou. Por favor solicite un novo. + invalid_reset_password_token: O token para restablecer o contrasinal non é válido ou caducou. Por favor solicita un novo. link_to_otp: Escribe o código do segundo factor do móbil ou un código de recuperación link_to_webauth: Usa o teu dispositivo de chave de seguridade log_in_with: Accede con diff --git a/config/locales/is.yml b/config/locales/is.yml index f9d408822..5978ac41c 100644 --- a/config/locales/is.yml +++ b/config/locales/is.yml @@ -853,13 +853,25 @@ is: empty: Þú hefur ekki enn skilgreint neinar aðvaranaforstillingar. title: Sýsla með forstilltar aðvaranir webhooks: + add_new: Bæta við endapunkti delete: Eyða + description_html: "webhook-vefkrækja gerir Mastodon kleift að ýta rauntíma-tilkynningum um valda atburði til þinna eigin forrita, þannig að þau forrit getir sett sjálfvirk viðbrögð í gang." disable: Gera óvirkt disabled: Óvirkt + edit: Breyta endapunkti + empty: Þú ert ekki enn búin/n að stilla neina endapunkta á webhook-vefkrækjum. enable: Virkja enabled: Virkt + enabled_events: + one: 1 virkjaður atburður + other: "%{count} virkjaðir atburðir" events: Atburðir + new: Ný webhook-vefkrækja + rotate_secret: Skipta um leyniteikn + secret: Leyniteikn undirritunar status: Staða + title: Webhook-vefkrækjur + webhook: Webhook-vefkrækja admin_mailer: new_appeal: actions: diff --git a/config/locales/ko.yml b/config/locales/ko.yml index 65bc57e3a..2397b63e5 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -840,11 +840,22 @@ ko: webhooks: add_new: 엔드포인트 추가 delete: 삭제 + description_html: "웹훅은 선택한 이벤트에 대해 마스토돈이 실시간 알림을 각자의 응용프로그램에게 보냄으로서, 당신의 응용프로그램이 자동으로 반응을 할 수 있도록 만듧니다." disable: 비활성화 disabled: 비활성화됨 edit: 엔드포인트 수정 + empty: 아직 설정한 웹훅 엔드포인트가 없습니다. enable: 활성화 enabled: 활성화됨 + enabled_events: + other: "%{count}개의 이벤트가 활성화되어 있습니다" + events: 이벤트 + new: 새 웹훅 + rotate_secret: 비밀키 회전 + secret: 비밀키 서명 + status: 상태 + title: 웹훅 + webhook: 웹훅 admin_mailer: new_appeal: actions: diff --git a/config/locales/nl.yml b/config/locales/nl.yml index db49efbb9..b5040153e 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -36,7 +36,7 @@ nl: one: toot other: berichten status_count_before: Zij schreven - tagline: Gedecentraliseerd sociaal netwerk + tagline: Decentraal sociaal netwerk terms: Gebruiksvoorwaarden unavailable_content: Gemodereerde servers unavailable_content_description: @@ -1306,6 +1306,9 @@ nl: subject: Jouw archief staat klaar om te worden gedownload title: Archief ophalen warning: + explanation: + mark_statuses_as_sensitive: Sommige van jouw berichten zijn als gevoelig gemarkeerd door de moderatoren van %{instance}. Dit betekent dat mensen op de media in de berichten moeten klikken/tikken om deze weer te geven. Je kunt media in de toekomst ook zelf als gevoelig markeren. + sensitive: Vanaf nu worden al jouw geüploade media als gevoelig gemarkeerd en verborgen achter een waarschuwing. subject: disable: Jouw account %{acct} is bevroren none: Waarschuwing voor %{acct} diff --git a/config/locales/simple_form.gl.yml b/config/locales/simple_form.gl.yml index 28d098630..e7fc36c64 100644 --- a/config/locales/simple_form.gl.yml +++ b/config/locales/simple_form.gl.yml @@ -57,7 +57,7 @@ gl: setting_hide_network: Non se mostrará no teu perfil quen te segue e a quen estás a seguir setting_noindex: Afecta ao teu perfil público e páxinas de publicación setting_show_application: A aplicación que estás a utilizar para enviar publicacións mostrarase na vista detallada da publicación - setting_use_blurhash: Os gradientes toman as cores da imaxe oculta pero esborranchando todos os detalles + setting_use_blurhash: Os gradientes toman as cores da imaxe oculta pero esvaecendo tódolos detalles setting_use_pending_items: Agochar actualizacións da cronoloxía tras un click no lugar de desprazar automáticamente os comentarios username: O teu nome de usuaria será único en %{domain} whole_word: Se a chave ou frase de paso é só alfanumérica, só se aplicará se concorda a palabra completa @@ -177,7 +177,7 @@ gl: setting_theme: Decorado da instancia setting_trends: Mostrar as tendencias de hoxe setting_unfollow_modal: Solicitar confirmación antes de deixar de seguir alguén - setting_use_blurhash: Mostrar gradientes coloridos para medios ocultos + setting_use_blurhash: Mostrar gradientes coloridos para multimedia oculto setting_use_pending_items: Modo lento severity: Severidade sign_in_token_attempt: Código de seguridade diff --git a/config/locales/simple_form.is.yml b/config/locales/simple_form.is.yml index 528e9a52a..638458dae 100644 --- a/config/locales/simple_form.is.yml +++ b/config/locales/simple_form.is.yml @@ -91,6 +91,9 @@ is: name: Þú getur aðeins breytt stafstöði mill há-/lágstafa, til gæmis til að gera þetta læsilegra user: chosen_languages: Þegar merkt er við þetta, birtast einungis færslur á völdum tungumálum á opinberum tímalínum + webhook: + events: Veldu atburði sem á að senda + url: Hvert atburðir verða sendir labels: account: fields: @@ -219,6 +222,9 @@ is: name: Myllumerki trendable: Leyfa þessu myllumerki að birtast undir tilhneigingum usable: Leyfa færslum að nota þetta myllumerki + webhook: + events: Virkjaðir atburðir + url: Slóð á endapunkt 'no': Nei recommended: Mælt með required: diff --git a/config/locales/simple_form.ko.yml b/config/locales/simple_form.ko.yml index 71d4058c8..d79b79513 100644 --- a/config/locales/simple_form.ko.yml +++ b/config/locales/simple_form.ko.yml @@ -91,6 +91,9 @@ ko: name: 읽기 쉽게하기 위한 글자의 대소문자만 변경할 수 있습니다. user: chosen_languages: 체크하면, 선택 된 언어로 작성된 게시물들만 공개 타임라인에 보여집니다 + webhook: + events: 전송할 이벤트를 선택하세요 + url: 이벤트가 어디로 전송될 지 labels: account: fields: @@ -219,6 +222,9 @@ ko: name: 해시태그 trendable: 이 해시태그가 유행에 보여지도록 허용 usable: 이 해시태그를 게시물에 사용 가능하도록 허용 + webhook: + events: 활성화된 이벤트 + url: 엔드포인트 URL 'no': 아니오 recommended: 추천함 required: diff --git a/config/locales/simple_form.nl.yml b/config/locales/simple_form.nl.yml index 33968b508..09dd2bd1b 100644 --- a/config/locales/simple_form.nl.yml +++ b/config/locales/simple_form.nl.yml @@ -201,6 +201,8 @@ nl: mention: Wanneer iemand jou heeft vermeld pending_account: Wanneer een nieuw account moet worden beoordeeld reblog: Wanneer iemand jouw bericht heeft geboost + report: Nieuwe rapportage is ingediend + trending_tag: Nieuwe trend vereist beoordeling rule: text: Regel tag: @@ -208,6 +210,8 @@ nl: name: Hashtag trendable: Toestaan dat deze hashtag onder trends te zien valt usable: Toestaan dat deze hashtag in berichten gebruikt mag worden + webhook: + url: Eindpunt URL 'no': Nee recommended: Aanbevolen required: diff --git a/config/locales/simple_form.th.yml b/config/locales/simple_form.th.yml index 8df50ab3a..a33e87593 100644 --- a/config/locales/simple_form.th.yml +++ b/config/locales/simple_form.th.yml @@ -88,6 +88,8 @@ th: name: คุณสามารถเปลี่ยนได้เฉพาะตัวพิมพ์ใหญ่เล็กของตัวอักษรเท่านั้น ตัวอย่างเช่น เพื่อทำให้ตัวอักษรอ่านได้ง่ายขึ้น user: chosen_languages: เมื่อกาเครื่องหมาย จะแสดงเฉพาะโพสต์ในภาษาที่เลือกในเส้นเวลาสาธารณะเท่านั้น + webhook: + events: เลือกเหตุการณ์ที่จะส่ง labels: account: fields: @@ -214,6 +216,8 @@ th: name: แฮชแท็ก trendable: อนุญาตให้แฮชแท็กนี้ปรากฏภายใต้แนวโน้ม usable: อนุญาตให้โพสต์ใช้แฮชแท็กนี้ + webhook: + url: URL ปลายทาง 'no': ไม่ recommended: แนะนำ required: diff --git a/config/locales/th.yml b/config/locales/th.yml index c5f70753d..57fbc1fdd 100644 --- a/config/locales/th.yml +++ b/config/locales/th.yml @@ -34,6 +34,7 @@ th: status_count_after: other: โพสต์ status_count_before: ผู้เผยแพร่ + tagline: เครือข่ายสังคมแบบกระจายศูนย์ terms: เงื่อนไขการให้บริการ unavailable_content: เซิร์ฟเวอร์ที่มีการควบคุม unavailable_content_description: @@ -782,6 +783,16 @@ th: edit_preset: แก้ไขคำเตือนที่ตั้งไว้ล่วงหน้า empty: คุณยังไม่ได้กำหนดคำเตือนที่ตั้งไว้ล่วงหน้าใด ๆ title: จัดการคำเตือนที่ตั้งไว้ล่วงหน้า + webhooks: + add_new: เพิ่มปลายทาง + delete: ลบ + disable: ปิดใช้งาน + disabled: ปิดใช้งานอยู่ + edit: แก้ไขปลายทาง + enable: เปิดใช้งาน + enabled: ใช้งานอยู่ + events: เหตุการณ์ + status: สถานะ admin_mailer: new_appeal: actions: @@ -1248,6 +1259,10 @@ th: reports: errors: invalid_rules: ไม่ได้อ้างอิงกฎที่ถูกต้อง + rss: + content_warning: 'คำเตือนเนื้อหา:' + descriptions: + account: โพสต์สาธารณะจาก @%{acct} scheduled_statuses: too_soon: วันที่ตามกำหนดการต้องอยู่ในอนาคต sessions: diff --git a/config/locales/tr.yml b/config/locales/tr.yml index 9159acba3..7bd5723c4 100644 --- a/config/locales/tr.yml +++ b/config/locales/tr.yml @@ -862,6 +862,9 @@ tr: empty: Henüz yapılandırılmış bir web kancanız yok. enable: Etkinleştir enabled: Etkin + enabled_events: + one: 1 aktif etkinlik + other: "%{count} aktif etkinlik" events: Olaylar new: Yeni web kancası rotate_secret: Gizi döndür diff --git a/config/locales/uk.yml b/config/locales/uk.yml index 53a4b3567..2612237b8 100644 --- a/config/locales/uk.yml +++ b/config/locales/uk.yml @@ -646,6 +646,7 @@ uk: placeholder: Опишіть, які дії були виконані, або інші зміни, що стосуються справи... title: Примітки notes_description_html: Переглядайте та залишайте примітки для інших модераторів та для себе на майбутнє + quick_actions_description_html: 'Виберіть швидку дію або гортайте вниз, щоб побачити матеріал, на який надійшла скарга:' remote_user_placeholder: віддалений користувач із %{instance} reopen: Перевідкрити скаргу report: 'Скарга #%{id}' @@ -896,6 +897,7 @@ uk: sensitive: щоб позначати їхній обліковий запис делікатним silence: щоб обмежити їхній обліковий запис suspend: щоб призупинити їхній обліковий запис + body: "%{target} оскаржує модерацію %{action_taken_by} від %{date}, яка була %{type}. Вони написали:" next_steps: Ви можете схвалити апеляцію, щоб скасувати рішення про модерацію або проігнорувати її. subject: "%{username} апелює до рішення про модерацію на %{instance}" new_pending_account: @@ -991,6 +993,7 @@ uk: functional: Ваш обліковий запис повністю робочий. pending: Ваша заява очікує на розгляд нашим персоналом. Це може зайняти деякий час. Ви отримаєте електронний лист, якщо ваша заява буде схвалена. redirecting_to: Ваш обліковий запис наразі неактивний, тому що він перенаправлений до %{acct}. + view_strikes: Переглянути попередні попередження вашому обліковому запису too_fast: Форму подано занадто швидко, спробуйте ще раз. trouble_logging_in: Проблема під час входу? use_security_key: Використовувати ключ безпеки @@ -1058,6 +1061,7 @@ uk: strikes: action_taken: Дію виконано appeal: Апеляція + appeal_approved: Це попередження було успішно оскаржене і більше не дійсне appeal_rejected: Апеляцію було відхилено appeal_submitted_at: Апеляцію надіслано appealed_msg: Вашу апеляцію було надіслано. Якщо її погодять, вам буде повідомлено про це. @@ -1392,6 +1396,9 @@ uk: invalid_rules: не посилається на чинні правила rss: content_warning: 'Попередження про матеріали:' + descriptions: + account: Загальнодоступні дописи від @%{acct} + tag: 'Загальнодоступні дописи позначені #%{hashtag}' scheduled_statuses: over_daily_limit: Ви перевищили ліміт в %{limit} запланованих дмухів на сьогодні over_total_limit: Ви перевищили ліміт в %{limit} запланованих дмухів @@ -1562,6 +1569,9 @@ uk: pinned: Закріплений пост reblogged: передмухнув(-ла) sensitive_content: Дражливий зміст + strikes: + errors: + too_late: Запізно оскаржувати це попередження tags: does_not_match_previous_name: не збігається з попереднім ім'ям terms: @@ -1593,9 +1603,11 @@ uk: user_mailer: appeal_approved: action: Перейти у ваш обліковий запис + explanation: Оскарження попередження вашому обліковому запису %{strike_date}, яке ви надіслали %{appeal_date} було схвалено. Ваш обліковий запис знову вважається добропорядним. subject: Вашу апеляцію від %{date} було схвалено title: Апеляцію схвалено appeal_rejected: + explanation: Оскарження попередження вашому обліковому запису %{strike_date}, яке ви надіслали %{appeal_date} було відхилено. subject: Вашу апеляцію від %{date} було відхилено title: Апеляцію відхилено backup_ready: @@ -1611,12 +1623,14 @@ uk: title: Новий вхід warning: appeal: Подати апеляцію + appeal_description: Якщо ви вважаєте, що це помилка, ви можете надіслати оскаржити дії персоналу %{instance}. categories: spam: Спам violation: Вміст порушує такі правила спільноти explanation: delete_statuses: Деякі з ваших дописів порушили одне або кілька правил спільноти, і модератори %{instance} видалили їх. disable: Ви можете більше не використовувати свій обліковий запис, але ваш профіль та інші дані залишаються недоторканими. Ви можете надіслати запит на створення резервної копії ваших даних, змінити налаштування облікового запису або видалити свій обліковий запис. + mark_statuses_as_sensitive: Деякі з ваших дописів модератори %{instance} позначили делікатними. Це означає, що людям потрібно буде торкнутися медіа у дописах перед тим, як буде показано попередній перегляд. Ви можете самостійно позначити медіа делікатним, коли розміщуватимете його в майбутньому. sensitive: Відтепер усі ваші завантажені медіафайли будуть позначені делікатними й приховані за попередженням. silence: Ви й надалі можете користуватися своїм обліковим записом, але ваші дописи на цьому сервері бачитимуть лише ті люди, які вже стежать за вами, а вас може бути виключено з різних можливостей виявлення. Проте, інші можуть почати стежити за вами вручну. suspend: Ви більше не можете користуватися своїм обліковим записом, а ваші інші дані більше недоступні. Ви досі можете увійти, щоб надіслати запит на отримання резервної копії своїх даних до повного видалення впродовж приблизно 30 днів, але ми збережемо деякі основні дані, щоб унеможливити ухилення вами від призупинення. @@ -1625,7 +1639,9 @@ uk: subject: delete_statuses: Ваші дописи на %{acct} були вилучені disable: Ваш обліковий запис %{acct} було заморожено + mark_statuses_as_sensitive: Ваші дописи на %{acct} позначені делікатними none: Попередження для %{acct} + sensitive: Ваші дописи на %{acct} відтепер будуть позначені делікатними silence: Ваш обліковий запис %{acct} було обмежено suspend: Ваш обліковий запис %{acct} було призупинено title: -- cgit From 2936f42a14cfdca70d4a9653dab29382315945e7 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 27 Jun 2022 09:30:15 +0200 Subject: Add notifications for new reports (#18697) --- app/javascript/mastodon/actions/notifications.js | 6 +++ app/javascript/mastodon/components/icon_button.js | 12 ++++- .../notifications/components/column_settings.js | 13 +++++ .../notifications/components/notification.js | 30 +++++++++++ .../features/notifications/components/report.js | 62 ++++++++++++++++++++++ .../containers/notification_container.js | 4 +- .../mastodon/locales/defaultMessages.json | 33 ++++++++++++ app/javascript/mastodon/locales/en.json | 7 +++ app/javascript/mastodon/reducers/notifications.js | 3 +- app/javascript/mastodon/reducers/settings.js | 3 ++ app/javascript/mastodon/selectors/index.js | 17 +++--- app/javascript/styles/mastodon/components.scss | 39 ++++++++++++++ app/models/notification.rb | 5 +- app/serializers/rest/admin/report_serializer.rb | 2 +- app/serializers/rest/notification_serializer.rb | 5 ++ app/serializers/rest/report_serializer.rb | 5 +- app/services/report_service.rb | 4 +- config/locales/en.yml | 2 + 18 files changed, 235 insertions(+), 17 deletions(-) create mode 100644 app/javascript/mastodon/features/notifications/components/report.js (limited to 'app') diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 96cf628d6..84dfbeef3 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -91,6 +91,10 @@ export function updateNotifications(notification, intlMessages, intlLocale) { dispatch(importFetchedStatus(notification.status)); } + if (notification.report) { + dispatch(importFetchedAccount(notification.report.target_account)); + } + dispatch({ type: NOTIFICATIONS_UPDATE, notification, @@ -134,6 +138,7 @@ const excludeTypesFromFilter = filter => { 'status', 'update', 'admin.sign_up', + 'admin.report', ]); return allTypes.filterNot(item => item === filter).toJS(); @@ -179,6 +184,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) { dispatch(importFetchedAccounts(response.data.map(item => item.account))); dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); + dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account))); dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems)); fetchRelatedRelationships(dispatch, response.data); diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index 6a653675b..81743a1db 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -132,8 +132,16 @@ export default class IconButton extends React.PureComponent { ); if (href) { - contents = ( - + return ( + {contents} ); diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index 1cdb24086..61df79b46 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.js +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -178,6 +178,19 @@ export default class ColumnSettings extends React.PureComponent { )} + + {isStaff && ( +
+ + +
+ + {showPushSettings && } + + +
+
+ )} ); } diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index 9198e9c9d..0af71418c 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -7,6 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { me } from 'mastodon/initial_state'; import StatusContainer from 'mastodon/containers/status_container'; import AccountContainer from 'mastodon/containers/account_container'; +import Report from './report'; import FollowRequestContainer from '../containers/follow_request_container'; import Icon from 'mastodon/components/icon'; import Permalink from 'mastodon/components/permalink'; @@ -21,6 +22,7 @@ const messages = defineMessages({ status: { id: 'notification.status', defaultMessage: '{name} just posted' }, update: { id: 'notification.update', defaultMessage: '{name} edited a post' }, adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' }, + adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' }, }); const notificationForScreenReader = (intl, message, timestamp) => { @@ -367,6 +369,32 @@ class Notification extends ImmutablePureComponent { ); } + renderAdminReport (notification, account, link) { + const { intl, unread, report } = this.props; + + const targetAccount = report.get('target_account'); + const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') }; + const targetLink = ; + + return ( + +
+
+
+ +
+ + + + +
+ +
+
+ ); + } + render () { const { notification } = this.props; const account = notification.get('account'); @@ -392,6 +420,8 @@ class Notification extends ImmutablePureComponent { return this.renderPoll(notification, account); case 'admin.sign_up': return this.renderAdminSignUp(notification, account, link); + case 'admin.report': + return this.renderAdminReport(notification, account, link); } return null; diff --git a/app/javascript/mastodon/features/notifications/components/report.js b/app/javascript/mastodon/features/notifications/components/report.js new file mode 100644 index 000000000..3ce3eb9d3 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/report.js @@ -0,0 +1,62 @@ +import React, { Fragment } from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import AvatarOverlay from 'mastodon/components/avatar_overlay'; +import RelativeTimestamp from 'mastodon/components/relative_timestamp'; + +const messages = defineMessages({ + openReport: { id: 'report_notification.open', defaultMessage: 'Open report' }, + other: { id: 'report_notification.categories.other', defaultMessage: 'Other' }, + spam: { id: 'report_notification.categories.spam', defaultMessage: 'Spam' }, + violation: { id: 'report_notification.categories.violation', defaultMessage: 'Rule violation' }, +}); + +export default @injectIntl +class Report extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + report: ImmutablePropTypes.map.isRequired, + hidden: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + render () { + const { intl, hidden, report, account } = this.props; + + if (!report) { + return null; + } + + if (hidden) { + return ( + + {report.get('id')} + + ); + } + + return ( +
+
+ +
+ +
+
+ · +
+ {intl.formatMessage(messages[report.get('category')])} +
+ + +
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js index 5c984197f..8bd5b3d78 100644 --- a/app/javascript/mastodon/features/notifications/containers/notification_container.js +++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import { makeGetNotification, makeGetStatus } from '../../../selectors'; +import { makeGetNotification, makeGetStatus, makeGetReport } from '../../../selectors'; import Notification from '../components/notification'; import { initBoostModal } from '../../../actions/boosts'; import { mentionCompose } from '../../../actions/compose'; @@ -18,12 +18,14 @@ import { boostModal } from '../../../initial_state'; const makeMapStateToProps = () => { const getNotification = makeGetNotification(); const getStatus = makeGetStatus(); + const getReport = makeGetReport(); const mapStateToProps = (state, props) => { const notification = getNotification(state, props.notification, props.accountId); return { notification: notification, status: notification.get('status') ? getStatus(state, { id: notification.get('status') }) : null, + report: notification.get('report') ? getReport(state, notification.get('report'), notification.getIn(['report', 'target_account', 'id'])) : null, }; }; diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 250987be3..7cd913493 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -2532,6 +2532,10 @@ { "defaultMessage": "New sign-ups:", "id": "notifications.column_settings.admin.sign_up" + }, + { + "defaultMessage": "New reports:", + "id": "notifications.column_settings.admin.report" } ], "path": "app/javascript/mastodon/features/notifications/components/column_settings.json" @@ -2625,6 +2629,10 @@ "defaultMessage": "{name} signed up", "id": "notification.admin.sign_up" }, + { + "defaultMessage": "{name} reported {target}", + "id": "notification.admin.report" + }, { "defaultMessage": "{name} has requested to follow you", "id": "notification.follow_request" @@ -2653,6 +2661,31 @@ ], "path": "app/javascript/mastodon/features/notifications/components/notifications_permission_banner.json" }, + { + "descriptors": [ + { + "defaultMessage": "Open report", + "id": "report_notification.open" + }, + { + "defaultMessage": "Other", + "id": "report_notification.categories.other" + }, + { + "defaultMessage": "Spam", + "id": "report_notification.categories.spam" + }, + { + "defaultMessage": "Rule violation", + "id": "report_notification.categories.violation" + }, + { + "defaultMessage": "{count, plural, one {{count} post} other {{count} posts}} attached", + "id": "report_notification.attached_statuses" + } + ], + "path": "app/javascript/mastodon/features/notifications/components/report.json" + }, { "descriptors": [ { diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index a0a1e1cdf..9082c4202 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -314,6 +314,7 @@ "navigation_bar.preferences": "Preferences", "navigation_bar.public_timeline": "Federated timeline", "navigation_bar.security": "Security", + "notification.admin.report": "{name} reported {target}", "notification.admin.sign_up": "{name} signed up", "notification.favourite": "{name} favourited your post", "notification.follow": "{name} followed you", @@ -326,6 +327,7 @@ "notification.update": "{name} edited a post", "notifications.clear": "Clear notifications", "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", + "notifications.column_settings.admin.report": "New reports:", "notifications.column_settings.admin.sign_up": "New sign-ups:", "notifications.column_settings.alert": "Desktop notifications", "notifications.column_settings.favourite": "Favourites:", @@ -431,6 +433,11 @@ "report.thanks.title_actionable": "Thanks for reporting, we'll look into this.", "report.unfollow": "Unfollow @{name}", "report.unfollow_explanation": "You are following this account. To not see their posts in your home feed anymore, unfollow them.", + "report_notification.attached_statuses": "{count, plural, one {{count} post} other {{count} posts}} attached", + "report_notification.categories.other": "Other", + "report_notification.categories.spam": "Spam", + "report_notification.categories.violation": "Rule violation", + "report_notification.open": "Open report", "search.placeholder": "Search", "search_popout.search_format": "Advanced search format", "search_popout.tips.full_text": "Simple text returns posts you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index b587b6d0f..4b460bc10 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -28,7 +28,7 @@ import { } from '../actions/app'; import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks'; import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines'; -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { fromJS, Map as ImmutableMap, List as ImmutableList } from 'immutable'; import compareId from '../compare_id'; const initialState = ImmutableMap({ @@ -52,6 +52,7 @@ const notificationToMap = notification => ImmutableMap({ account: notification.account.id, created_at: notification.created_at, status: notification.status ? notification.status.id : null, + report: notification.report ? fromJS(notification.report) : null, }); const normalizeNotification = (state, notification, usePendingItems) => { diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index afffce917..f9d3236e4 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -39,6 +39,7 @@ const initialState = ImmutableMap({ status: false, update: false, 'admin.sign_up': false, + 'admin.report': false, }), quickFilter: ImmutableMap({ @@ -60,6 +61,7 @@ const initialState = ImmutableMap({ status: true, update: true, 'admin.sign_up': true, + 'admin.report': true, }), sounds: ImmutableMap({ @@ -72,6 +74,7 @@ const initialState = ImmutableMap({ status: true, update: true, 'admin.sign_up': true, + 'admin.report': true, }), }), diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index 3121774b3..fbd25b605 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -152,14 +152,15 @@ export const getAlerts = createSelector([getAlertsBase], (base) => { return arr; }); -export const makeGetNotification = () => { - return createSelector([ - (_, base) => base, - (state, _, accountId) => state.getIn(['accounts', accountId]), - ], (base, account) => { - return base.set('account', account); - }); -}; +export const makeGetNotification = () => createSelector([ + (_, base) => base, + (state, _, accountId) => state.getIn(['accounts', accountId]), +], (base, account) => base.set('account', account)); + +export const makeGetReport = () => createSelector([ + (_, base) => base, + (state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]), +], (base, targetAccount) => base.set('target_account', targetAccount)); export const getAccountGallery = createSelector([ (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()), diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 1ada1fcf7..7e3ce3de2 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1355,6 +1355,8 @@ a .account__avatar { .account__avatar-overlay { @include avatar-size(48px); + position: relative; + &-base { @include avatar-radius; @include avatar-size(36px); @@ -1620,6 +1622,33 @@ a.account__display-name { } } +.notification__report { + padding: 8px 10px; + padding-left: 68px; + position: relative; + border-bottom: 1px solid lighten($ui-base-color, 8%); + min-height: 54px; + + &__details { + display: flex; + justify-content: space-between; + align-items: center; + color: $darker-text-color; + font-size: 15px; + line-height: 22px; + + strong { + font-weight: 500; + } + } + + &__avatar { + position: absolute; + left: 10px; + top: 10px; + } +} + .notification__message { margin: 0 10px 0 68px; padding: 8px 0 0; @@ -2360,6 +2389,16 @@ a.account__display-name { padding-top: 15px; } + .notification__report { + padding: 15px 15px 15px (48px + 15px * 2); + min-height: 48px + 2px; + + &__avatar { + left: 15px; + top: 17px; + } + } + .status { padding: 15px 15px 15px (48px + 15px * 2); min-height: 48px + 2px; diff --git a/app/models/notification.rb b/app/models/notification.rb index ba94b54d1..bbc63c1c0 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -37,6 +37,7 @@ class Notification < ApplicationRecord poll update admin.sign_up + admin.report ).freeze TARGET_STATUS_INCLUDES_BY_TYPE = { @@ -46,6 +47,7 @@ class Notification < ApplicationRecord favourite: [favourite: :status], poll: [poll: :status], update: :status, + 'admin.report': [report: :target_account], }.freeze belongs_to :account, optional: true @@ -58,6 +60,7 @@ class Notification < ApplicationRecord belongs_to :follow_request, foreign_key: 'activity_id', optional: true belongs_to :favourite, foreign_key: 'activity_id', optional: true belongs_to :poll, foreign_key: 'activity_id', optional: true + belongs_to :report, foreign_key: 'activity_id', optional: true validates :type, inclusion: { in: TYPES } @@ -146,7 +149,7 @@ class Notification < ApplicationRecord return unless new_record? case activity_type - when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll' + when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'Report' self.from_account_id = activity&.account_id when 'Mention' self.from_account_id = activity&.status&.account_id diff --git a/app/serializers/rest/admin/report_serializer.rb b/app/serializers/rest/admin/report_serializer.rb index 237f41d8e..44b4726e4 100644 --- a/app/serializers/rest/admin/report_serializer.rb +++ b/app/serializers/rest/admin/report_serializer.rb @@ -2,7 +2,7 @@ class REST::Admin::ReportSerializer < ActiveModel::Serializer attributes :id, :action_taken, :action_taken_at, :category, :comment, - :created_at, :updated_at + :forwarded, :created_at, :updated_at has_one :account, serializer: REST::Admin::AccountSerializer has_one :target_account, serializer: REST::Admin::AccountSerializer diff --git a/app/serializers/rest/notification_serializer.rb b/app/serializers/rest/notification_serializer.rb index 69b81f6de..137fc53dd 100644 --- a/app/serializers/rest/notification_serializer.rb +++ b/app/serializers/rest/notification_serializer.rb @@ -5,6 +5,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer belongs_to :from_account, key: :account, serializer: REST::AccountSerializer belongs_to :target_status, key: :status, if: :status_type?, serializer: REST::StatusSerializer + belongs_to :report, if: :report_type?, serializer: REST::ReportSerializer def id object.id.to_s @@ -13,4 +14,8 @@ class REST::NotificationSerializer < ActiveModel::Serializer def status_type? [:favourite, :reblog, :status, :mention, :poll, :update].include?(object.type) end + + def report_type? + object.type == :'admin.report' + end end diff --git a/app/serializers/rest/report_serializer.rb b/app/serializers/rest/report_serializer.rb index ecb88d653..de68dfc6d 100644 --- a/app/serializers/rest/report_serializer.rb +++ b/app/serializers/rest/report_serializer.rb @@ -1,7 +1,10 @@ # frozen_string_literal: true class REST::ReportSerializer < ActiveModel::Serializer - attributes :id, :action_taken + attributes :id, :action_taken, :action_taken_at, :category, :comment, + :forwarded, :created_at, :status_ids, :rule_ids + + has_one :target_account, serializer: REST::AccountSerializer def id object.id.to_s diff --git a/app/services/report_service.rb b/app/services/report_service.rb index d251bb33f..70212a6a7 100644 --- a/app/services/report_service.rb +++ b/app/services/report_service.rb @@ -39,8 +39,8 @@ class ReportService < BaseService return if @report.unresolved_siblings? User.staff.includes(:account).each do |u| - next unless u.allows_report_emails? - AdminMailer.new_report(u.account, @report).deliver_later + LocalNotificationWorker.perform_async(u.account_id, @report.id, 'Report', 'admin.report') + AdminMailer.new_report(u.account, @report).deliver_later if u.allows_report_emails? end end diff --git a/config/locales/en.yml b/config/locales/en.yml index cedcc9361..5a0fc3da8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1251,6 +1251,8 @@ en: copy_account_note_text: 'This user moved from %{acct}, here were your previous notes about them:' notification_mailer: admin: + report: + subject: "%{name} submitted a report" sign_up: subject: "%{name} signed up" digest: -- cgit From 02851848e964675bb59919fa5fd1bdee2c1c29db Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 28 Jun 2022 09:42:13 +0200 Subject: Revamp post filtering system (#18058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add model for custom filter keywords * Use CustomFilterKeyword internally Does not change the API * Fix /filters/edit and /filters/new * Add migration tests * Remove whole_word column from custom_filters (covered by custom_filter_keywords) * Redesign /filters Instead of a list, present a card that displays more information and handles multiple keywords per filter. * Redesign /filters/new and /filters/edit to add and remove keywords This adds a new gem dependency: cocoon, as well as a npm dependency: cocoon-js-vanilla. Those are used to easily populate and remove form fields from the user interface when manipulating multiple keyword filters at once. * Add /api/v2/filters to edit filter with multiple keywords Entities: - `Filter`: `id`, `title`, `filter_action` (either `hide` or `warn`), `context` `keywords` - `FilterKeyword`: `id`, `keyword`, `whole_word` API endpoits: - `GET /api/v2/filters` to list filters (including keywords) - `POST /api/v2/filters` to create a new filter `keywords_attributes` can also be passed to create keywords in one request - `GET /api/v2/filters/:id` to read a particular filter - `PUT /api/v2/filters/:id` to update a new filter `keywords_attributes` can also be passed to edit, delete or add keywords in one request - `DELETE /api/v2/filters/:id` to delete a particular filter - `GET /api/v2/filters/:id/keywords` to list keywords for a filter - `POST /api/v2/filters/:filter_id/keywords/:id` to add a new keyword to a filter - `GET /api/v2/filter_keywords/:id` to read a particular keyword - `PUT /api/v2/filter_keywords/:id` to edit a particular keyword - `DELETE /api/v2/filter_keywords/:id` to delete a particular keyword * Change from `irreversible` boolean to `action` enum * Remove irrelevent `irreversible_must_be_within_context` check * Fix /filters/new and /filters/edit with update for filter_action * Fix Rubocop/Codeclimate complaining about task names * Refactor FeedManager#phrase_filtered? This moves regexp building and filter caching to the `CustomFilter` class. This does not change the functional behavior yet, but this changes how the cache is built, doing per-custom_filter regexps so that filters can be matched independently, while still offering caching. * Perform server-side filtering and output result in REST API * Fix numerous filters_changed events being sent when editing multiple keywords at once * Add some tests * Use the new API in the WebUI - use client-side logic for filters we have fetched rules for. This is so that filter changes can be retroactively applied without reloading the UI. - use server-side logic for filters we haven't fetched rules for yet (e.g. network error, or initial timeline loading) * Minor optimizations and refactoring * Perform server-side filtering on the streaming server * Change the wording of filter action labels * Fix issues pointed out by linter * Change design of “Show anyway” link in accordence to review comments * Drop “irreversible” filtering behavior * Move /api/v2/filter_keywords to /api/v1/filters/keywords * Rename `filter_results` attribute to `filtered` * Rename REST::LegacyFilterSerializer to REST::V1::FilterSerializer * Fix systemChannelId value in streaming server * Simplify code by removing client-side filtering code The simplifcation comes at a cost though: filters aren't retroactively applied anymore. --- .circleci/config.yml | 18 ++- Gemfile | 2 + Gemfile.lock | 2 + .../api/v1/filters/keywords_controller.rb | 50 ++++++++ app/controllers/api/v1/filters_controller.rb | 35 +++-- app/controllers/api/v2/filters_controller.rb | 48 +++++++ app/controllers/filters_controller.rb | 12 +- app/javascript/mastodon/actions/filters.js | 26 ---- app/javascript/mastodon/actions/importer/index.js | 11 ++ .../mastodon/actions/importer/normalizer.js | 12 ++ app/javascript/mastodon/actions/notifications.js | 13 +- app/javascript/mastodon/actions/streaming.js | 4 - app/javascript/mastodon/components/status.js | 21 ++- .../mastodon/components/status_action_bar.js | 17 +++ app/javascript/mastodon/features/ui/index.js | 3 +- app/javascript/mastodon/reducers/filters.js | 34 ++++- app/javascript/mastodon/selectors/index.js | 51 +++----- app/javascript/packs/public.js | 1 + app/javascript/styles/mastodon/admin.scss | 33 ++++- app/javascript/styles/mastodon/components.scss | 15 +++ app/javascript/styles/mastodon/forms.scss | 31 +++++ app/lib/feed_manager.rb | 30 ----- app/models/concerns/account_interactions.rb | 13 ++ app/models/custom_filter.rb | 87 +++++++++---- app/models/custom_filter_keyword.rb | 34 +++++ app/presenters/filter_result_presenter.rb | 5 + app/presenters/status_relationships_presenter.rb | 24 +++- app/serializers/rest/filter_keyword_serializer.rb | 9 ++ app/serializers/rest/filter_result_serializer.rb | 6 + app/serializers/rest/filter_serializer.rb | 8 +- app/serializers/rest/status_serializer.rb | 9 ++ app/serializers/rest/v1/filter_serializer.rb | 26 ++++ app/views/filters/_fields.html.haml | 16 --- app/views/filters/_filter.html.haml | 32 +++++ app/views/filters/_filter_fields.html.haml | 33 +++++ app/views/filters/_keyword_fields.html.haml | 8 ++ app/views/filters/edit.html.haml | 2 +- app/views/filters/index.html.haml | 17 +-- app/views/filters/new.html.haml | 4 +- config/locales/en.yml | 11 +- config/locales/simple_form.en.yml | 10 ++ config/routes.rb | 9 +- ...20220613110628_create_custom_filter_keywords.rb | 13 ++ .../20220613110711_migrate_custom_filters.rb | 34 +++++ .../20220613110834_add_action_to_custom_filters.rb | 20 +++ ...110802_remove_whole_word_from_custom_filters.rb | 20 +++ ...0903_remove_irreversible_from_custom_filters.rb | 20 +++ db/schema.rb | 15 ++- lib/tasks/tests.rake | 18 ++- package.json | 2 + .../api/v1/filters/keywords_controller_spec.rb | 142 +++++++++++++++++++++ spec/controllers/api/v1/filters_controller_spec.rb | 27 ++-- .../controllers/api/v1/statuses_controller_spec.rb | 52 ++++++++ spec/controllers/api/v2/filters_controller_spec.rb | 121 ++++++++++++++++++ .../custom_filter_keyword_fabricator.rb | 4 + spec/lib/feed_manager_spec.rb | 32 ----- spec/models/custom_filter_keyword_spec.rb | 4 + .../status_relationships_presenter_spec.rb | 29 ++++- streaming/index.js | 90 ++++++++++++- yarn.lock | 67 +++++++++- 60 files changed, 1292 insertions(+), 250 deletions(-) create mode 100644 app/controllers/api/v1/filters/keywords_controller.rb create mode 100644 app/controllers/api/v2/filters_controller.rb delete mode 100644 app/javascript/mastodon/actions/filters.js create mode 100644 app/models/custom_filter_keyword.rb create mode 100644 app/presenters/filter_result_presenter.rb create mode 100644 app/serializers/rest/filter_keyword_serializer.rb create mode 100644 app/serializers/rest/filter_result_serializer.rb create mode 100644 app/serializers/rest/v1/filter_serializer.rb delete mode 100644 app/views/filters/_fields.html.haml create mode 100644 app/views/filters/_filter.html.haml create mode 100644 app/views/filters/_filter_fields.html.haml create mode 100644 app/views/filters/_keyword_fields.html.haml create mode 100644 db/migrate/20220613110628_create_custom_filter_keywords.rb create mode 100644 db/migrate/20220613110711_migrate_custom_filters.rb create mode 100644 db/migrate/20220613110834_add_action_to_custom_filters.rb create mode 100644 db/post_migrate/20220613110802_remove_whole_word_from_custom_filters.rb create mode 100644 db/post_migrate/20220613110903_remove_irreversible_from_custom_filters.rb create mode 100644 spec/controllers/api/v1/filters/keywords_controller_spec.rb create mode 100644 spec/controllers/api/v2/filters_controller_spec.rb create mode 100644 spec/fabricators/custom_filter_keyword_fabricator.rb create mode 100644 spec/models/custom_filter_keyword_spec.rb (limited to 'app') diff --git a/.circleci/config.yml b/.circleci/config.yml index b9228f996..2a60ae684 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -133,6 +133,12 @@ jobs: - run: command: ./bin/rails tests:migrations:populate_v2_4 name: Populate database with test data + - run: + command: ./bin/rails db:migrate VERSION=20180707154237 + name: Run migrations up to v2.4.3 + - run: + command: ./bin/rails tests:migrations:populate_v2_4_3 + name: Populate database with test data - run: command: ./bin/rails db:migrate name: Run all remaining migrations @@ -167,14 +173,22 @@ jobs: - run: command: ./bin/rails tests:migrations:populate_v2_4 name: Populate database with test data + - run: + command: ./bin/rails db:migrate VERSION=20180707154237 + name: Run migrations up to v2.4.3 + environment: + SKIP_POST_DEPLOYMENT_MIGRATIONS: true + - run: + command: ./bin/rails tests:migrations:populate_v2_4_3 + name: Populate database with test data - run: command: ./bin/rails db:migrate - name: Run all pre-deployment migrations + name: Run all remaining pre-deployment migrations environment: SKIP_POST_DEPLOYMENT_MIGRATIONS: true - run: command: ./bin/rails db:migrate - name: Run all post-deployment remaining migrations + name: Run all post-deployment migrations - run: command: ./bin/rails tests:migrations:check_database name: Check migration result diff --git a/Gemfile b/Gemfile index d732f6eed..9a7635b06 100644 --- a/Gemfile +++ b/Gemfile @@ -153,3 +153,5 @@ gem 'concurrent-ruby', require: false gem 'connection_pool', require: false gem 'xorcist', '~> 1.1' + +gem 'cocoon', '~> 1.2' diff --git a/Gemfile.lock b/Gemfile.lock index 36a0984e1..7e022b198 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -163,6 +163,7 @@ GEM elasticsearch-dsl chunky_png (1.4.0) climate_control (0.2.0) + cocoon (1.2.15) coderay (1.1.3) color_diff (0.1) concurrent-ruby (1.1.10) @@ -746,6 +747,7 @@ DEPENDENCIES charlock_holmes (~> 0.7.7) chewy (~> 7.2) climate_control (~> 0.2) + cocoon (~> 1.2) color_diff (~> 0.1) concurrent-ruby connection_pool diff --git a/app/controllers/api/v1/filters/keywords_controller.rb b/app/controllers/api/v1/filters/keywords_controller.rb new file mode 100644 index 000000000..d3718a137 --- /dev/null +++ b/app/controllers/api/v1/filters/keywords_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class Api::V1::Filters::KeywordsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show] + before_action :require_user! + + before_action :set_keywords, only: :index + before_action :set_keyword, only: [:show, :update, :destroy] + + def index + render json: @keywords, each_serializer: REST::FilterKeywordSerializer + end + + def create + @keyword = current_account.custom_filters.find(params[:filter_id]).keywords.create!(resource_params) + + render json: @keyword, serializer: REST::FilterKeywordSerializer + end + + def show + render json: @keyword, serializer: REST::FilterKeywordSerializer + end + + def update + @keyword.update!(resource_params) + + render json: @keyword, serializer: REST::FilterKeywordSerializer + end + + def destroy + @keyword.destroy! + render_empty + end + + private + + def set_keywords + filter = current_account.custom_filters.includes(:keywords).find(params[:filter_id]) + @keywords = filter.keywords + end + + def set_keyword + @keyword = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account: current_account }).find(params[:id]) + end + + def resource_params + params.permit(:keyword, :whole_word) + end +end diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb index b0ace3af0..07cd14147 100644 --- a/app/controllers/api/v1/filters_controller.rb +++ b/app/controllers/api/v1/filters_controller.rb @@ -8,21 +8,32 @@ class Api::V1::FiltersController < Api::BaseController before_action :set_filter, only: [:show, :update, :destroy] def index - render json: @filters, each_serializer: REST::FilterSerializer + render json: @filters, each_serializer: REST::V1::FilterSerializer end def create - @filter = current_account.custom_filters.create!(resource_params) - render json: @filter, serializer: REST::FilterSerializer + ApplicationRecord.transaction do + filter_category = current_account.custom_filters.create!(resource_params) + @filter = filter_category.keywords.create!(keyword_params) + end + + render json: @filter, serializer: REST::V1::FilterSerializer end def show - render json: @filter, serializer: REST::FilterSerializer + render json: @filter, serializer: REST::V1::FilterSerializer end def update - @filter.update!(resource_params) - render json: @filter, serializer: REST::FilterSerializer + ApplicationRecord.transaction do + @filter.update!(keyword_params) + @filter.custom_filter.assign_attributes(filter_params) + raise Mastodon::ValidationError, I18n.t('filters.errors.deprecated_api_multiple_keywords') if @filter.custom_filter.changed? && @filter.custom_filter.keywords.count > 1 + + @filter.custom_filter.save! + end + + render json: @filter, serializer: REST::V1::FilterSerializer end def destroy @@ -33,14 +44,22 @@ class Api::V1::FiltersController < Api::BaseController private def set_filters - @filters = current_account.custom_filters + @filters = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account: current_account }) end def set_filter - @filter = current_account.custom_filters.find(params[:id]) + @filter = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account: current_account }).find(params[:id]) end def resource_params params.permit(:phrase, :expires_in, :irreversible, :whole_word, context: []) end + + def filter_params + resource_params.slice(:expires_in, :irreversible, :context) + end + + def keyword_params + resource_params.slice(:phrase, :whole_word) + end end diff --git a/app/controllers/api/v2/filters_controller.rb b/app/controllers/api/v2/filters_controller.rb new file mode 100644 index 000000000..8ff3076cf --- /dev/null +++ b/app/controllers/api/v2/filters_controller.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class Api::V2::FiltersController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show] + before_action :require_user! + before_action :set_filters, only: :index + before_action :set_filter, only: [:show, :update, :destroy] + + def index + render json: @filters, each_serializer: REST::FilterSerializer, rules_requested: true + end + + def create + @filter = current_account.custom_filters.create!(resource_params) + + render json: @filter, serializer: REST::FilterSerializer, rules_requested: true + end + + def show + render json: @filter, serializer: REST::FilterSerializer, rules_requested: true + end + + def update + @filter.update!(resource_params) + + render json: @filter, serializer: REST::FilterSerializer, rules_requested: true + end + + def destroy + @filter.destroy! + render_empty + end + + private + + def set_filters + @filters = current_account.custom_filters.includes(:keywords) + end + + def set_filter + @filter = current_account.custom_filters.find(params[:id]) + end + + def resource_params + params.permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) + end +end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index 79a1ab02b..5ed53bce1 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -4,16 +4,16 @@ class FiltersController < ApplicationController layout 'admin' before_action :authenticate_user! - before_action :set_filters, only: :index before_action :set_filter, only: [:edit, :update, :destroy] before_action :set_body_classes def index - @filters = current_account.custom_filters.order(:phrase) + @filters = current_account.custom_filters.includes(:keywords).order(:phrase) end def new - @filter = current_account.custom_filters.build + @filter = current_account.custom_filters.build(action: :warn) + @filter.keywords.build end def create @@ -43,16 +43,12 @@ class FiltersController < ApplicationController private - def set_filters - @filters = current_account.custom_filters - end - def set_filter @filter = current_account.custom_filters.find(params[:id]) end def resource_params - params.require(:custom_filter).permit(:phrase, :expires_in, :irreversible, :whole_word, context: []) + params.require(:custom_filter).permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) end def set_body_classes diff --git a/app/javascript/mastodon/actions/filters.js b/app/javascript/mastodon/actions/filters.js deleted file mode 100644 index 7fa1c9a70..000000000 --- a/app/javascript/mastodon/actions/filters.js +++ /dev/null @@ -1,26 +0,0 @@ -import api from '../api'; - -export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; -export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; -export const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL'; - -export const fetchFilters = () => (dispatch, getState) => { - dispatch({ - type: FILTERS_FETCH_REQUEST, - skipLoading: true, - }); - - api(getState) - .get('/api/v1/filters') - .then(({ data }) => dispatch({ - type: FILTERS_FETCH_SUCCESS, - filters: data, - skipLoading: true, - })) - .catch(err => dispatch({ - type: FILTERS_FETCH_FAIL, - err, - skipLoading: true, - skipAlert: true, - })); -}; diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index f4372fb31..9c69be601 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -5,6 +5,7 @@ export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'; export const STATUS_IMPORT = 'STATUS_IMPORT'; export const STATUSES_IMPORT = 'STATUSES_IMPORT'; export const POLLS_IMPORT = 'POLLS_IMPORT'; +export const FILTERS_IMPORT = 'FILTERS_IMPORT'; function pushUnique(array, object) { if (array.every(element => element.id !== object.id)) { @@ -28,6 +29,10 @@ export function importStatuses(statuses) { return { type: STATUSES_IMPORT, statuses }; } +export function importFilters(filters) { + return { type: FILTERS_IMPORT, filters }; +} + export function importPolls(polls) { return { type: POLLS_IMPORT, polls }; } @@ -61,11 +66,16 @@ export function importFetchedStatuses(statuses) { const accounts = []; const normalStatuses = []; const polls = []; + const filters = []; function processStatus(status) { pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]))); pushUnique(accounts, status.account); + if (status.filtered) { + status.filtered.forEach(result => pushUnique(filters, result.filter)); + } + if (status.reblog && status.reblog.id) { processStatus(status.reblog); } @@ -80,6 +90,7 @@ export function importFetchedStatuses(statuses) { dispatch(importPolls(polls)); dispatch(importFetchedAccounts(accounts)); dispatch(importStatuses(normalStatuses)); + dispatch(importFilters(filters)); }; } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index ca76e3494..8a22f83fa 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -42,6 +42,14 @@ export function normalizeAccount(account) { return account; } +export function normalizeFilterResult(result) { + const normalResult = { ...result }; + + normalResult.filter = normalResult.filter.id; + + return normalResult; +} + export function normalizeStatus(status, normalOldStatus) { const normalStatus = { ...status }; normalStatus.account = status.account.id; @@ -54,6 +62,10 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.poll = status.poll.id; } + if (status.filtered) { + normalStatus.filtered = status.filtered.map(normalizeFilterResult); + } + // Only calculate these values when status first encountered and // when the underlying values change. Otherwise keep the ones // already in the reducer diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 84dfbeef3..3c42f71da 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -12,10 +12,8 @@ import { saveSettings } from './settings'; import { defineMessages } from 'react-intl'; import { List as ImmutableList } from 'immutable'; import { unescapeHTML } from '../utils/html'; -import { getFiltersRegex } from '../selectors'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; import compareId from 'mastodon/compare_id'; -import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer'; import { requestNotificationPermission } from '../utils/notifications'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; @@ -62,20 +60,17 @@ export function updateNotifications(notification, intlMessages, intlLocale) { const showInColumn = activeFilter === 'all' ? getState().getIn(['settings', 'notifications', 'shows', notification.type], true) : activeFilter === notification.type; const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); - const filters = getFiltersRegex(getState(), { contextType: 'notifications' }); let filtered = false; - if (['mention', 'status'].includes(notification.type)) { - const dropRegex = filters[0]; - const regex = filters[1]; - const searchIndex = searchTextFromRawStatus(notification.status); + if (['mention', 'status'].includes(notification.type) && notification.status.filtered) { + const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications')); - if (dropRegex && dropRegex.test(searchIndex)) { + if (filters.some(result => result.filter.filter_action === 'hide')) { return; } - filtered = regex && regex.test(searchIndex); + filtered = filters.length > 0; } if (['follow_request'].includes(notification.type)) { diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index d76f045c8..84709083f 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -21,7 +21,6 @@ import { updateReaction as updateAnnouncementsReaction, deleteAnnouncement, } from './announcements'; -import { fetchFilters } from './filters'; import { getLocale } from '../locales'; const { messages } = getLocale(); @@ -97,9 +96,6 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti case 'conversation': dispatch(updateConversations(JSON.parse(data.payload))); break; - case 'filters_changed': - dispatch(fetchFilters()); - break; case 'announcement': dispatch(updateAnnouncements(JSON.parse(data.payload))); break; diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 7c44669d2..4ca392824 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -116,6 +116,7 @@ class Status extends ImmutablePureComponent { state = { showMedia: defaultMediaVisibility(this.props.status), statusId: undefined, + forceFilter: undefined, }; static getDerivedStateFromProps(nextProps, prevState) { @@ -277,6 +278,15 @@ class Status extends ImmutablePureComponent { this.handleToggleMediaVisibility(); } + handleUnfilterClick = e => { + this.setState({ forceFilter: false }); + e.preventDefault(); + } + + handleFilterClick = () => { + this.setState({ forceFilter: true }); + } + _properStatus () { const { status } = this.props; @@ -328,7 +338,8 @@ class Status extends ImmutablePureComponent { ); } - if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) { + const matchedFilters = status.get('filtered') || status.getIn(['reblog', 'filtered']); + if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) { const minHandlers = this.props.muted ? {} : { moveUp: this.handleHotkeyMoveUp, moveDown: this.handleHotkeyMoveDown, @@ -337,7 +348,11 @@ class Status extends ImmutablePureComponent { return (
- + : {matchedFilters.join(', ')}. + {' '} +
); @@ -496,7 +511,7 @@ class Status extends ImmutablePureComponent { {media} - + diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 1d8fe23da..ab8755be0 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -38,6 +38,7 @@ const messages = defineMessages({ admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' }, copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, + hide: { id: 'status.hide', defaultMessage: 'Hide toot' }, blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' }, unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, @@ -76,6 +77,7 @@ class StatusActionBar extends ImmutablePureComponent { onMuteConversation: PropTypes.func, onPin: PropTypes.func, onBookmark: PropTypes.func, + onFilter: PropTypes.func, withDismiss: PropTypes.bool, withCounters: PropTypes.bool, scrollKey: PropTypes.string, @@ -207,6 +209,10 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onMuteConversation(this.props.status); } + handleFilter = () => { + this.props.onFilter(); + } + handleCopy = () => { const url = this.props.status.get('url'); const textarea = document.createElement('textarea'); @@ -226,6 +232,11 @@ class StatusActionBar extends ImmutablePureComponent { } } + + handleFilterClick = () => { + this.props.onFilter(); + } + render () { const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props; @@ -329,6 +340,10 @@ class StatusActionBar extends ImmutablePureComponent { ); + const filterButton = this.props.onFilter && ( + + ); + return (
@@ -337,6 +352,8 @@ class StatusActionBar extends ImmutablePureComponent { {shareButton} + {filterButton} +
this.props.dispatch(fetchFilters()), 500); + setTimeout(() => this.props.dispatch(fetchRules()), 3000); this.hotkeys.__mousetrap__.stopCallback = (e, element) => { diff --git a/app/javascript/mastodon/reducers/filters.js b/app/javascript/mastodon/reducers/filters.js index 33f0c6732..14b704027 100644 --- a/app/javascript/mastodon/reducers/filters.js +++ b/app/javascript/mastodon/reducers/filters.js @@ -1,10 +1,34 @@ -import { FILTERS_FETCH_SUCCESS } from '../actions/filters'; -import { List as ImmutableList, fromJS } from 'immutable'; +import { FILTERS_IMPORT } from '../actions/importer'; +import { Map as ImmutableMap, is, fromJS } from 'immutable'; -export default function filters(state = ImmutableList(), action) { +const normalizeFilter = (state, filter) => { + const normalizedFilter = fromJS({ + id: filter.id, + title: filter.title, + context: filter.context, + filter_action: filter.filter_action, + expires_at: filter.expires_at ? Date.parse(filter.expires_at) : null, + }); + + if (is(state.get(filter.id), normalizedFilter)) { + return state; + } else { + return state.set(filter.id, normalizedFilter); + } +}; + +const normalizeFilters = (state, filters) => { + filters.forEach(filter => { + state = normalizeFilter(state, filter); + }); + + return state; +}; + +export default function filters(state = ImmutableMap(), action) { switch(action.type) { - case FILTERS_FETCH_SUCCESS: - return fromJS(action.filters); + case FILTERS_IMPORT: + return normalizeFilters(state, action.filters); default: return state; } diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index fbd25b605..6aeb8b7bd 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -40,15 +40,15 @@ const toServerSideType = columnType => { const escapeRegExp = string => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string -const regexFromFilters = filters => { - if (filters.size === 0) { +const regexFromKeywords = keywords => { + if (keywords.size === 0) { return null; } - return new RegExp(filters.map(filter => { - let expr = escapeRegExp(filter.get('phrase')); + return new RegExp(keywords.map(keyword_filter => { + let expr = escapeRegExp(keyword_filter.get('keyword')); - if (filter.get('whole_word')) { + if (keyword_filter.get('whole_word')) { if (/^[\w]/.test(expr)) { expr = `\\b${expr}`; } @@ -62,27 +62,15 @@ const regexFromFilters = filters => { }).join('|'), 'i'); }; -// Memoize the filter regexps for each valid server contextType -const makeGetFiltersRegex = () => { - let memo = {}; +const getFilters = (state, { contextType }) => { + if (!contextType) return null; - return (state, { contextType }) => { - if (!contextType) return ImmutableList(); + const serverSideType = toServerSideType(contextType); + const now = new Date(); - const serverSideType = toServerSideType(contextType); - const filters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))); - - if (!memo[serverSideType] || !is(memo[serverSideType].filters, filters)) { - const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible'))); - const regex = regexFromFilters(filters); - memo[serverSideType] = { filters: filters, results: [dropRegex, regex] }; - } - return memo[serverSideType].results; - }; + return state.get('filters').filter((filter) => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || filter.get('expires_at') > now)); }; -export const getFiltersRegex = makeGetFiltersRegex(); - export const makeGetStatus = () => { return createSelector( [ @@ -90,10 +78,10 @@ export const makeGetStatus = () => { (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), - getFiltersRegex, + getFilters, ], - (statusBase, statusReblog, accountBase, accountReblog, filtersRegex) => { + (statusBase, statusReblog, accountBase, accountReblog, filters) => { if (!statusBase) { return null; } @@ -104,14 +92,17 @@ export const makeGetStatus = () => { statusReblog = null; } - const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0]; - if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) { - return null; + let filtered = false; + if ((accountReblog || accountBase).get('id') !== me && filters) { + let filterResults = statusReblog?.get('filtered') || statusBase.get('filtered') || ImmutableList(); + if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) { + return null; + } + if (!filterResults.isEmpty()) { + filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title'])); + } } - const regex = (accountReblog || accountBase).get('id') !== me && filtersRegex[1]; - const filtered = regex && regex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index')); - return statusBase.withMutations(map => { map.set('reblog', statusReblog); map.set('account', accountBase); diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index 3d0a937e1..e42468e0c 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -4,6 +4,7 @@ import loadPolyfills from '../mastodon/load_polyfills'; import ready from '../mastodon/ready'; import { start } from '../mastodon/common'; import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions'; +import 'cocoon-js-vanilla'; start(); diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 66e2997f1..4ce5cd101 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -915,7 +915,8 @@ a.name-tag, text-align: center; } -.applications-list__item { +.applications-list__item, +.filters-list__item { padding: 15px 0; background: $ui-base-color; border: 1px solid lighten($ui-base-color, 4%); @@ -923,7 +924,8 @@ a.name-tag, margin-top: 15px; } -.announcements-list { +.announcements-list, +.filters-list { border: 1px solid lighten($ui-base-color, 4%); border-radius: 4px; @@ -976,6 +978,33 @@ a.name-tag, } } +.filters-list__item { + &__title { + display: flex; + justify-content: space-between; + margin-bottom: 0; + } + + &__permissions { + margin-top: 0; + margin-bottom: 10px; + } + + .expiration { + font-size: 13px; + } + + &.expired { + .expiration { + color: lighten($error-red, 12%); + } + + .permissions-list__item__icon { + color: $dark-text-color; + } + } +} + .dashboard__counters.admin-account-counters { margin-top: 10px; } diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 7e3ce3de2..592ce91f3 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -959,6 +959,21 @@ width: 100%; clear: both; border-bottom: 1px solid lighten($ui-base-color, 8%); + + &__button { + display: inline; + color: lighten($ui-highlight-color, 8%); + border: 0; + background: transparent; + padding: 0; + font-size: inherit; + line-height: inherit; + + &:hover, + &:active { + text-decoration: underline; + } + } } .status__prepend-icon-wrapper { diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index d57eabc09..da699dd25 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -1070,3 +1070,34 @@ code { } } } + +.keywords-table { + thead { + th { + white-space: nowrap; + } + + th:first-child { + width: 100%; + } + } + + tfoot { + td { + border: 0; + } + } + + .input.string { + margin-bottom: 0; + } + + .label_input__wrapper { + margin-top: 10px; + } + + .table-action-link { + margin-top: 10px; + white-space: nowrap; + } +} diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 4811ebbcc..2eb4ba2f4 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -352,7 +352,6 @@ class FeedManager def filter_from_home?(status, receiver_id, crutches) return false if receiver_id == status.account_id return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?) - return true if phrase_filtered?(status, receiver_id, :home) check_for_blocks = crutches[:active_mentions][status.id] || [] check_for_blocks.concat([status.account_id]) @@ -388,7 +387,6 @@ class FeedManager # @return [Boolean] def filter_from_mentions?(status, receiver_id) return true if receiver_id == status.account_id - return true if phrase_filtered?(status, receiver_id, :notifications) # This filter is called from NotifyService, but already after the sender of # the notification has been checked for mute/block. Therefore, it's not @@ -418,34 +416,6 @@ class FeedManager false end - # Check if the status hits a phrase filter - # @param [Status] status - # @param [Integer] receiver_id - # @param [Symbol] context - # @return [Boolean] - def phrase_filtered?(status, receiver_id, context) - active_filters = Rails.cache.fetch("filters:#{receiver_id}") { CustomFilter.where(account_id: receiver_id).active_irreversible.to_a }.to_a - - active_filters.select! { |filter| filter.context.include?(context.to_s) && !filter.expired? } - - active_filters.map! do |filter| - if filter.whole_word - sb = /\A[[:word:]]/.match?(filter.phrase) ? '\b' : '' - eb = /[[:word:]]\z/.match?(filter.phrase) ? '\b' : '' - - /(?mix:#{sb}#{Regexp.escape(filter.phrase)}#{eb})/ - else - /#{Regexp.escape(filter.phrase)}/i - end - end - - return false if active_filters.empty? - - combined_regex = Regexp.union(active_filters) - - combined_regex.match?(status.proper.searchable_text) - end - # Adds a status to an account's feed, returning true if a status was # added, and false if it was not added to the feed. Note that this is # an internal helper: callers must call trim or push updates if diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index ad1665dc4..a7401362f 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -247,6 +247,19 @@ module AccountInteractions account_pins.where(target_account: account).exists? end + def status_matches_filters(status) + active_filters = CustomFilter.cached_filters_for(id) + + filter_matches = active_filters.filter_map do |filter, rules| + next if rules[:keywords].blank? + + match = rules[:keywords].match(status.proper.searchable_text) + FilterResultPresenter.new(filter: filter, keyword_matches: [match.to_s]) unless match.nil? + end + + filter_matches + end + def followers_for_local_distribution followers.local .joins(:user) diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb index 8e3476794..e98ed7df9 100644 --- a/app/models/custom_filter.rb +++ b/app/models/custom_filter.rb @@ -3,18 +3,22 @@ # # Table name: custom_filters # -# id :bigint(8) not null, primary key -# account_id :bigint(8) -# expires_at :datetime -# phrase :text default(""), not null -# context :string default([]), not null, is an Array -# whole_word :boolean default(TRUE), not null -# irreversible :boolean default(FALSE), not null -# created_at :datetime not null -# updated_at :datetime not null +# id :bigint not null, primary key +# account_id :bigint +# expires_at :datetime +# phrase :text default(""), not null +# context :string default([]), not null, is an Array +# created_at :datetime not null +# updated_at :datetime not null +# action :integer default(0), not null # class CustomFilter < ApplicationRecord + self.ignored_columns = %w(whole_word irreversible) + + alias_attribute :title, :phrase + alias_attribute :filter_action, :action + VALID_CONTEXTS = %w( home notifications @@ -26,16 +30,20 @@ class CustomFilter < ApplicationRecord include Expireable include Redisable + enum action: [:warn, :hide], _suffix: :action + belongs_to :account + has_many :keywords, class_name: 'CustomFilterKeyword', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy + accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true - validates :phrase, :context, presence: true + validates :title, :context, presence: true validate :context_must_be_valid - validate :irreversible_must_be_within_context - - scope :active_irreversible, -> { where(irreversible: true).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()')) } before_validation :clean_up_contexts - after_commit :remove_cache + + before_save :prepare_cache_invalidation! + before_destroy :prepare_cache_invalidation! + after_commit :invalidate_cache! def expires_in return @expires_in if defined?(@expires_in) @@ -44,22 +52,55 @@ class CustomFilter < ApplicationRecord [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].find { |expires_in| expires_in.from_now >= expires_at } end - private + def irreversible=(value) + self.action = value ? :hide : :warn + end - def clean_up_contexts - self.context = Array(context).map(&:strip).filter_map(&:presence) + def irreversible? + hide_action? + end + + def self.cached_filters_for(account_id) + active_filters = Rails.cache.fetch("filters:v3:#{account_id}") do + scope = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()')) + scope.to_a.group_by(&:custom_filter).map do |filter, keywords| + keywords.map! do |keyword| + if keyword.whole_word + sb = /\A[[:word:]]/.match?(keyword.keyword) ? '\b' : '' + eb = /[[:word:]]\z/.match?(keyword.keyword) ? '\b' : '' + + /(?mix:#{sb}#{Regexp.escape(keyword.keyword)}#{eb})/ + else + /#{Regexp.escape(keyword.keyword)}/i + end + end + [filter, { keywords: Regexp.union(keywords) }] + end + end.to_a + + active_filters.select { |custom_filter, _| !custom_filter.expired? } + end + + def prepare_cache_invalidation! + @should_invalidate_cache = true end - def remove_cache - Rails.cache.delete("filters:#{account_id}") + def invalidate_cache! + return unless @should_invalidate_cache + @should_invalidate_cache = false + + Rails.cache.delete("filters:v3:#{account_id}") redis.publish("timeline:#{account_id}", Oj.dump(event: :filters_changed)) + redis.publish("timeline:system:#{account_id}", Oj.dump(event: :filters_changed)) end - def context_must_be_valid - errors.add(:context, I18n.t('filters.errors.invalid_context')) if context.empty? || context.any? { |c| !VALID_CONTEXTS.include?(c) } + private + + def clean_up_contexts + self.context = Array(context).map(&:strip).filter_map(&:presence) end - def irreversible_must_be_within_context - errors.add(:irreversible, I18n.t('filters.errors.invalid_irreversible')) if irreversible? && !context.include?('home') && !context.include?('notifications') + def context_must_be_valid + errors.add(:context, I18n.t('filters.errors.invalid_context')) if context.empty? || context.any? { |c| !VALID_CONTEXTS.include?(c) } end end diff --git a/app/models/custom_filter_keyword.rb b/app/models/custom_filter_keyword.rb new file mode 100644 index 000000000..bf5c55746 --- /dev/null +++ b/app/models/custom_filter_keyword.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: custom_filter_keywords +# +# id :bigint not null, primary key +# custom_filter_id :bigint not null +# keyword :text default(""), not null +# whole_word :boolean default(TRUE), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class CustomFilterKeyword < ApplicationRecord + belongs_to :custom_filter + + validates :keyword, presence: true + + alias_attribute :phrase, :keyword + + before_save :prepare_cache_invalidation! + before_destroy :prepare_cache_invalidation! + after_commit :invalidate_cache! + + private + + def prepare_cache_invalidation! + custom_filter.prepare_cache_invalidation! + end + + def invalidate_cache! + custom_filter.invalidate_cache! + end +end diff --git a/app/presenters/filter_result_presenter.rb b/app/presenters/filter_result_presenter.rb new file mode 100644 index 000000000..677225f5e --- /dev/null +++ b/app/presenters/filter_result_presenter.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class FilterResultPresenter < ActiveModelSerializers::Model + attributes :filter, :keyword_matches +end diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index 4163bb098..d7ffb1954 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -2,7 +2,7 @@ class StatusRelationshipsPresenter attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map, - :bookmarks_map + :bookmarks_map, :filters_map def initialize(statuses, current_account_id = nil, **options) if current_account_id.nil? @@ -11,12 +11,14 @@ class StatusRelationshipsPresenter @bookmarks_map = {} @mutes_map = {} @pins_map = {} + @filters_map = {} else statuses = statuses.compact status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact conversation_ids = statuses.filter_map(&:conversation_id).uniq pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && %w(public unlisted private).include?(s.visibility) } + @filters_map = build_filters_map(statuses, current_account_id).merge(options[:filters_map] || {}) @reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {}) @favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {}) @bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {}) @@ -24,4 +26,24 @@ class StatusRelationshipsPresenter @pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {}) end end + + private + + def build_filters_map(statuses, current_account_id) + active_filters = CustomFilter.cached_filters_for(current_account_id) + + @filters_map = statuses.each_with_object({}) do |status, h| + filter_matches = active_filters.filter_map do |filter, rules| + next if rules[:keywords].blank? + + match = rules[:keywords].match(status.proper.searchable_text) + FilterResultPresenter.new(filter: filter, keyword_matches: [match.to_s]) unless match.nil? + end + + unless filter_matches.empty? + h[status.id] = filter_matches + h[status.reblog_of_id] = filter_matches if status.reblog? + end + end + end end diff --git a/app/serializers/rest/filter_keyword_serializer.rb b/app/serializers/rest/filter_keyword_serializer.rb new file mode 100644 index 000000000..dd2ebac6e --- /dev/null +++ b/app/serializers/rest/filter_keyword_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class REST::FilterKeywordSerializer < ActiveModel::Serializer + attributes :id, :keyword, :whole_word + + def id + object.id.to_s + end +end diff --git a/app/serializers/rest/filter_result_serializer.rb b/app/serializers/rest/filter_result_serializer.rb new file mode 100644 index 000000000..0ef4db79a --- /dev/null +++ b/app/serializers/rest/filter_result_serializer.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class REST::FilterResultSerializer < ActiveModel::Serializer + belongs_to :filter, serializer: REST::FilterSerializer + has_many :keyword_matches +end diff --git a/app/serializers/rest/filter_serializer.rb b/app/serializers/rest/filter_serializer.rb index 57205630b..98d7edb17 100644 --- a/app/serializers/rest/filter_serializer.rb +++ b/app/serializers/rest/filter_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class REST::FilterSerializer < ActiveModel::Serializer - attributes :id, :phrase, :context, :whole_word, :expires_at, - :irreversible + attributes :id, :title, :context, :expires_at, :filter_action + has_many :keywords, serializer: REST::FilterKeywordSerializer, if: :rules_requested? def id object.id.to_s end + + def rules_requested? + instance_options[:rules_requested] + end end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 6bd6a23e5..e0b8f32a6 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -13,6 +13,7 @@ class REST::StatusSerializer < ActiveModel::Serializer attribute :muted, if: :current_user? attribute :bookmarked, if: :current_user? attribute :pinned, if: :pinnable? + has_many :filtered, serializer: REST::FilterResultSerializer, if: :current_user? attribute :content, unless: :source_requested? attribute :text, if: :source_requested? @@ -120,6 +121,14 @@ class REST::StatusSerializer < ActiveModel::Serializer end end + def filtered + if instance_options && instance_options[:relationships] + instance_options[:relationships].filters_map[object.id] || [] + else + current_user.account.status_matches_filters(object) + end + end + def pinnable? current_user? && current_user.account_id == object.account_id && diff --git a/app/serializers/rest/v1/filter_serializer.rb b/app/serializers/rest/v1/filter_serializer.rb new file mode 100644 index 000000000..455f17efd --- /dev/null +++ b/app/serializers/rest/v1/filter_serializer.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class REST::V1::FilterSerializer < ActiveModel::Serializer + attributes :id, :phrase, :context, :whole_word, :expires_at, + :irreversible + + delegate :context, :expires_at, to: :custom_filter + + def id + object.id.to_s + end + + def phrase + object.keyword + end + + def irreversible + custom_filter.irreversible? + end + + private + + def custom_filter + object.custom_filter + end +end diff --git a/app/views/filters/_fields.html.haml b/app/views/filters/_fields.html.haml deleted file mode 100644 index 84dcdcca5..000000000 --- a/app/views/filters/_fields.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -.fields-row - .fields-row__column.fields-row__column-6.fields-group - = f.input :phrase, as: :string, wrapper: :with_label, hint: false - .fields-row__column.fields-row__column-6.fields-group - = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, include_blank: I18n.t('invites.expires_in_prompt') - -.fields-group - = f.input :context, wrapper: :with_block_label, collection: CustomFilter::VALID_CONTEXTS, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: lambda { |context| I18n.t("filters.contexts.#{context}") }, include_blank: false - -%hr.spacer/ - -.fields-group - = f.input :irreversible, wrapper: :with_label - -.fields-group - = f.input :whole_word, wrapper: :with_label diff --git a/app/views/filters/_filter.html.haml b/app/views/filters/_filter.html.haml new file mode 100644 index 000000000..2ab014081 --- /dev/null +++ b/app/views/filters/_filter.html.haml @@ -0,0 +1,32 @@ +.filters-list__item{ class: [filter.expired? && 'expired'] } + = link_to edit_filter_path(filter), class: 'filters-list__item__title' do + = filter.title + + - if filter.expires? + .expiration{ title: t('filters.index.expires_on', date: l(filter.expires_at)) } + - if filter.expired? + = t('invites.expired') + - else + = t('filters.index.expires_in', distance: distance_of_time_in_words_to_now(filter.expires_at)) + + .filters-list__item__permissions + %ul.permissions-list + - unless filter.keywords.empty? + %li.permissions-list__item + .permissions-list__item__icon + = fa_icon('paragraph') + .permissions-list__item__text + .permissions-list__item__text__title + = t('filters.index.keywords', count: filter.keywords.size) + .permissions-list__item__text__type + - keywords = filter.keywords.map(&:keyword) + - keywords = keywords.take(5) + ['…'] if keywords.size > 5 # TODO + = keywords.join(', ') + + .announcements-list__item__action-bar + .announcements-list__item__meta + = t('filters.index.contexts', contexts: filter.context.map { |context| I18n.t("filters.contexts.#{context}") }.join(', ')) + + %div + = table_link_to 'pencil', t('filters.edit.title'), edit_filter_path(filter) + = table_link_to 'times', t('filters.index.delete'), filter_path(filter), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/filters/_filter_fields.html.haml b/app/views/filters/_filter_fields.html.haml new file mode 100644 index 000000000..1a52faa7a --- /dev/null +++ b/app/views/filters/_filter_fields.html.haml @@ -0,0 +1,33 @@ +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :title, as: :string, wrapper: :with_label, hint: false + .fields-row__column.fields-row__column-6.fields-group + = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, include_blank: I18n.t('invites.expires_in_prompt') + +.fields-group + = f.input :context, wrapper: :with_block_label, collection: CustomFilter::VALID_CONTEXTS, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: lambda { |context| I18n.t("filters.contexts.#{context}") }, include_blank: false + +%hr.spacer/ + +.fields-group + = f.input :filter_action, as: :radio_buttons, collection: %i(warn hide), include_blank: false, wrapper: :with_block_label, label_method: ->(action) { safe_join([t("simple_form.labels.filters.actions.#{action}"), content_tag(:span, t("simple_form.hints.filters.actions.#{action}"), class: 'hint')]) }, hint: t('simple_form.hints.filters.action'), required: true + +%hr.spacer/ + +%h4= t('filters.edit.keywords') + +.table-wrapper + %table.table.keywords-table + %thead + %tr + %th= t('simple_form.labels.defaults.phrase') + %th= t('simple_form.labels.defaults.whole_word') + %th + %tbody + = f.simple_fields_for :keywords do |keyword| + = render 'keyword_fields', f: keyword + %tfoot + %tr + %td{ colspan: 3} + = link_to_add_association f, :keywords, class: 'table-action-link', partial: 'keyword_fields', 'data-association-insertion-node': '.keywords-table tbody', 'data-association-insertion-method': 'append' do + = safe_join([fa_icon('plus'), t('filters.edit.add_keyword')]) diff --git a/app/views/filters/_keyword_fields.html.haml b/app/views/filters/_keyword_fields.html.haml new file mode 100644 index 000000000..eedd514ef --- /dev/null +++ b/app/views/filters/_keyword_fields.html.haml @@ -0,0 +1,8 @@ +%tr.nested-fields + %td= f.input :keyword, as: :string + %td + .label_input__wrapper= f.input_field :whole_word + %td + = f.hidden_field :id if f.object&.persisted? # Required so Rails doesn't put the field outside of the + = link_to_remove_association(f, class: 'table-action-link') do + = safe_join([fa_icon('times'), t('filters.index.delete')]) diff --git a/app/views/filters/edit.html.haml b/app/views/filters/edit.html.haml index e971215ac..3dc3f07b7 100644 --- a/app/views/filters/edit.html.haml +++ b/app/views/filters/edit.html.haml @@ -2,7 +2,7 @@ = t('filters.edit.title') = simple_form_for @filter, url: filter_path(@filter), method: :put do |f| - = render 'fields', f: f + = render 'filter_fields', f: f .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/filters/index.html.haml b/app/views/filters/index.html.haml index b4d5333aa..0227526a4 100644 --- a/app/views/filters/index.html.haml +++ b/app/views/filters/index.html.haml @@ -7,18 +7,5 @@ - if @filters.empty? %div.muted-hint.center-text= t 'filters.index.empty' - else - .table-wrapper - %table.table - %thead - %tr - %th= t('simple_form.labels.defaults.phrase') - %th= t('simple_form.labels.defaults.context') - %th - %tbody - - @filters.each do |filter| - %tr - %td= filter.phrase - %td= filter.context.map { |context| I18n.t("filters.contexts.#{context}") }.join(', ') - %td - = table_link_to 'pencil', t('filters.edit.title'), edit_filter_path(filter) - = table_link_to 'times', t('filters.index.delete'), filter_path(filter), method: :delete + .applications-list + = render partial: 'filter', collection: @filters diff --git a/app/views/filters/new.html.haml b/app/views/filters/new.html.haml index 05bec343f..5f400e604 100644 --- a/app/views/filters/new.html.haml +++ b/app/views/filters/new.html.haml @@ -2,7 +2,7 @@ = t('filters.new.title') = simple_form_for @filter, url: filters_path do |f| - = render 'fields', f: f + = render 'filter_fields', f: f .actions - = f.button :button, t('filters.new.title'), type: :submit + = f.button :button, t('filters.new.save'), type: :submit diff --git a/config/locales/en.yml b/config/locales/en.yml index 5a0fc3da8..91ae3a3bc 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1124,15 +1124,24 @@ en: public: Public timelines thread: Conversations edit: + add_keyword: Add keyword + keywords: Keywords title: Edit filter errors: + deprecated_api_multiple_keywords: These parameters cannot be changed from this application because they apply to more than one filter keyword. Use a more recent application or the web interface. invalid_context: None or invalid context supplied - invalid_irreversible: Irreversible filtering only works with home or notifications context index: + contexts: Filters in %{contexts} delete: Delete empty: You have no filters. + expires_in: Expires in %{distance} + expires_on: Expires on %{date} + keywords: + one: "%{count} keyword" + other: "%{count} keywords" title: Filters new: + save: Save new filter title: Add new filter footer: developers: Developers diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 7e4f52849..ea4f68562 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -68,6 +68,11 @@ en: with_dns_records: An attempt to resolve the given domain's DNS records will be made and the results will also be blocked featured_tag: name: 'You might want to use one of these:' + filters: + action: Chose which action to perform when a post matches the filter + actions: + hide: Completely hide the filtered content, behaving as if it did not exist + warn: Hide the filtered content behind a warning mentioning the filter's title form_challenge: current_password: You are entering a secure area imports: @@ -181,6 +186,7 @@ en: setting_use_pending_items: Slow mode severity: Severity sign_in_token_attempt: Security code + title: Title type: Import type username: Username username_or_email: Username or Email @@ -189,6 +195,10 @@ en: with_dns_records: Include MX records and IPs of the domain featured_tag: name: Hashtag + filters: + actions: + hide: Hide completely + warn: Hide with a warning interactions: must_be_follower: Block notifications from non-followers must_be_following: Block notifications from people you don't follow diff --git a/config/routes.rb b/config/routes.rb index 1b9c50799..4abf55655 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -451,10 +451,16 @@ Rails.application.routes.draw do resources :bookmarks, only: [:index] resources :reports, only: [:create] resources :trends, only: [:index], controller: 'trends/tags' - resources :filters, only: [:index, :create, :show, :update, :destroy] + resources :filters, only: [:index, :create, :show, :update, :destroy] do + resources :keywords, only: [:index, :create], controller: 'filters/keywords' + end resources :endorsements, only: [:index] resources :markers, only: [:index, :create] + namespace :filters do + resources :keywords, only: [:show, :update, :destroy] + end + namespace :apps do get :verify_credentials, to: 'credentials#show' end @@ -589,6 +595,7 @@ Rails.application.routes.draw do resources :media, only: [:create] get '/search', to: 'search#index', as: :search resources :suggestions, only: [:index] + resources :filters, only: [:index, :create, :show, :update, :destroy] namespace :admin do resources :accounts, only: [:index] diff --git a/db/migrate/20220613110628_create_custom_filter_keywords.rb b/db/migrate/20220613110628_create_custom_filter_keywords.rb new file mode 100644 index 000000000..353fc334f --- /dev/null +++ b/db/migrate/20220613110628_create_custom_filter_keywords.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateCustomFilterKeywords < ActiveRecord::Migration[6.1] + def change + create_table :custom_filter_keywords do |t| + t.belongs_to :custom_filter, foreign_key: { on_delete: :cascade }, null: false + t.text :keyword, null: false, default: '' + t.boolean :whole_word, null: false, default: true + + t.timestamps + end + end +end diff --git a/db/migrate/20220613110711_migrate_custom_filters.rb b/db/migrate/20220613110711_migrate_custom_filters.rb new file mode 100644 index 000000000..ea6a9b8c6 --- /dev/null +++ b/db/migrate/20220613110711_migrate_custom_filters.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class MigrateCustomFilters < ActiveRecord::Migration[6.1] + def up + # Preserve IDs as much as possible to not confuse existing clients. + # As long as this migration is irreversible, we do not have to deal with conflicts. + safety_assured do + execute <<-SQL.squish + INSERT INTO custom_filter_keywords (id, custom_filter_id, keyword, whole_word, created_at, updated_at) + SELECT id, id, phrase, whole_word, created_at, updated_at + FROM custom_filters + SQL + end + end + + def down + # Copy back changes from custom filters guaranteed to be from the old API + safety_assured do + execute <<-SQL.squish + UPDATE custom_filters + SET phrase = custom_filter_keywords.keyword, whole_word = custom_filter_keywords.whole_word + FROM custom_filter_keywords + WHERE custom_filters.id = custom_filter_keywords.id AND custom_filters.id = custom_filter_keywords.custom_filter_id + SQL + end + + # Drop every keyword as we can't safely provide a 1:1 mapping + safety_assured do + execute <<-SQL.squish + TRUNCATE custom_filter_keywords RESTART IDENTITY + SQL + end + end +end diff --git a/db/migrate/20220613110834_add_action_to_custom_filters.rb b/db/migrate/20220613110834_add_action_to_custom_filters.rb new file mode 100644 index 000000000..9427a66fc --- /dev/null +++ b/db/migrate/20220613110834_add_action_to_custom_filters.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddActionToCustomFilters < ActiveRecord::Migration[6.1] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def up + safety_assured do + add_column_with_default :custom_filters, :action, :integer, allow_null: false, default: 0 + execute 'UPDATE custom_filters SET action = 1 WHERE irreversible IS TRUE' + end + end + + def down + execute 'UPDATE custom_filters SET irreversible = (action = 1)' + remove_column :custom_filters, :action + end +end diff --git a/db/post_migrate/20220613110802_remove_whole_word_from_custom_filters.rb b/db/post_migrate/20220613110802_remove_whole_word_from_custom_filters.rb new file mode 100644 index 000000000..7ef0749e5 --- /dev/null +++ b/db/post_migrate/20220613110802_remove_whole_word_from_custom_filters.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class RemoveWholeWordFromCustomFilters < ActiveRecord::Migration[6.1] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def up + safety_assured do + remove_column :custom_filters, :whole_word + end + end + + def down + safety_assured do + add_column_with_default :custom_filters, :whole_word, :boolean, default: true, allow_null: false + end + end +end diff --git a/db/post_migrate/20220613110903_remove_irreversible_from_custom_filters.rb b/db/post_migrate/20220613110903_remove_irreversible_from_custom_filters.rb new file mode 100644 index 000000000..6ed8bcfee --- /dev/null +++ b/db/post_migrate/20220613110903_remove_irreversible_from_custom_filters.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class RemoveIrreversibleFromCustomFilters < ActiveRecord::Migration[6.1] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def up + safety_assured do + remove_column :custom_filters, :irreversible + end + end + + def down + safety_assured do + add_column_with_default :custom_filters, :irreversible, :boolean, allow_null: false, default: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 5d8aea601..759dc712b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_06_06_044941) do +ActiveRecord::Schema.define(version: 2022_06_13_110903) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -339,15 +339,23 @@ ActiveRecord::Schema.define(version: 2022_06_06_044941) do t.index ["shortcode", "domain"], name: "index_custom_emojis_on_shortcode_and_domain", unique: true end + create_table "custom_filter_keywords", force: :cascade do |t| + t.bigint "custom_filter_id", null: false + t.text "keyword", default: "", null: false + t.boolean "whole_word", default: true, null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["custom_filter_id"], name: "index_custom_filter_keywords_on_custom_filter_id" + end + create_table "custom_filters", force: :cascade do |t| t.bigint "account_id" t.datetime "expires_at" t.text "phrase", default: "", null: false t.string "context", default: [], null: false, array: true - t.boolean "irreversible", default: false, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.boolean "whole_word", default: true, null: false + t.integer "action", default: 0, null: false t.index ["account_id"], name: "index_custom_filters_on_account_id" end @@ -1082,6 +1090,7 @@ ActiveRecord::Schema.define(version: 2022_06_06_044941) do add_foreign_key "canonical_email_blocks", "accounts", column: "reference_account_id", on_delete: :cascade add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade + add_foreign_key "custom_filter_keywords", "custom_filters", on_delete: :cascade add_foreign_key "custom_filters", "accounts", on_delete: :cascade add_foreign_key "devices", "accounts", on_delete: :cascade add_foreign_key "devices", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade diff --git a/lib/tasks/tests.rake b/lib/tasks/tests.rake index 0f3b44a74..65bff6a8e 100644 --- a/lib/tasks/tests.rake +++ b/lib/tasks/tests.rake @@ -38,10 +38,26 @@ namespace :tests do puts 'Instance actor does not have a private key' exit(1) end + + unless Account.find_by(username: 'user', domain: nil).custom_filters.map { |filter| filter.keywords.pluck(:keyword) } == [['test'], ['take']] + puts 'CustomFilterKeyword records not created as expected' + exit(1) + end + end + + desc 'Populate the database with test data for 2.4.3' + task populate_v2_4_3: :environment do # rubocop:disable Naming/VariableNumber + ActiveRecord::Base.connection.execute(<<~SQL) + INSERT INTO "custom_filters" + (id, account_id, phrase, context, whole_word, irreversible, created_at, updated_at) + VALUES + (1, 2, 'test', '{ "home", "public" }', true, true, now(), now()), + (2, 2, 'take', '{ "home" }', false, false, now(), now()); + SQL end desc 'Populate the database with test data for 2.4.0' - task populate_v2_4: :environment do + task populate_v2_4: :environment do # rubocop:disable Naming/VariableNumber ActiveRecord::Base.connection.execute(<<~SQL) INSERT INTO "settings" (id, thing_type, thing_id, var, value, created_at, updated_at) diff --git a/package.json b/package.json index 30fe8b672..343a8267e 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "blurhash": "^1.1.5", "classnames": "^2.3.1", + "cocoon-js-vanilla": "^1.2.0", "color-blend": "^3.0.1", "compression-webpack-plugin": "^6.1.1", "cross-env": "^7.0.3", @@ -71,6 +72,7 @@ "intl-relativeformat": "^6.4.3", "is-nan": "^1.3.2", "js-yaml": "^4.1.0", + "jsdom": "^20.0.0", "lodash": "^4.17.21", "mark-loader": "^0.1.6", "marky": "^1.2.4", diff --git a/spec/controllers/api/v1/filters/keywords_controller_spec.rb b/spec/controllers/api/v1/filters/keywords_controller_spec.rb new file mode 100644 index 000000000..aecb4e41c --- /dev/null +++ b/spec/controllers/api/v1/filters/keywords_controller_spec.rb @@ -0,0 +1,142 @@ +require 'rails_helper' + +RSpec.describe Api::V1::Filters::KeywordsController, type: :controller do + render_views + + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:filter) { Fabricate(:custom_filter, account: user.account) } + let(:other_user) { Fabricate(:user) } + let(:other_filter) { Fabricate(:custom_filter, account: other_user.account) } + + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'GET #index' do + let(:scopes) { 'read:filters' } + let!(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) } + + it 'returns http success' do + get :index, params: { filter_id: filter.id } + expect(response).to have_http_status(200) + end + + context "when trying to access another's user filters" do + it 'returns http not found' do + get :index, params: { filter_id: other_filter.id } + expect(response).to have_http_status(404) + end + end + end + + describe 'POST #create' do + let(:scopes) { 'write:filters' } + let(:filter_id) { filter.id } + + before do + post :create, params: { filter_id: filter_id, keyword: 'magic', whole_word: false } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'returns a keyword' do + json = body_as_json + expect(json[:keyword]).to eq 'magic' + expect(json[:whole_word]).to eq false + end + + it 'creates a keyword' do + filter = user.account.custom_filters.first + expect(filter).to_not be_nil + expect(filter.keywords.pluck(:keyword)).to eq ['magic'] + end + + context "when trying to add to another another's user filters" do + let(:filter_id) { other_filter.id } + + it 'returns http not found' do + expect(response).to have_http_status(404) + end + end + end + + describe 'GET #show' do + let(:scopes) { 'read:filters' } + let(:keyword) { Fabricate(:custom_filter_keyword, keyword: 'foo', whole_word: false, custom_filter: filter) } + + before do + get :show, params: { id: keyword.id } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'returns expected data' do + json = body_as_json + expect(json[:keyword]).to eq 'foo' + expect(json[:whole_word]).to eq false + end + + context "when trying to access another user's filter keyword" do + let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: other_filter) } + + it 'returns http not found' do + expect(response).to have_http_status(404) + end + end + end + + describe 'PUT #update' do + let(:scopes) { 'write:filters' } + let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) } + + before do + get :update, params: { id: keyword.id, keyword: 'updated' } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'updates the keyword' do + expect(keyword.reload.keyword).to eq 'updated' + end + + context "when trying to update another user's filter keyword" do + let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: other_filter) } + + it 'returns http not found' do + expect(response).to have_http_status(404) + end + end + end + + describe 'DELETE #destroy' do + let(:scopes) { 'write:filters' } + let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) } + + before do + delete :destroy, params: { id: keyword.id } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'removes the filter' do + expect { keyword.reload }.to raise_error ActiveRecord::RecordNotFound + end + + context "when trying to update another user's filter keyword" do + let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: other_filter) } + + it 'returns http not found' do + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/controllers/api/v1/filters_controller_spec.rb b/spec/controllers/api/v1/filters_controller_spec.rb index 5948809e3..af1951f0b 100644 --- a/spec/controllers/api/v1/filters_controller_spec.rb +++ b/spec/controllers/api/v1/filters_controller_spec.rb @@ -34,7 +34,7 @@ RSpec.describe Api::V1::FiltersController, type: :controller do it 'creates a filter' do filter = user.account.custom_filters.first expect(filter).to_not be_nil - expect(filter.phrase).to eq 'magic' + expect(filter.keywords.pluck(:keyword)).to eq ['magic'] expect(filter.context).to eq %w(home) expect(filter.irreversible?).to be true expect(filter.expires_at).to be_nil @@ -42,21 +42,23 @@ RSpec.describe Api::V1::FiltersController, type: :controller do end describe 'GET #show' do - let(:scopes) { 'read:filters' } - let(:filter) { Fabricate(:custom_filter, account: user.account) } + let(:scopes) { 'read:filters' } + let(:filter) { Fabricate(:custom_filter, account: user.account) } + let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) } it 'returns http success' do - get :show, params: { id: filter.id } + get :show, params: { id: keyword.id } expect(response).to have_http_status(200) end end describe 'PUT #update' do - let(:scopes) { 'write:filters' } - let(:filter) { Fabricate(:custom_filter, account: user.account) } + let(:scopes) { 'write:filters' } + let(:filter) { Fabricate(:custom_filter, account: user.account) } + let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) } before do - put :update, params: { id: filter.id, phrase: 'updated' } + put :update, params: { id: keyword.id, phrase: 'updated' } end it 'returns http success' do @@ -64,16 +66,17 @@ RSpec.describe Api::V1::FiltersController, type: :controller do end it 'updates the filter' do - expect(filter.reload.phrase).to eq 'updated' + expect(keyword.reload.phrase).to eq 'updated' end end describe 'DELETE #destroy' do - let(:scopes) { 'write:filters' } - let(:filter) { Fabricate(:custom_filter, account: user.account) } + let(:scopes) { 'write:filters' } + let(:filter) { Fabricate(:custom_filter, account: user.account) } + let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) } before do - delete :destroy, params: { id: filter.id } + delete :destroy, params: { id: keyword.id } end it 'returns http success' do @@ -81,7 +84,7 @@ RSpec.describe Api::V1::FiltersController, type: :controller do end it 'removes the filter' do - expect { filter.reload }.to raise_error ActiveRecord::RecordNotFound + expect { keyword.reload }.to raise_error ActiveRecord::RecordNotFound end end end diff --git a/spec/controllers/api/v1/statuses_controller_spec.rb b/spec/controllers/api/v1/statuses_controller_spec.rb index 2eb30af74..4d104a198 100644 --- a/spec/controllers/api/v1/statuses_controller_spec.rb +++ b/spec/controllers/api/v1/statuses_controller_spec.rb @@ -20,6 +20,58 @@ RSpec.describe Api::V1::StatusesController, type: :controller do get :show, params: { id: status.id } expect(response).to have_http_status(200) end + + context 'when post includes filtered terms' do + let(:status) { Fabricate(:status, text: 'this toot is about that banned word') } + + before do + user.account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }]) + end + + it 'returns http success' do + get :show, params: { id: status.id } + expect(response).to have_http_status(200) + end + + it 'returns filter information' do + get :show, params: { id: status.id } + json = body_as_json + expect(json[:filtered][0]).to include({ + filter: a_hash_including({ + id: user.account.custom_filters.first.id.to_s, + title: 'filter1', + filter_action: 'hide', + }), + keyword_matches: ['banned'], + }) + end + end + + context 'when reblog includes filtered terms' do + let(:status) { Fabricate(:status, reblog: Fabricate(:status, text: 'this toot is about that banned word')) } + + before do + user.account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }]) + end + + it 'returns http success' do + get :show, params: { id: status.id } + expect(response).to have_http_status(200) + end + + it 'returns filter information' do + get :show, params: { id: status.id } + json = body_as_json + expect(json[:reblog][:filtered][0]).to include({ + filter: a_hash_including({ + id: user.account.custom_filters.first.id.to_s, + title: 'filter1', + filter_action: 'hide', + }), + keyword_matches: ['banned'], + }) + end + end end describe 'GET #context' do diff --git a/spec/controllers/api/v2/filters_controller_spec.rb b/spec/controllers/api/v2/filters_controller_spec.rb new file mode 100644 index 000000000..cc0070d57 --- /dev/null +++ b/spec/controllers/api/v2/filters_controller_spec.rb @@ -0,0 +1,121 @@ +require 'rails_helper' + +RSpec.describe Api::V2::FiltersController, type: :controller do + render_views + + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'GET #index' do + let(:scopes) { 'read:filters' } + let!(:filter) { Fabricate(:custom_filter, account: user.account) } + + it 'returns http success' do + get :index + expect(response).to have_http_status(200) + end + end + + describe 'POST #create' do + let(:scopes) { 'write:filters' } + + before do + post :create, params: { title: 'magic', context: %w(home), filter_action: 'hide', keywords_attributes: [keyword: 'magic'] } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'returns a filter with keywords' do + json = body_as_json + expect(json[:title]).to eq 'magic' + expect(json[:filter_action]).to eq 'hide' + expect(json[:context]).to eq ['home'] + expect(json[:keywords].map { |keyword| keyword.slice(:keyword, :whole_word) }).to eq [{ keyword: 'magic', whole_word: true }] + end + + it 'creates a filter' do + filter = user.account.custom_filters.first + expect(filter).to_not be_nil + expect(filter.keywords.pluck(:keyword)).to eq ['magic'] + expect(filter.context).to eq %w(home) + expect(filter.irreversible?).to be true + expect(filter.expires_at).to be_nil + end + end + + describe 'GET #show' do + let(:scopes) { 'read:filters' } + let(:filter) { Fabricate(:custom_filter, account: user.account) } + + it 'returns http success' do + get :show, params: { id: filter.id } + expect(response).to have_http_status(200) + end + end + + describe 'PUT #update' do + let(:scopes) { 'write:filters' } + let!(:filter) { Fabricate(:custom_filter, account: user.account) } + let!(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) } + + context 'updating filter parameters' do + before do + put :update, params: { id: filter.id, title: 'updated', context: %w(home public) } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'updates the filter title' do + expect(filter.reload.title).to eq 'updated' + end + + it 'updates the filter context' do + expect(filter.reload.context).to eq %w(home public) + end + end + + context 'updating keywords in bulk' do + before do + allow(redis).to receive_messages(publish: nil) + put :update, params: { id: filter.id, keywords_attributes: [{ id: keyword.id, keyword: 'updated' }] } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'updates the keyword' do + expect(keyword.reload.keyword).to eq 'updated' + end + + it 'sends exactly one filters_changed event' do + expect(redis).to have_received(:publish).with("timeline:#{user.account.id}", Oj.dump(event: :filters_changed)).once + end + end + end + + describe 'DELETE #destroy' do + let(:scopes) { 'write:filters' } + let(:filter) { Fabricate(:custom_filter, account: user.account) } + + before do + delete :destroy, params: { id: filter.id } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'removes the filter' do + expect { filter.reload }.to raise_error ActiveRecord::RecordNotFound + end + end +end diff --git a/spec/fabricators/custom_filter_keyword_fabricator.rb b/spec/fabricators/custom_filter_keyword_fabricator.rb new file mode 100644 index 000000000..0f101dcd1 --- /dev/null +++ b/spec/fabricators/custom_filter_keyword_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:custom_filter_keyword) do + custom_filter + keyword 'discourse' +end diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index 3ba8aaa9f..48c57b86e 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -127,38 +127,6 @@ RSpec.describe FeedManager do reblog = Fabricate(:status, reblog: status, account: jeff) expect(FeedManager.instance.filter?(:home, reblog, alice)).to be true end - - context 'for irreversibly muted phrases' do - it 'considers word boundaries when matching' do - alice.custom_filters.create!(phrase: 'bob', context: %w(home), irreversible: true) - alice.follow!(jeff) - status = Fabricate(:status, text: 'bobcats', account: jeff) - expect(FeedManager.instance.filter?(:home, status, alice)).to be_falsy - end - - it 'returns true if phrase is contained' do - alice.custom_filters.create!(phrase: 'farts', context: %w(home public), irreversible: true) - alice.custom_filters.create!(phrase: 'pop tarts', context: %w(home), irreversible: true) - alice.follow!(jeff) - status = Fabricate(:status, text: 'i sure like POP TARts', account: jeff) - expect(FeedManager.instance.filter?(:home, status, alice)).to be true - end - - it 'matches substrings if whole_word is false' do - alice.custom_filters.create!(phrase: 'take', context: %w(home), whole_word: false, irreversible: true) - alice.follow!(jeff) - status = Fabricate(:status, text: 'shiitake', account: jeff) - expect(FeedManager.instance.filter?(:home, status, alice)).to be true - end - - it 'returns true if phrase is contained in a poll option' do - alice.custom_filters.create!(phrase: 'farts', context: %w(home public), irreversible: true) - alice.custom_filters.create!(phrase: 'pop tarts', context: %w(home), irreversible: true) - alice.follow!(jeff) - status = Fabricate(:status, text: 'what do you prefer', poll: Fabricate(:poll, options: %w(farts POP TARts)), account: jeff) - expect(FeedManager.instance.filter?(:home, status, alice)).to be true - end - end end context 'for mentions feed' do diff --git a/spec/models/custom_filter_keyword_spec.rb b/spec/models/custom_filter_keyword_spec.rb new file mode 100644 index 000000000..e15b9dad5 --- /dev/null +++ b/spec/models/custom_filter_keyword_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' + +RSpec.describe CustomFilterKeyword, type: :model do +end diff --git a/spec/presenters/status_relationships_presenter_spec.rb b/spec/presenters/status_relationships_presenter_spec.rb index 03296bd17..5cd4929a6 100644 --- a/spec/presenters/status_relationships_presenter_spec.rb +++ b/spec/presenters/status_relationships_presenter_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' RSpec.describe StatusRelationshipsPresenter do describe '.initialize' do before do - allow(Status).to receive(:reblogs_map).with(status_ids, current_account_id).and_return(default_map) + allow(Status).to receive(:reblogs_map).with(match_array(status_ids), current_account_id).and_return(default_map) allow(Status).to receive(:favourites_map).with(status_ids, current_account_id).and_return(default_map) allow(Status).to receive(:bookmarks_map).with(status_ids, current_account_id).and_return(default_map) allow(Status).to receive(:mutes_map).with(anything, current_account_id).and_return(default_map) @@ -15,7 +15,7 @@ RSpec.describe StatusRelationshipsPresenter do let(:presenter) { StatusRelationshipsPresenter.new(statuses, current_account_id, **options) } let(:current_account_id) { Fabricate(:account).id } let(:statuses) { [Fabricate(:status)] } - let(:status_ids) { statuses.map(&:id) } + let(:status_ids) { statuses.map(&:id) + statuses.map(&:reblog_of_id).compact } let(:default_map) { { 1 => true } } context 'options are not set' do @@ -69,5 +69,30 @@ RSpec.describe StatusRelationshipsPresenter do expect(presenter.pins_map).to eq default_map.merge(options[:pins_map]) end end + + context 'when post includes filtered terms' do + let(:statuses) { [Fabricate(:status, text: 'this toot is about that banned word'), Fabricate(:status, reblog: Fabricate(:status, text: 'this toot is about an irrelevant word'))] } + let(:options) { {} } + + before do + Account.find(current_account_id).custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }]) + end + + it 'sets @filters_map to filter top-level status' do + matched_filters = presenter.filters_map[statuses[0].id] + expect(matched_filters.size).to eq 1 + + expect(matched_filters[0].filter.title).to eq 'filter1' + expect(matched_filters[0].keyword_matches).to eq ['banned'] + end + + it 'sets @filters_map to filter reblogged status' do + matched_filters = presenter.filters_map[statuses[1].reblog_of_id] + expect(matched_filters.size).to eq 1 + + expect(matched_filters[0].filter.title).to eq 'filter1' + expect(matched_filters[0].keyword_matches).to eq ['irrelevant'] + end + end end end diff --git a/streaming/index.js b/streaming/index.js index 6935c4764..792ec5a44 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -12,6 +12,7 @@ const url = require('url'); const uuid = require('uuid'); const fs = require('fs'); const WebSocket = require('ws'); +const { JSDOM } = require('jsdom'); const env = process.env.NODE_ENV || 'development'; const alwaysRequireAuth = process.env.LIMITED_FEDERATION_MODE === 'true' || process.env.WHITELIST_MODE === 'true' || process.env.AUTHORIZED_FETCH === 'true'; @@ -503,6 +504,9 @@ const startWorker = async (workerId) => { if (event === 'kill') { log.verbose(req.requestId, `Closing connection for ${req.accountId} due to expired access token`); eventHandlers.onKill(); + } else if (event === 'filters_changed') { + log.verbose(req.requestId, `Invalidating filters cache for ${req.accountId}`); + req.cachedFilters = null; } }; }; @@ -512,7 +516,8 @@ const startWorker = async (workerId) => { * @param {any} res */ const subscribeHttpToSystemChannel = (req, res) => { - const systemChannelId = `timeline:access_token:${req.accessTokenId}`; + const accessTokenChannelId = `timeline:access_token:${req.accessTokenId}`; + const systemChannelId = `timeline:system:${req.accountId}`; const listener = createSystemMessageListener(req, { @@ -523,9 +528,11 @@ const startWorker = async (workerId) => { }); res.on('close', () => { + unsubscribe(`${redisPrefix}${accessTokenChannelId}`, listener); unsubscribe(`${redisPrefix}${systemChannelId}`, listener); }); + subscribe(`${redisPrefix}${accessTokenChannelId}`, listener); subscribe(`${redisPrefix}${systemChannelId}`, listener); }; @@ -674,17 +681,84 @@ const startWorker = async (workerId) => { queries.push(client.query('SELECT 1 FROM account_domain_blocks WHERE account_id = $1 AND domain = $2', [req.accountId, accountDomain])); } + if (!unpackedPayload.filter_results && !req.cachedFilters) { + queries.push(client.query('SELECT filter.id AS id, filter.phrase AS title, filter.context AS context, filter.expires_at AS expires_at, filter.action AS filter_action, keyword.keyword AS keyword, keyword.whole_word AS whole_word FROM custom_filter_keywords keyword JOIN custom_filters filter ON keyword.custom_filter_id = filter.id WHERE filter.account_id = $1 AND filter.expires_at IS NULL OR filter.expires_at > NOW()', [req.accountId])); + } + Promise.all(queries).then(values => { done(); - if (values[0].rows.length > 0 || (values.length > 1 && values[1].rows.length > 0)) { + if (values[0].rows.length > 0 || (accountDomain && values[1].rows.length > 0)) { return; } + if (!unpackedPayload.filter_results && !req.cachedFilters) { + const filterRows = values[accountDomain ? 2 : 1].rows; + + req.cachedFilters = filterRows.reduce((cache, row) => { + if (cache[row.id]) { + cache[row.id].keywords.push([row.keyword, row.whole_word]); + } else { + cache[row.id] = { + keywords: [[row.keyword, row.whole_word]], + expires_at: row.expires_at, + repr: { + id: row.id, + title: row.title, + context: row.context, + expires_at: row.expires_at, + filter_action: row.filter_action, + }, + }; + } + + return cache; + }, {}); + + Object.keys(req.cachedFilters).forEach((key) => { + req.cachedFilters[key].regexp = new RegExp(req.cachedFilters[key].keywords.map(([keyword, whole_word]) => { + let expr = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');; + + if (whole_word) { + if (/^[\w]/.test(expr)) { + expr = `\\b${expr}`; + } + + if (/[\w]$/.test(expr)) { + expr = `${expr}\\b`; + } + } + + return expr; + }).join('|'), 'i'); + }); + } + + // Check filters + if (req.cachedFilters && !unpackedPayload.filter_results) { + const status = unpackedPayload; + const searchContent = ([status.spoiler_text || '', status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); + const searchIndex = JSDOM.fragment(searchContent).textContent; + + const now = new Date(); + payload.filter_results = []; + Object.values(req.cachedFilters).forEach((cachedFilter) => { + if ((cachedFilter.expires_at === null || cachedFilter.expires_at > now)) { + const keyword_matches = searchIndex.match(cachedFilter.regexp); + if (keyword_matches) { + payload.filter_results.push({ + filter: cachedFilter.repr, + keyword_matches, + }); + } + } + }); + } + transmit(); }).catch(err => { - done(); log.error(err); + done(); }); }); }; @@ -1009,7 +1083,8 @@ const startWorker = async (workerId) => { * @param {WebSocketSession} session */ const subscribeWebsocketToSystemChannel = ({ socket, request, subscriptions }) => { - const systemChannelId = `timeline:access_token:${request.accessTokenId}`; + const accessTokenChannelId = `timeline:access_token:${request.accessTokenId}`; + const systemChannelId = `timeline:system:${request.accountId}`; const listener = createSystemMessageListener(request, { @@ -1019,8 +1094,15 @@ const startWorker = async (workerId) => { }); + subscribe(`${redisPrefix}${accessTokenChannelId}`, listener); subscribe(`${redisPrefix}${systemChannelId}`, listener); + subscriptions[accessTokenChannelId] = { + listener, + stopHeartbeat: () => { + }, + }; + subscriptions[systemChannelId] = { listener, stopHeartbeat: () => { diff --git a/yarn.lock b/yarn.lock index 42d877453..7901b9fd5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1486,7 +1486,7 @@ "@types/yargs" "^16.0.0" chalk "^4.0.0" -"@jest/types@^28.1.0", "@jest/types@^28.1.1": +"@jest/types@^28.1.1": version "28.1.1" resolved "https://registry.yarnpkg.com/@jest/types/-/types-28.1.1.tgz#d059bbc80e6da6eda9f081f293299348bd78ee0b" integrity sha512-vRXVqSg1VhDnB8bWcmvLzmg0Bt9CRKVgHPXqYwvWMX3TvAjeO+nRuK6+VdTKCtWOvYlmkF/HqNAL/z+N3B53Kw== @@ -2153,7 +2153,7 @@ acorn@^8.0.4: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.3.0.tgz#1193f9b96c4e8232f00b11a9edff81b2c8b98b88" integrity sha512-tqPKHZ5CaBJw0Xmy0ZZvLs1qTV+BNFSyvn77ASXkpBNfIRk8ev26fKrD9iLGwGA9zedPao52GSHzq8lyZG0NUw== -acorn@^8.5.0: +acorn@^8.5.0, acorn@^8.7.1: version "8.7.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== @@ -3304,6 +3304,11 @@ coa@^2.0.2: chalk "^2.4.1" q "^1.1.2" +cocoon-js-vanilla@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/cocoon-js-vanilla/-/cocoon-js-vanilla-1.2.0.tgz#595348499d315d3b5828dd77a20974756cf59321" + integrity sha512-qLomIVL0Krfc983WLgaYPPktMjMtBN+F/CV15NPVDc9U9BCe2OL5WyAIYkPrVhDRphoYBmHCdIlZkq+vSBI4xg== + collect-v8-coverage@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59" @@ -3892,7 +3897,7 @@ damerau-levenshtein@^1.0.7: resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.7.tgz#64368003512a1a6992593741a09a9d31a836f55d" integrity sha512-VvdQIPGdWP0SqFXghj79Wf/5LArmreyMsGLa6FG6iC4t3j7j5s71TrwWmT/4akbDQIqjfACkLZmjXhA7g2oUZw== -data-urls@^3.0.1: +data-urls@^3.0.1, data-urls@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143" integrity sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ== @@ -4338,6 +4343,11 @@ entities@^2.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f" integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ== +entities@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.3.0.tgz#62915f08d67353bb4eb67e3d62641a4059aec656" + integrity sha512-/iP1rZrSEJ0DTlPiX+jbzlA3eVkY/e8L8SozroF395fIqE3TYF/Nz7YOMAawta+vLmyJ/hkGNNPcSbMADCCXbg== + errno@^0.1.3, errno@~0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618" @@ -5700,7 +5710,7 @@ https-browserify@^1.0.0: resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= -https-proxy-agent@^5.0.0: +https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== @@ -6728,7 +6738,7 @@ jest-snapshot@^28.1.1: pretty-format "^28.1.1" semver "^7.3.5" -jest-util@^28.1.0, jest-util@^28.1.1: +jest-util@^28.1.1: version "28.1.1" resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-28.1.1.tgz#ff39e436a1aca397c0ab998db5a51ae2b7080d05" integrity sha512-FktOu7ca1DZSyhPAxgxB6hfh2+9zMoJ7aEQA759Z6p45NuO8mWcqujH+UdHlCm/V6JTWwDztM2ITCzU1ijJAfw== @@ -6852,6 +6862,39 @@ jsdom@^19.0.0: ws "^8.2.3" xml-name-validator "^4.0.0" +jsdom@^20.0.0: + version "20.0.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-20.0.0.tgz#882825ac9cc5e5bbee704ba16143e1fa78361ebf" + integrity sha512-x4a6CKCgx00uCmP+QakBDFXwjAJ69IkkIWHmtmjd3wvXPcdOS44hfX2vqkOQrVrq8l9DhNNADZRXaCEWvgXtVA== + dependencies: + abab "^2.0.6" + acorn "^8.7.1" + acorn-globals "^6.0.0" + cssom "^0.5.0" + cssstyle "^2.3.0" + data-urls "^3.0.2" + decimal.js "^10.3.1" + domexception "^4.0.0" + escodegen "^2.0.0" + form-data "^4.0.0" + html-encoding-sniffer "^3.0.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.1" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.0" + parse5 "^7.0.0" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^4.0.0" + w3c-hr-time "^1.0.2" + w3c-xmlserializer "^3.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^2.0.0" + whatwg-mimetype "^3.0.0" + whatwg-url "^11.0.0" + ws "^8.8.0" + xml-name-validator "^4.0.0" + jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -8141,6 +8184,13 @@ parse5@6.0.1: resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== +parse5@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.0.0.tgz#51f74a5257f5fcc536389e8c2d0b3802e1bfa91a" + integrity sha512-y/t8IXSPWTuRZqXc0ajH/UwDj4mnqLEbSttNbThcFhGrZuOyoyvNBO85PBp2jQa55wY9d07PBNjsK8ZP3K5U6g== + dependencies: + entities "^4.3.0" + parseurl@~1.3.2, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -9771,6 +9821,13 @@ saxes@^5.0.1: dependencies: xmlchars "^2.2.0" +saxes@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== + dependencies: + xmlchars "^2.2.0" + scheduler@^0.19.1: version "0.19.1" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" -- cgit