From 89c77fe225a7550e19d0631ce2172fd2b49a605a Mon Sep 17 00:00:00 2001 From: ThibG Date: Fri, 27 Oct 2017 19:08:30 +0200 Subject: Instantiate service classes for each call (fixes #5540) (#5543) --- app/services/post_status_service.rb | 4 ++-- app/services/process_mentions_service.rb | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) (limited to 'app/services') diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index e37cd94df..de350f8e6 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -70,11 +70,11 @@ class PostStatusService < BaseService end def process_mentions_service - @process_mentions_service ||= ProcessMentionsService.new + ProcessMentionsService.new end def process_hashtags_service - @process_hashtags_service ||= ProcessHashtagsService.new + ProcessHashtagsService.new end def redis diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 1c3eea369..1fd2ece1c 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -16,7 +16,7 @@ class ProcessMentionsService < BaseService if mentioned_account.nil? && !domain.nil? begin - mentioned_account = follow_remote_account_service.call(match.first.to_s) + mentioned_account = resolve_remote_account_service.call(match.first.to_s) rescue Goldfinger::Error, HTTP::Error mentioned_account = nil end @@ -54,7 +54,7 @@ class ProcessMentionsService < BaseService ).as_json).sign!(status.account)) end - def follow_remote_account_service - @follow_remote_account_service ||= ResolveRemoteAccountService.new + def resolve_remote_account_service + ResolveRemoteAccountService.new end end -- cgit From d37a56c07cdc475f9a0f5a87d65d1e258c48644b Mon Sep 17 00:00:00 2001 From: ThibG Date: Sun, 29 Oct 2017 16:24:16 +0100 Subject: Update remote ActivityPub users when fetching their toots (#5545) --- app/services/activitypub/fetch_remote_status_service.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'app/services') diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb index e2a89a87c..8d7b7a17c 100644 --- a/app/services/activitypub/fetch_remote_status_service.rb +++ b/app/services/activitypub/fetch_remote_status_service.rb @@ -16,7 +16,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService return if actor_id.nil? || !trustworthy_attribution?(@json['id'], actor_id) actor = ActivityPub::TagManager.instance.uri_to_resource(actor_id, Account) - actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id, id: true) if actor.nil? + actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id, id: true) if actor.nil? || needs_update(actor) return if actor.suspended? @@ -44,4 +44,8 @@ class ActivityPub::FetchRemoteStatusService < BaseService def expected_type? %w(Note Article).include? @json['type'] end + + def needs_update(actor) + actor.possibly_stale? + end end -- cgit From 7bea1530f4b396ae384502b3fcbf8d34f22005e1 Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 7 Nov 2017 14:31:57 +0100 Subject: Resolve remote accounts when mentioned even if they are already known (#5539) This commit reduces the risk of not having up-to-date public key or protocol information for a remote account, which is required to deliver toots (especially direct messages). --- app/services/process_mentions_service.rb | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) (limited to 'app/services') diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 1fd2ece1c..17c01a91d 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -11,15 +11,10 @@ class ProcessMentionsService < BaseService return unless status.local? status.text.scan(Account::MENTION_RE).each do |match| - username, domain = match.first.split('@') - mentioned_account = Account.find_remote(username, domain) - - if mentioned_account.nil? && !domain.nil? - begin - mentioned_account = resolve_remote_account_service.call(match.first.to_s) - rescue Goldfinger::Error, HTTP::Error - mentioned_account = nil - end + begin + mentioned_account = resolve_remote_account_service.call(match.first.to_s) + rescue Goldfinger::Error, HTTP::Error + mentioned_account = nil end next if mentioned_account.nil? -- cgit From 84cfee2488ed0d795b69ffe51e7260548c2d6af3 Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 7 Nov 2017 14:47:39 +0100 Subject: Do not process undeliverable mentions (#5598) * Resolve remote accounts when mentioned even if they are already known This commit reduces the risk of not having up-to-date public key or protocol information for a remote account, which is required to deliver toots (especially direct messages). * Do not add mentions in private messages for remote users we cannot deliver to Mastodon does not deliver private and direct toots to OStatus users, as there is no guarantee the remote software understands the toot's privacy. However, users currently do not get any feedback on it (Mastodon won't attempt delivery, but the toot will be displayed exactly the same way to the user). This change introduces *some* feedback by not processing mentions that are not going to be delivered. A long-term solution is still needed to have delivery receipts or at least some better indication of what is going on, but at least an user can see *something* is up. --- app/services/process_mentions_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app/services') diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 17c01a91d..aa649652c 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -17,7 +17,7 @@ class ProcessMentionsService < BaseService mentioned_account = nil end - next if mentioned_account.nil? + next if mentioned_account.nil? || (mentioned_account.ostatus? && status.stream_entry.hidden?) mentioned_account.mentions.where(status: status).first_or_create(status: status) end -- cgit From 1032f3994fdbd61c2f517057261ddc3559199b6b Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 7 Nov 2017 19:06:44 +0100 Subject: Add ability to disable login and mark accounts as memorial (#5615) Fix #5597 --- app/controllers/admin/accounts_controller.rb | 22 ++++++++++++++- app/controllers/admin/suspensions_controller.rb | 2 +- app/javascript/styles/mastodon/landing_strip.scss | 7 ++++- app/mailers/notification_mailer.rb | 18 ++++++++---- app/mailers/user_mailer.rb | 6 ++++ app/models/account.rb | 15 ++++++++++ app/models/user.rb | 18 +++++++++++- app/services/suspend_account_service.rb | 25 +++++++++------- app/views/accounts/_header.html.haml | 33 +++++++++++----------- app/views/accounts/show.html.haml | 4 ++- app/views/admin/accounts/show.html.haml | 11 ++++++++ app/workers/admin/suspension_worker.rb | 2 +- config/locales/en.yml | 7 +++++ config/routes.rb | 3 ++ .../20171107143332_add_memorial_to_accounts.rb | 15 ++++++++++ db/migrate/20171107143624_add_disabled_to_users.rb | 15 ++++++++++ db/schema.rb | 4 ++- 17 files changed, 168 insertions(+), 39 deletions(-) create mode 100644 db/migrate/20171107143332_add_memorial_to_accounts.rb create mode 100644 db/migrate/20171107143624_add_disabled_to_users.rb (limited to 'app/services') diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index ffa4dc850..7503b880d 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -2,8 +2,9 @@ module Admin class AccountsController < BaseController - before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload] + before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :enable, :disable, :memorialize] before_action :require_remote_account!, only: [:subscribe, :unsubscribe, :redownload] + before_action :require_local_account!, only: [:enable, :disable, :memorialize] def index @accounts = filtered_accounts.page(params[:page]) @@ -24,6 +25,21 @@ module Admin redirect_to admin_account_path(@account.id) end + def memorialize + @account.memorialize! + redirect_to admin_account_path(@account.id) + end + + def enable + @account.user.enable! + redirect_to admin_account_path(@account.id) + end + + def disable + @account.user.disable! + redirect_to admin_account_path(@account.id) + end + def redownload @account.reset_avatar! @account.reset_header! @@ -42,6 +58,10 @@ module Admin redirect_to admin_account_path(@account.id) if @account.local? end + def require_local_account! + redirect_to admin_account_path(@account.id) unless @account.local? && @account.user.present? + end + def filtered_accounts AccountFilter.new(filter_params).results end diff --git a/app/controllers/admin/suspensions_controller.rb b/app/controllers/admin/suspensions_controller.rb index 5d9048d94..5eaf1a2e9 100644 --- a/app/controllers/admin/suspensions_controller.rb +++ b/app/controllers/admin/suspensions_controller.rb @@ -10,7 +10,7 @@ module Admin end def destroy - @account.update(suspended: false) + @account.unsuspend! redirect_to admin_accounts_path end diff --git a/app/javascript/styles/mastodon/landing_strip.scss b/app/javascript/styles/mastodon/landing_strip.scss index 15ff84912..0bf9daafd 100644 --- a/app/javascript/styles/mastodon/landing_strip.scss +++ b/app/javascript/styles/mastodon/landing_strip.scss @@ -1,4 +1,5 @@ -.landing-strip { +.landing-strip, +.memoriam-strip { background: rgba(darken($ui-base-color, 7%), 0.8); color: $ui-primary-color; font-weight: 400; @@ -29,3 +30,7 @@ margin-bottom: 0; } } + +.memoriam-strip { + background: rgba($base-shadow-color, 0.7); +} diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 80c9d8ccf..d79f26366 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -7,6 +7,8 @@ class NotificationMailer < ApplicationMailer @me = recipient @status = notification.target_status + return if @me.user.disabled? + locale_for_account(@me) do thread_by_conversation(@status.conversation) mail to: @me.user.email, subject: I18n.t('notification_mailer.mention.subject', name: @status.account.acct) @@ -17,6 +19,8 @@ class NotificationMailer < ApplicationMailer @me = recipient @account = notification.from_account + return if @me.user.disabled? + locale_for_account(@me) do mail to: @me.user.email, subject: I18n.t('notification_mailer.follow.subject', name: @account.acct) end @@ -27,6 +31,8 @@ class NotificationMailer < ApplicationMailer @account = notification.from_account @status = notification.target_status + return if @me.user.disabled? + locale_for_account(@me) do thread_by_conversation(@status.conversation) mail to: @me.user.email, subject: I18n.t('notification_mailer.favourite.subject', name: @account.acct) @@ -38,6 +44,8 @@ class NotificationMailer < ApplicationMailer @account = notification.from_account @status = notification.target_status + return if @me.user.disabled? + locale_for_account(@me) do thread_by_conversation(@status.conversation) mail to: @me.user.email, subject: I18n.t('notification_mailer.reblog.subject', name: @account.acct) @@ -48,6 +56,8 @@ class NotificationMailer < ApplicationMailer @me = recipient @account = notification.from_account + return if @me.user.disabled? + locale_for_account(@me) do mail to: @me.user.email, subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct) end @@ -59,15 +69,11 @@ class NotificationMailer < ApplicationMailer @notifications = Notification.where(account: @me, activity_type: 'Mention').where('created_at > ?', @since) @follows_since = Notification.where(account: @me, activity_type: 'Follow').where('created_at > ?', @since).count - return if @notifications.empty? + return if @me.user.disabled? || @notifications.empty? locale_for_account(@me) do mail to: @me.user.email, - subject: I18n.t( - :subject, - scope: [:notification_mailer, :digest], - count: @notifications.size - ) + subject: I18n.t(:subject, scope: [:notification_mailer, :digest], count: @notifications.size) end end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index c475a9911..bdb29ebad 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -10,6 +10,8 @@ class UserMailer < Devise::Mailer @token = token @instance = Rails.configuration.x.local_domain + return if @resource.disabled? + I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.unconfirmed_email.blank? ? @resource.email : @resource.unconfirmed_email, subject: I18n.t('devise.mailer.confirmation_instructions.subject', instance: @instance) end @@ -20,6 +22,8 @@ class UserMailer < Devise::Mailer @token = token @instance = Rails.configuration.x.local_domain + return if @resource.disabled? + I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.reset_password_instructions.subject') end @@ -29,6 +33,8 @@ class UserMailer < Devise::Mailer @resource = user @instance = Rails.configuration.x.local_domain + return if @resource.disabled? + I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.password_change.subject') end diff --git a/app/models/account.rb b/app/models/account.rb index 3dc2a95ab..1142e7c79 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -41,6 +41,7 @@ # shared_inbox_url :string default(""), not null # followers_url :string default(""), not null # protocol :integer default("ostatus"), not null +# memorial :boolean default(FALSE), not null # class Account < ApplicationRecord @@ -150,6 +151,20 @@ class Account < ApplicationRecord ResolveRemoteAccountService.new.call(acct) end + def unsuspend! + transaction do + user&.enable! if local? + update!(suspended: false) + end + end + + def memorialize! + transaction do + user&.disable! if local? + update!(memorial: true) + end + end + def keypair @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key) end diff --git a/app/models/user.rb b/app/models/user.rb index 325e27f44..836d54d15 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -5,7 +5,6 @@ # # id :integer not null, primary key # email :string default(""), not null -# account_id :integer not null # created_at :datetime not null # updated_at :datetime not null # encrypted_password :string default(""), not null @@ -31,10 +30,13 @@ # last_emailed_at :datetime # otp_backup_codes :string is an Array # filtered_languages :string default([]), not null, is an Array +# account_id :integer not null +# disabled :boolean default(FALSE), not null # class User < ApplicationRecord include Settings::Extend + ACTIVE_DURATION = 14.days devise :registerable, :recoverable, @@ -72,12 +74,26 @@ class User < ApplicationRecord confirmed_at.present? end + def disable! + update!(disabled: true, + last_sign_in_at: current_sign_in_at, + current_sign_in_at: nil) + end + + def enable! + update!(disabled: false) + end + def disable_two_factor! self.otp_required_for_login = false otp_backup_codes&.clear save! end + def active_for_authentication? + super && !disabled? + end + def setting_default_privacy settings.default_privacy || (account.locked? ? 'private' : 'public') end diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index 983c5495b..5b37ba9ba 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -1,22 +1,27 @@ # frozen_string_literal: true class SuspendAccountService < BaseService - def call(account, remove_user = false) + def call(account, options = {}) @account = account + @options = options - purge_user if remove_user - purge_profile - purge_content - unsubscribe_push_subscribers + purge_user! + purge_profile! + purge_content! + unsubscribe_push_subscribers! end private - def purge_user - @account.user.destroy + def purge_user! + if @options[:remove_user] + @account.user&.destroy + else + @account.user&.disable! + end end - def purge_content + def purge_content! @account.statuses.reorder(nil).find_in_batches do |statuses| BatchedRemoveStatusService.new.call(statuses) end @@ -33,7 +38,7 @@ class SuspendAccountService < BaseService end end - def purge_profile + def purge_profile! @account.suspended = true @account.display_name = '' @account.note = '' @@ -42,7 +47,7 @@ class SuspendAccountService < BaseService @account.save! end - def unsubscribe_push_subscribers + def unsubscribe_push_subscribers! destroy_all(@account.subscriptions) end diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml index 08c3891d2..5530fcc20 100644 --- a/app/views/accounts/_header.html.haml +++ b/app/views/accounts/_header.html.haml @@ -1,21 +1,22 @@ .card.h-card.p-author{ style: "background-image: url(#{account.header.url(:original)})" } .card__illustration - - if user_signed_in? && current_account.id != account.id && !current_account.requested?(account) - .controls - - if current_account.following?(account) - = link_to account_unfollow_path(account), data: { method: :post }, class: 'icon-button' do - = fa_icon 'user-times' - = t('accounts.unfollow') - - else - = link_to account_follow_path(account), data: { method: :post }, class: 'icon-button' do - = fa_icon 'user-plus' - = t('accounts.follow') - - elsif !user_signed_in? - .controls - .remote-follow - = link_to account_remote_follow_path(account), class: 'icon-button' do - = fa_icon 'user-plus' - = t('accounts.remote_follow') + - unless account.memorial? + - if user_signed_in? && current_account.id != account.id && !current_account.requested?(account) + .controls + - if current_account.following?(account) + = link_to account_unfollow_path(account), data: { method: :post }, class: 'icon-button' do + = fa_icon 'user-times' + = t('accounts.unfollow') + - else + = link_to account_follow_path(account), data: { method: :post }, class: 'icon-button' do + = fa_icon 'user-plus' + = t('accounts.follow') + - elsif !user_signed_in? + .controls + .remote-follow + = link_to account_remote_follow_path(account), class: 'icon-button' do + = fa_icon 'user-plus' + = t('accounts.remote_follow') .avatar= image_tag account.avatar.url(:original), class: 'u-photo' diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml index 6c90b2c04..fd8ad5530 100644 --- a/app/views/accounts/show.html.haml +++ b/app/views/accounts/show.html.haml @@ -12,7 +12,9 @@ = opengraph 'og:type', 'profile' = render 'og', account: @account, url: short_account_url(@account, only_path: false) -- if show_landing_strip? +- if @account.memorial? + .memoriam-strip= t('in_memoriam_html') +- elsif show_landing_strip? = render partial: 'shared/landing_strip', locals: { account: @account } .h-feed diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index 1f5c8fcf5..b5ce56dbc 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -18,6 +18,15 @@ %tr %th= t('admin.accounts.email') %td= @account.user_email + %tr + %th= t('admin.accounts.login_status') + %td + - if @account.user&.disabled? + = t('admin.accounts.disabled') + = table_link_to 'unlock', t('admin.accounts.enable'), enable_admin_account_path(@account.id), method: :post + - else + = t('admin.accounts.enabled') + = table_link_to 'lock', t('admin.accounts.disable'), disable_admin_account_path(@account.id), method: :post %tr %th= t('admin.accounts.most_recent_ip') %td= @account.user_current_sign_in_ip @@ -65,6 +74,8 @@ = link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button' - if @account.user&.otp_required_for_login? = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button' + - unless @account.memorial? + = link_to t('admin.accounts.memorialize'), memorialize_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' - else = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button' diff --git a/app/workers/admin/suspension_worker.rb b/app/workers/admin/suspension_worker.rb index 6338b1130..e41465ccc 100644 --- a/app/workers/admin/suspension_worker.rb +++ b/app/workers/admin/suspension_worker.rb @@ -6,6 +6,6 @@ class Admin::SuspensionWorker sidekiq_options queue: 'pull' def perform(account_id, remove_user = false) - SuspendAccountService.new.call(Account.find(account_id), remove_user) + SuspendAccountService.new.call(Account.find(account_id), remove_user: remove_user) end end diff --git a/config/locales/en.yml b/config/locales/en.yml index ce439029c..41a636760 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -62,11 +62,15 @@ en: by_domain: Domain confirm: Confirm confirmed: Confirmed + disable: Disable disable_two_factor_authentication: Disable 2FA + disabled: Disabled display_name: Display name domain: Domain edit: Edit email: E-mail + enable: Enable + enabled: Enabled feed_url: Feed URL followers: Followers followers_url: Followers URL @@ -78,7 +82,9 @@ en: local: Local remote: Remote title: Location + login_status: Login status media_attachments: Media attachments + memorialize: Turn into memoriam moderation: all: All silenced: Silenced @@ -379,6 +385,7 @@ en: following: Following list muting: Muting list upload: Upload + in_memoriam_html: In Memoriam. landing_strip_html: "%{name} is a user on %{link_to_root_path}. You can follow them or interact with them if you have an account anywhere in the fediverse." landing_strip_signup_html: If you don't, you can sign up here. media_attachments: diff --git a/config/routes.rb b/config/routes.rb index bdfcdaff6..e6d6b52f7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -126,7 +126,10 @@ Rails.application.routes.draw do member do post :subscribe post :unsubscribe + post :enable + post :disable post :redownload + post :memorialize end resource :reset, only: [:create] diff --git a/db/migrate/20171107143332_add_memorial_to_accounts.rb b/db/migrate/20171107143332_add_memorial_to_accounts.rb new file mode 100644 index 000000000..f3e012ce8 --- /dev/null +++ b/db/migrate/20171107143332_add_memorial_to_accounts.rb @@ -0,0 +1,15 @@ +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddMemorialToAccounts < ActiveRecord::Migration[5.1] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def up + safety_assured { add_column_with_default :accounts, :memorial, :bool, default: false } + end + + def down + remove_column :accounts, :memorial + end +end diff --git a/db/migrate/20171107143624_add_disabled_to_users.rb b/db/migrate/20171107143624_add_disabled_to_users.rb new file mode 100644 index 000000000..a71cac1c6 --- /dev/null +++ b/db/migrate/20171107143624_add_disabled_to_users.rb @@ -0,0 +1,15 @@ +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddDisabledToUsers < ActiveRecord::Migration[5.1] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def up + safety_assured { add_column_with_default :users, :disabled, :bool, default: false } + end + + def down + remove_column :users, :disabled + end +end diff --git a/db/schema.rb b/db/schema.rb index 697a7f374..935fd79c5 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: 20171020084748) do +ActiveRecord::Schema.define(version: 20171107143624) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -71,6 +71,7 @@ ActiveRecord::Schema.define(version: 20171020084748) do t.string "shared_inbox_url", default: "", null: false t.string "followers_url", default: "", null: false t.integer "protocol", default: 0, null: false + t.boolean "memorial", default: false, null: false t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin t.index "lower((username)::text), lower((domain)::text)", name: "index_accounts_on_username_and_domain_lower" t.index ["uri"], name: "index_accounts_on_uri" @@ -435,6 +436,7 @@ ActiveRecord::Schema.define(version: 20171020084748) do t.string "otp_backup_codes", array: true t.string "filtered_languages", default: [], null: false, array: true t.bigint "account_id", null: false + t.boolean "disabled", default: false, null: false t.index ["account_id"], name: "index_users_on_account_id" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true -- cgit From 5d5c0f4f4358f4349d9e2db59cf90b1f5de24e81 Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 7 Nov 2017 19:08:14 +0100 Subject: Twidere mention workaround (#5552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Work around Twidere and Tootdon bug Tootdon and Twidere construct @user@domain handles from mentions in toots based solely on the mention text and account URI's domain without performing any webfinger call or retrieving account info from the Mastodon server. As a result, when a remote user has WEB_DOMAIN ≠ LOCAL_DOMAIN, Twidere and Tootdon will construct the mention as @user@WEB_DOMAIN. Now, this will usually resolve to the correct account (since the recommended configuration is to have WEB_DOMAIN perform webfinger redirections to LOCAL_DOMAIN) when processing mentions, but won't do so when displaying them (as it does not go through the whole account resolution at that time). This change rewrites mentions to the resolved account, so that displaying the mentions will work. * Use lookbehind instead of non-capturing group in MENTION_RE Indeed, substitutions with the previous regexp would erroneously eat any preceding whitespace, which would lead to concatenated mentions in the previous commit. Note that users will “lose” up to one character space per mention for their toots, as that regexp is also used to remove the domain-part of mentioned users for character counting purposes, and it also erroneously removed the preceding character if it was a space. --- app/javascript/mastodon/features/compose/util/counter.js | 2 +- app/models/account.rb | 2 +- app/services/process_mentions_service.rb | 9 ++++++--- 3 files changed, 8 insertions(+), 5 deletions(-) (limited to 'app/services') diff --git a/app/javascript/mastodon/features/compose/util/counter.js b/app/javascript/mastodon/features/compose/util/counter.js index e6d2487c5..700ba2163 100644 --- a/app/javascript/mastodon/features/compose/util/counter.js +++ b/app/javascript/mastodon/features/compose/util/counter.js @@ -5,5 +5,5 @@ const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx'; export function countableText(inputText) { return inputText .replace(urlRegex, urlPlaceholder) - .replace(/(?:^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/ig, '@$2'); + .replace(/(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/ig, '$1@$3'); }; diff --git a/app/models/account.rb b/app/models/account.rb index 1142e7c79..7e4d29f96 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -45,7 +45,7 @@ # class Account < ApplicationRecord - MENTION_RE = /(?:^|[^\/[:word:]])@(([a-z0-9_]+)(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i + MENTION_RE = /(?<=^|[^\/[:word:]])@(([a-z0-9_]+)(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i include AccountAvatar include AccountFinderConcern diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index aa649652c..65e6b1361 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -10,18 +10,21 @@ class ProcessMentionsService < BaseService def call(status) return unless status.local? - status.text.scan(Account::MENTION_RE).each do |match| + status.text = status.text.gsub(Account::MENTION_RE) do |match| begin - mentioned_account = resolve_remote_account_service.call(match.first.to_s) + mentioned_account = resolve_remote_account_service.call($1) rescue Goldfinger::Error, HTTP::Error mentioned_account = nil end - next if mentioned_account.nil? || (mentioned_account.ostatus? && status.stream_entry.hidden?) + next match if mentioned_account.nil? || (mentioned_account.ostatus? && status.stream_entry.hidden?) mentioned_account.mentions.where(status: status).first_or_create(status: status) + "@#{mentioned_account.acct}" end + status.save! + status.mentions.includes(:account).each do |mention| create_notification(status, mention) end -- cgit From 889ada5ee257f7cecce55bd2366161d0a673a4f8 Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 7 Nov 2017 22:15:15 +0100 Subject: Fix process mentions for local users, as local users are considered to use OStatus (#5618) --- app/services/process_mentions_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app/services') diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 65e6b1361..c1ff68209 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -17,7 +17,7 @@ class ProcessMentionsService < BaseService mentioned_account = nil end - next match if mentioned_account.nil? || (mentioned_account.ostatus? && status.stream_entry.hidden?) + next match if mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus? && status.stream_entry.hidden?) mentioned_account.mentions.where(status: status).first_or_create(status: status) "@#{mentioned_account.acct}" -- cgit From fbef909c2a1ff8d24811f76237e62fbef6cc63cc Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 14 Nov 2017 21:12:57 +0100 Subject: Add option to block direct messages from people you don't follow (#5669) * Add option to block direct messages from people you don't follow Fix #5326 * If the DM responds to a toot by recipient, allow it through * i18n: Update Polish translation (for #5669) (#5673) --- .../settings/notifications_controller.rb | 2 +- app/services/notify_service.rb | 59 ++++++++++++++++++---- app/views/settings/notifications/show.html.haml | 3 +- config/locales/simple_form.en.yml | 1 + config/locales/simple_form.pl.yml | 1 + config/settings.yml | 1 + spec/services/notify_service_spec.rb | 33 ++++++++++++ 7 files changed, 89 insertions(+), 11 deletions(-) (limited to 'app/services') diff --git a/app/controllers/settings/notifications_controller.rb b/app/controllers/settings/notifications_controller.rb index 09839f16e..ce2530c54 100644 --- a/app/controllers/settings/notifications_controller.rb +++ b/app/controllers/settings/notifications_controller.rb @@ -26,7 +26,7 @@ class Settings::NotificationsController < ApplicationController def user_settings_params params.require(:user).permit( notification_emails: %i(follow follow_request reblog favourite mention digest), - interactions: %i(must_be_follower must_be_following) + interactions: %i(must_be_follower must_be_following must_be_following_dm) ) end end diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index ca53c61c5..6a24a8247 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -36,17 +36,58 @@ class NotifyService < BaseService false end + def following_sender? + return @following_sender if defined?(@following_sender) + @following_sender = @recipient.following?(@notification.from_account) || @recipient.requested?(@notification.from_account) + end + + def optional_non_follower? + @recipient.user.settings.interactions['must_be_follower'] && !@notification.from_account.following?(@recipient) + end + + def optional_non_following? + @recipient.user.settings.interactions['must_be_following'] && !following_sender? + end + + def direct_message? + @notification.type == :mention && @notification.target_status.direct_visibility? + end + + def response_to_recipient? + @notification.target_status.in_reply_to_account_id == @recipient.id + end + + def optional_non_following_and_direct? + direct_message? && + @recipient.user.settings.interactions['must_be_following_dm'] && + !following_sender? && + !response_to_recipient? + end + + def hellbanned? + @notification.from_account.silenced? && !following_sender? + end + + def from_self? + @recipient.id == @notification.from_account.id + end + + def domain_blocking? + @recipient.domain_blocking?(@notification.from_account.domain) && !following_sender? + end + def blocked? - blocked = @recipient.suspended? # Skip if the recipient account is suspended anyway - blocked ||= @recipient.id == @notification.from_account.id # Skip for interactions with self - blocked ||= @recipient.domain_blocking?(@notification.from_account.domain) && !@recipient.following?(@notification.from_account) # Skip for domain blocked accounts - blocked ||= @recipient.blocking?(@notification.from_account) # Skip for blocked accounts - blocked ||= @recipient.muting?(@notification.from_account) # Skip for muted accounts - blocked ||= (@notification.from_account.silenced? && !@recipient.following?(@notification.from_account)) # Hellban - blocked ||= (@recipient.user.settings.interactions['must_be_follower'] && !@notification.from_account.following?(@recipient)) # Options - blocked ||= (@recipient.user.settings.interactions['must_be_following'] && !@recipient.following?(@notification.from_account)) # Options + blocked = @recipient.suspended? # Skip if the recipient account is suspended anyway + blocked ||= from_self? # Skip for interactions with self + blocked ||= domain_blocking? # Skip for domain blocked accounts + blocked ||= @recipient.blocking?(@notification.from_account) # Skip for blocked accounts + blocked ||= @recipient.muting?(@notification.from_account) # Skip for muted accounts + blocked ||= hellbanned? # Hellban + blocked ||= optional_non_follower? # Options + blocked ||= optional_non_following? # Options + blocked ||= optional_non_following_and_direct? # Options blocked ||= conversation_muted? - blocked ||= send("blocked_#{@notification.type}?") # Type-dependent filters + blocked ||= send("blocked_#{@notification.type}?") # Type-dependent filters blocked end diff --git a/app/views/settings/notifications/show.html.haml b/app/views/settings/notifications/show.html.haml index 80cd615c7..b718b62df 100644 --- a/app/views/settings/notifications/show.html.haml +++ b/app/views/settings/notifications/show.html.haml @@ -11,7 +11,7 @@ = ff.input :reblog, as: :boolean, wrapper: :with_label = ff.input :favourite, as: :boolean, wrapper: :with_label = ff.input :mention, as: :boolean, wrapper: :with_label - + .fields-group = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| = ff.input :digest, as: :boolean, wrapper: :with_label @@ -20,6 +20,7 @@ = f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff| = ff.input :must_be_follower, as: :boolean, wrapper: :with_label = ff.input :must_be_following, as: :boolean, wrapper: :with_label + = ff.input :must_be_following_dm, as: :boolean, wrapper: :with_label .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index aafae48ce..faf41f316 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -54,6 +54,7 @@ en: interactions: must_be_follower: Block notifications from non-followers must_be_following: Block notifications from people you don't follow + must_be_following_dm: Block direct messages from people you don't follow notification_emails: digest: Send digest e-mails favourite: Send e-mail when someone favourites your status diff --git a/config/locales/simple_form.pl.yml b/config/locales/simple_form.pl.yml index 68f84d109..8b539662c 100644 --- a/config/locales/simple_form.pl.yml +++ b/config/locales/simple_form.pl.yml @@ -58,6 +58,7 @@ pl: interactions: must_be_follower: Nie wyświetlaj powiadomień od osób, które Cię nie śledzą must_be_following: Nie wyświetlaj powiadomień od osób, których nie śledzisz + must_be_following_dm: Nie wyświetlaj wiadomości bezpośrednich od osób, których nie śledzisz notification_emails: digest: Wysyłaj podsumowania e-mailem favourite: Powiadamiaj mnie e-mailem, gdy ktoś polubi mój wpis diff --git a/config/settings.yml b/config/settings.yml index 11681d7ec..a4df4094d 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -36,6 +36,7 @@ defaults: &defaults interactions: must_be_follower: false must_be_following: false + must_be_following_dm: false reserved_usernames: - admin - support diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb index 7a66bd0fe..58ee66ded 100644 --- a/spec/services/notify_service_spec.rb +++ b/spec/services/notify_service_spec.rb @@ -38,6 +38,39 @@ RSpec.describe NotifyService do is_expected.to_not change(Notification, :count) end + context 'for direct messages' do + let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct)) } + + before do + user.settings.interactions = user.settings.interactions.merge('must_be_following_dm' => enabled) + end + + context 'if recipient is supposed to be following sender' do + let(:enabled) { true } + + it 'does not notify' do + is_expected.to_not change(Notification, :count) + end + + context 'if the message chain initiated by recipient' do + let(:reply_to) { Fabricate(:status, account: recipient) } + let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct, thread: reply_to)) } + + it 'does notify' do + is_expected.to change(Notification, :count) + end + end + end + + context 'if recipient is NOT supposed to be following sender' do + let(:enabled) { false } + + it 'does notify' do + is_expected.to change(Notification, :count) + end + end + end + context do let(:asshole) { Fabricate(:account, username: 'asshole') } let(:reply_to) { Fabricate(:status, account: asshole) } -- cgit From 1c25853842075f88e3b6ed28decba3907d548f2e Mon Sep 17 00:00:00 2001 From: ThibG Date: Wed, 15 Nov 2017 01:06:49 +0100 Subject: Use already-known remote user data if resolving temporarily fails in mentions (#5702) --- app/services/process_mentions_service.rb | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'app/services') diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index c1ff68209..a229d4ff8 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -17,6 +17,11 @@ class ProcessMentionsService < BaseService mentioned_account = nil end + if mentioned_account.nil? + username, domain = match.first.split('@') + mentioned_account = Account.find_remote(username, domain) + end + next match if mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus? && status.stream_entry.hidden?) mentioned_account.mentions.where(status: status).first_or_create(status: status) -- cgit From 031a5a8f922d422ff087ad2fca82e3557a1b29d9 Mon Sep 17 00:00:00 2001 From: Surinna Curtis Date: Tue, 14 Nov 2017 20:56:41 -0600 Subject: Optional notification muting (#5087) * Add a hide_notifications column to mutes * Add muting_notifications? and a notifications argument to mute! * block notifications in notify_service from hard muted accounts * Add specs for how mute! interacts with muting_notifications? * specs testing that hide_notifications in mutes actually hides notifications * Add support for muting notifications in MuteService * API support for muting notifications (and specs) * Less gross passing of notifications flag * Break out a separate mute modal with a hide-notifications checkbox. * Convert profile header mute to use mute modal * Satisfy eslint. * specs for MuteService notifications params * add trailing newlines to files for Pork :) * Put the label for the hide notifications checkbox in a label element. * Add a /api/v1/mutes/details route that just returns the array of mutes. * Define a serializer for /api/v1/mutes/details * Add more specs for the /api/v1/mutes/details endpoint * Expose whether a mute hides notifications in the api/v1/relationships endpoint * Show whether muted users' notifications are muted in account lists * Allow modifying the hide_notifications of a mute with the /api/v1/accounts/:id/mute endpoint * make the hide/unhide notifications buttons work * satisfy eslint * In probably dead code, replace a dispatch of muteAccount that was skipping the modal with launching the mute modal. * fix a missing import * add an explanatory comment to AccountInteractions * Refactor handling of default params for muting to make code cleaner * minor code style fixes oops * Fixed a typo that was breaking the account mute API endpoint * Apply white-space: nowrap to account relationships icons * Fix code style issues * Remove superfluous blank line * Rename /api/v1/mutes/details -> /api/v2/mutes * Don't serialize "account" in MuteSerializer Doing so is somewhat unnecessary since it's always the current user's account. * Fix wrong variable name in api/v2/mutes * Use Toggle in place of checkbox in the mute modal. * Make the Toggle in the mute modal look better * Code style changes in specs and removed an extra space * Code review suggestions from akihikodaki Also fixed a syntax error in tests for AccountInteractions. * Make AddHideNotificationsToMute Concurrent It's not clear how much this will benefit instances in practice, as the number of mutes tends to be pretty small, but this should prevent any blocking migrations nonetheless. * Fix up migration things * Remove /api/v2/mutes --- app/controllers/api/v1/accounts_controller.rb | 2 +- app/javascript/mastodon/actions/accounts.js | 4 +- app/javascript/mastodon/actions/mutes.js | 21 +++++ app/javascript/mastodon/components/account.js | 23 ++++- .../mastodon/containers/account_container.js | 7 +- .../mastodon/containers/status_container.js | 13 +-- .../containers/header_container.js | 9 +- .../mastodon/features/ui/components/modal_root.js | 2 + .../mastodon/features/ui/components/mute_modal.js | 105 +++++++++++++++++++++ .../mastodon/features/ui/util/async-components.js | 4 + app/javascript/mastodon/reducers/index.js | 2 + app/javascript/mastodon/reducers/mutes.js | 29 ++++++ app/javascript/styles/mastodon/components.scss | 20 +++- app/models/concerns/account_interactions.rb | 19 +++- app/models/mute.rb | 11 ++- app/services/mute_service.rb | 5 +- app/services/notify_service.rb | 2 +- ...0170716191202_add_hide_notifications_to_mute.rb | 15 +++ db/schema.rb | 1 + .../controllers/api/v1/accounts_controller_spec.rb | 29 ++++++ spec/controllers/api/v1/mutes_controller_spec.rb | 2 +- spec/models/concerns/account_interactions_spec.rb | 38 ++++++++ spec/services/mute_service_spec.rb | 32 +++++++ spec/services/notify_service_spec.rb | 10 ++ 24 files changed, 368 insertions(+), 37 deletions(-) create mode 100644 app/javascript/mastodon/features/ui/components/mute_modal.js create mode 100644 app/javascript/mastodon/reducers/mutes.js create mode 100644 db/migrate/20170716191202_add_hide_notifications_to_mute.rb create mode 100644 spec/models/concerns/account_interactions_spec.rb (limited to 'app/services') diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index b3fc4e561..4676f60de 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -26,7 +26,7 @@ class Api::V1::AccountsController < Api::BaseController end def mute - MuteService.new.call(current_user.account, @account) + MuteService.new.call(current_user.account, @account, notifications: params[:notifications]) render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index 73d6baace..fbaebf786 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -241,11 +241,11 @@ export function unblockAccountFail(error) { }; -export function muteAccount(id) { +export function muteAccount(id, notifications) { return (dispatch, getState) => { dispatch(muteAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/mute`).then(response => { + api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => { // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); }).catch(error => { diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js index febda7219..3474250fe 100644 --- a/app/javascript/mastodon/actions/mutes.js +++ b/app/javascript/mastodon/actions/mutes.js @@ -1,5 +1,6 @@ import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; +import { openModal } from '../../mastodon/actions/modal'; export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS'; @@ -9,6 +10,9 @@ export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST'; export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS'; export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; +export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; +export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; + export function fetchMutes() { return (dispatch, getState) => { dispatch(fetchMutesRequest()); @@ -80,3 +84,20 @@ export function expandMutesFail(error) { error, }; }; + +export function initMuteModal(account) { + return dispatch => { + dispatch({ + type: MUTES_INIT_MODAL, + account, + }); + + dispatch(openModal('MUTE')); + }; +} + +export function toggleHideNotifications() { + return dispatch => { + dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); + }; +} \ No newline at end of file diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index 0e3007ce8..724b10980 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -15,6 +15,8 @@ const messages = defineMessages({ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' }, + unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' }, }); @injectIntl @@ -41,6 +43,14 @@ export default class Account extends ImmutablePureComponent { this.props.onMute(this.props.account); } + handleMuteNotifications = () => { + this.props.onMuteNotifications(this.props.account, true); + } + + handleUnmuteNotifications = () => { + this.props.onMuteNotifications(this.props.account, false); + } + render () { const { account, intl, hidden } = this.props; @@ -70,7 +80,18 @@ export default class Account extends ImmutablePureComponent { } else if (blocking) { buttons = ; } else if (muting) { - buttons = ; + let hidingNotificationsButton; + if (muting.get('notifications')) { + hidingNotificationsButton = ; + } else { + hidingNotificationsButton = ; + } + buttons = ( +
+ + {hidingNotificationsButton} +
+ ); } else { buttons = ; } diff --git a/app/javascript/mastodon/containers/account_container.js b/app/javascript/mastodon/containers/account_container.js index 344f6749d..5a5136dd1 100644 --- a/app/javascript/mastodon/containers/account_container.js +++ b/app/javascript/mastodon/containers/account_container.js @@ -12,6 +12,7 @@ import { unmuteAccount, } from '../actions/accounts'; import { openModal } from '../actions/modal'; +import { initMuteModal } from '../actions/mutes'; import { unfollowModal } from '../initial_state'; const messages = defineMessages({ @@ -58,10 +59,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ if (account.getIn(['relationship', 'muting'])) { dispatch(unmuteAccount(account.get('id'))); } else { - dispatch(muteAccount(account.get('id'))); + dispatch(initMuteModal(account)); } }, + + onMuteNotifications (account, notifications) { + dispatch(muteAccount(account.get('id'), notifications)); + }, }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account)); diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 311ccae5b..b22540204 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -14,11 +14,9 @@ import { pin, unpin, } from '../actions/interactions'; -import { - blockAccount, - muteAccount, -} from '../actions/accounts'; +import { blockAccount } from '../actions/accounts'; import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses'; +import { initMuteModal } from '../actions/mutes'; import { initReport } from '../actions/reports'; import { openModal } from '../actions/modal'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; @@ -28,7 +26,6 @@ const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, - muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' }, }); const makeMapStateToProps = () => { @@ -120,11 +117,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ }, onMute (account) { - dispatch(openModal('CONFIRM', { - message: @{account.get('acct')} }} />, - confirm: intl.formatMessage(messages.muteConfirm), - onConfirm: () => dispatch(muteAccount(account.get('id'))), - })); + dispatch(initMuteModal(account)); }, onMuteConversation (status) { diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js index 01e18928e..8e50ec405 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -7,10 +7,10 @@ import { unfollowAccount, blockAccount, unblockAccount, - muteAccount, unmuteAccount, } from '../../../actions/accounts'; import { mentionCompose } from '../../../actions/compose'; +import { initMuteModal } from '../../../actions/mutes'; import { initReport } from '../../../actions/reports'; import { openModal } from '../../../actions/modal'; import { blockDomain, unblockDomain } from '../../../actions/domain_blocks'; @@ -20,7 +20,6 @@ import { unfollowModal } from '../../../initial_state'; const messages = defineMessages({ unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, - muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' }, blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, }); @@ -76,11 +75,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ if (account.getIn(['relationship', 'muting'])) { dispatch(unmuteAccount(account.get('id'))); } else { - dispatch(openModal('CONFIRM', { - message: @{account.get('acct')} }} />, - confirm: intl.formatMessage(messages.muteConfirm), - onConfirm: () => dispatch(muteAccount(account.get('id'))), - })); + dispatch(initMuteModal(account)); } }, diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index f420f0abf..79d86370e 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -10,6 +10,7 @@ import BoostModal from './boost_modal'; import ConfirmationModal from './confirmation_modal'; import { OnboardingModal, + MuteModal, ReportModal, EmbedModal, } from '../../../features/ui/util/async-components'; @@ -20,6 +21,7 @@ const MODAL_COMPONENTS = { 'VIDEO': () => Promise.resolve({ default: VideoModal }), 'BOOST': () => Promise.resolve({ default: BoostModal }), 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }), + 'MUTE': MuteModal, 'REPORT': ReportModal, 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), 'EMBED': EmbedModal, diff --git a/app/javascript/mastodon/features/ui/components/mute_modal.js b/app/javascript/mastodon/features/ui/components/mute_modal.js new file mode 100644 index 000000000..73e48cf09 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/mute_modal.js @@ -0,0 +1,105 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import Toggle from 'react-toggle'; +import Button from '../../../components/button'; +import { closeModal } from '../../../actions/modal'; +import { muteAccount } from '../../../actions/accounts'; +import { toggleHideNotifications } from '../../../actions/mutes'; + + +const mapStateToProps = state => { + return { + isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']), + account: state.getIn(['mutes', 'new', 'account']), + notifications: state.getIn(['mutes', 'new', 'notifications']), + }; +}; + +const mapDispatchToProps = dispatch => { + return { + onConfirm(account, notifications) { + dispatch(muteAccount(account.get('id'), notifications)); + }, + + onClose() { + dispatch(closeModal()); + }, + + onToggleNotifications() { + dispatch(toggleHideNotifications()); + }, + }; +}; + +@connect(mapStateToProps, mapDispatchToProps) +@injectIntl +export default class MuteModal extends React.PureComponent { + + static propTypes = { + isSubmitting: PropTypes.bool.isRequired, + account: PropTypes.object.isRequired, + notifications: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + onToggleNotifications: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + componentDidMount() { + this.button.focus(); + } + + handleClick = () => { + this.props.onClose(); + this.props.onConfirm(this.props.account, this.props.notifications); + } + + handleCancel = () => { + this.props.onClose(); + } + + setRef = (c) => { + this.button = c; + } + + toggleNotifications = () => { + this.props.onToggleNotifications(); + } + + render () { + const { account, notifications } = this.props; + + return ( +
+
+

+ @{account.get('acct')} }} + /> +

+
+ +
+
+ +
+ + +
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 8f7b91d21..39663d5ca 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -86,6 +86,10 @@ export function OnboardingModal () { return import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal'); } +export function MuteModal () { + return import(/* webpackChunkName: "modals/mute_modal" */'../components/mute_modal'); +} + export function ReportModal () { return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal'); } diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index e65144871..17c870351 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -13,6 +13,7 @@ import settings from './settings'; import push_notifications from './push_notifications'; import status_lists from './status_lists'; import cards from './cards'; +import mutes from './mutes'; import reports from './reports'; import contexts from './contexts'; import compose from './compose'; @@ -37,6 +38,7 @@ const reducers = { settings, push_notifications, cards, + mutes, reports, contexts, compose, diff --git a/app/javascript/mastodon/reducers/mutes.js b/app/javascript/mastodon/reducers/mutes.js new file mode 100644 index 000000000..a96232dbd --- /dev/null +++ b/app/javascript/mastodon/reducers/mutes.js @@ -0,0 +1,29 @@ +import Immutable from 'immutable'; + +import { + MUTES_INIT_MODAL, + MUTES_TOGGLE_HIDE_NOTIFICATIONS, +} from '../actions/mutes'; + +const initialState = Immutable.Map({ + new: Immutable.Map({ + isSubmitting: false, + account: null, + notifications: true, + }), +}); + +export default function mutes(state = initialState, action) { + switch (action.type) { + case MUTES_INIT_MODAL: + return state.withMutations((state) => { + state.setIn(['new', 'isSubmitting'], false); + state.setIn(['new', 'account'], action.account); + state.setIn(['new', 'notifications'], true); + }); + case MUTES_TOGGLE_HIDE_NOTIFICATIONS: + return state.updateIn(['new', 'notifications'], (old) => !old); + default: + return state; + } +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index e4504f543..0ded6f159 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -906,6 +906,7 @@ .account__relationship { height: 18px; padding: 10px; + white-space: nowrap; } .account__header { @@ -3515,7 +3516,8 @@ button.icon-button.active i.fa-retweet { .boost-modal, .confirmation-modal, .report-modal, -.actions-modal { +.actions-modal, +.mute-modal { background: lighten($ui-secondary-color, 8%); color: $ui-base-color; border-radius: 8px; @@ -3565,6 +3567,7 @@ button.icon-button.active i.fa-retweet { .boost-modal__action-bar, .confirmation-modal__action-bar, +.mute-modal__action-bar, .report-modal__action-bar { display: flex; justify-content: space-between; @@ -3601,6 +3604,14 @@ button.icon-button.active i.fa-retweet { } } +.mute-modal { + line-height: 24px; +} + +.mute-modal .react-toggle { + vertical-align: middle; +} + .report-modal__statuses, .report-modal__comment { padding: 10px; @@ -3673,8 +3684,10 @@ button.icon-button.active i.fa-retweet { } } -.confirmation-modal__action-bar { - .confirmation-modal__cancel-button { +.confirmation-modal__action-bar, +.mute-modal__action-bar { + .confirmation-modal__cancel-button, + .mute-modal__cancel-button { background-color: transparent; color: darken($ui-secondary-color, 34%); font-size: 14px; @@ -3689,6 +3702,7 @@ button.icon-button.active i.fa-retweet { } .confirmation-modal__container, +.mute-modal__container, .report-modal__target { padding: 30px; font-size: 16px; diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index b26520f5b..55ad812b2 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -17,7 +17,11 @@ module AccountInteractions end def muting_map(target_account_ids, account_id) - follow_mapping(Mute.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) + Mute.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |mute, mapping| + mapping[mute.target_account_id] = { + notifications: mute.hide_notifications?, + } + end end def requested_map(target_account_ids, account_id) @@ -70,8 +74,13 @@ module AccountInteractions block_relationships.find_or_create_by!(target_account: other_account) end - def mute!(other_account) - mute_relationships.find_or_create_by!(target_account: other_account) + def mute!(other_account, notifications: nil) + notifications = true if notifications.nil? + mute = mute_relationships.create_with(hide_notifications: notifications).find_or_create_by!(target_account: other_account) + # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't. + if mute.hide_notifications? != notifications + mute.update!(hide_notifications: notifications) + end end def mute_conversation!(conversation) @@ -127,6 +136,10 @@ module AccountInteractions conversation_mutes.where(conversation: conversation).exists? end + def muting_notifications?(other_account) + mute_relationships.where(target_account: other_account, hide_notifications: true).exists? + end + def requested?(other_account) follow_requests.where(target_account: other_account).exists? end diff --git a/app/models/mute.rb b/app/models/mute.rb index 4174a3523..105696da6 100644 --- a/app/models/mute.rb +++ b/app/models/mute.rb @@ -3,11 +3,12 @@ # # Table name: mutes # -# created_at :datetime not null -# updated_at :datetime not null -# account_id :bigint not null -# id :bigint not null, primary key -# target_account_id :bigint not null +# id :integer not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer not null +# target_account_id :integer not null +# hide_notifications :boolean default(TRUE), not null # class Mute < ApplicationRecord diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb index 132369484..9b7cbd81f 100644 --- a/app/services/mute_service.rb +++ b/app/services/mute_service.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true class MuteService < BaseService - def call(account, target_account) + def call(account, target_account, notifications: nil) return if account.id == target_account.id - mute = account.mute!(target_account) + FeedManager.instance.clear_from_timeline(account, target_account) + mute = account.mute!(target_account, notifications: notifications) BlockWorker.perform_async(account.id, target_account.id) mute end diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index 6a24a8247..8a77f2f38 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -81,7 +81,7 @@ class NotifyService < BaseService blocked ||= from_self? # Skip for interactions with self blocked ||= domain_blocking? # Skip for domain blocked accounts blocked ||= @recipient.blocking?(@notification.from_account) # Skip for blocked accounts - blocked ||= @recipient.muting?(@notification.from_account) # Skip for muted accounts + blocked ||= @recipient.muting_notifications?(@notification.from_account) blocked ||= hellbanned? # Hellban blocked ||= optional_non_follower? # Options blocked ||= optional_non_following? # Options diff --git a/db/migrate/20170716191202_add_hide_notifications_to_mute.rb b/db/migrate/20170716191202_add_hide_notifications_to_mute.rb new file mode 100644 index 000000000..0410938c9 --- /dev/null +++ b/db/migrate/20170716191202_add_hide_notifications_to_mute.rb @@ -0,0 +1,15 @@ +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddHideNotificationsToMute < ActiveRecord::Migration[5.1] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def up + add_column_with_default :mutes, :hide_notifications, :boolean, default: true, allow_null: false + end + + def down + remove_column :mutes, :hide_notifications + end +end diff --git a/db/schema.rb b/db/schema.rb index bf319ce55..2d763e2f4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -203,6 +203,7 @@ ActiveRecord::Schema.define(version: 20171114080328) do t.datetime "updated_at", null: false t.bigint "account_id", null: false t.bigint "target_account_id", null: false + t.boolean "hide_notifications", default: true, null: false t.index ["account_id", "target_account_id"], name: "index_mutes_on_account_id_and_target_account_id", unique: true end diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb index c770649ec..053c53e5a 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts_controller_spec.rb @@ -137,6 +137,35 @@ RSpec.describe Api::V1::AccountsController, type: :controller do it 'creates a muting relation' do expect(user.account.muting?(other_account)).to be true end + + it 'mutes notifications' do + expect(user.account.muting_notifications?(other_account)).to be true + end + end + + describe 'POST #mute with notifications set to false' do + let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + + before do + user.account.follow!(other_account) + post :mute, params: {id: other_account.id, notifications: false } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'does not remove the following relation between user and target user' do + expect(user.account.following?(other_account)).to be true + end + + it 'creates a muting relation' do + expect(user.account.muting?(other_account)).to be true + end + + it 'does not mute notifications' do + expect(user.account.muting_notifications?(other_account)).to be false + end end describe 'POST #unmute' do diff --git a/spec/controllers/api/v1/mutes_controller_spec.rb b/spec/controllers/api/v1/mutes_controller_spec.rb index 3e6fa887b..97d6c2773 100644 --- a/spec/controllers/api/v1/mutes_controller_spec.rb +++ b/spec/controllers/api/v1/mutes_controller_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Api::V1::MutesController, type: :controller do let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'follow') } before do - Fabricate(:mute, account: user.account) + Fabricate(:mute, account: user.account, hide_notifications: false) allow(controller).to receive(:doorkeeper_token) { token } end diff --git a/spec/models/concerns/account_interactions_spec.rb b/spec/models/concerns/account_interactions_spec.rb new file mode 100644 index 000000000..a468549d8 --- /dev/null +++ b/spec/models/concerns/account_interactions_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +describe AccountInteractions do + describe 'muting an account' do + let(:me) { Fabricate(:account, username: 'Me') } + let(:you) { Fabricate(:account, username: 'You') } + + context 'with the notifications option unspecified' do + before do + me.mute!(you) + end + + it 'defaults to muting notifications' do + expect(me.muting_notifications?(you)).to be true + end + end + + context 'with the notifications option set to false' do + before do + me.mute!(you, notifications: false) + end + + it 'does not mute notifications' do + expect(me.muting_notifications?(you)).to be false + end + end + + context 'with the notifications option set to true' do + before do + me.mute!(you, notifications: true) + end + + it 'does mute notifications' do + expect(me.muting_notifications?(you)).to be true + end + end + end +end diff --git a/spec/services/mute_service_spec.rb b/spec/services/mute_service_spec.rb index 8097cb250..800140b6f 100644 --- a/spec/services/mute_service_spec.rb +++ b/spec/services/mute_service_spec.rb @@ -32,4 +32,36 @@ RSpec.describe MuteService do account.muting?(target_account) }.from(false).to(true) end + + context 'without specifying a notifications parameter' do + it 'mutes notifications from the account' do + is_expected.to change { + account.muting_notifications?(target_account) + }.from(false).to(true) + end + end + + context 'with a true notifications parameter' do + subject do + -> { described_class.new.call(account, target_account, notifications: true) } + end + + it 'mutes notifications from the account' do + is_expected.to change { + account.muting_notifications?(target_account) + }.from(false).to(true) + end + end + + context 'with a false notifications parameter' do + subject do + -> { described_class.new.call(account, target_account, notifications: false) } + end + + it 'does not mute notifications from the account' do + is_expected.to_not change { + account.muting_notifications?(target_account) + }.from(false) + end + end end diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb index 58ee66ded..fad0dd369 100644 --- a/spec/services/notify_service_spec.rb +++ b/spec/services/notify_service_spec.rb @@ -17,6 +17,16 @@ RSpec.describe NotifyService do is_expected.to_not change(Notification, :count) end + it 'does not notify when sender is muted with hide_notifications' do + recipient.mute!(sender, notifications: true) + is_expected.to_not change(Notification, :count) + end + + it 'does notify when sender is muted without hide_notifications' do + recipient.mute!(sender, notifications: false) + is_expected.to change(Notification, :count) + end + it 'does not notify when sender\'s domain is blocked' do recipient.block_domain!(sender.domain) is_expected.to_not change(Notification, :count) -- cgit