Add Special:Mute as a shortcut for muting notifications
authorDayllan Maza <dmaza@wikimedia.org>
Tue, 7 May 2019 14:55:48 +0000 (10:55 -0400)
committerDayllan Maza <dmaza@wikimedia.org>
Tue, 25 Jun 2019 03:10:02 +0000 (23:10 -0400)
 - Special:Mute has been added as a quick way for users to block unwanted
   emails from other users originating from Special:EmailUser
 - Special:Mute can be enabled by setting $wgEnableSpecialMute = true.
   This flag default value is `false`. This flag is temporary until
   enough wikis have this feature enabled and then it will go away
 - When Special:Mute is enabled, emails sent from Special:EmailUser
   contain a link to Special:Mute to facilitate access to the page

In the future, Special:Mute will support extensions to append other types
of notifications that could be muted. These are some of the tasks tracking
the rest of the work:
- T218270
- T220163
- T218266

It is worth mentioning that blocking emails from users is already available
via Special:Preferences

Bug: T218265
Change-Id: I54b847192f42ee1f37999d36c3a187f8826f55a8

14 files changed:
RELEASE-NOTES-1.34
autoload.php
includes/DefaultSettings.php
includes/specialpage/SpecialPageFactory.php
includes/specials/SpecialEmailUser.php
includes/specials/SpecialMute.php [new file with mode: 0644]
includes/specials/helpers/LoginHelper.php
languages/i18n/en.json
languages/i18n/qqq.json
languages/messages/MessagesEn.php
resources/Resources.php
resources/src/mediawiki.special.mute.js [new file with mode: 0644]
resources/src/mediawiki.special.pageLanguage.js
tests/phpunit/includes/specials/SpecialMuteTest.php [new file with mode: 0644]

index 33d060d..c85a14c 100644 (file)
@@ -33,6 +33,9 @@ For notes on 1.33.x and older releases, see HISTORY.
   the code as the request identificator. Otherwise, the sent header will be
   ignored and the request ID will either be taken from Apache's mod_unique
   module or will be generated by Mediawiki itself (depending on the set-up).
+* $wgEnableSpecialMute (T218265) - This configuration controls whether
+  Special:Mute is available and whether to include a link to it on emails
+  originating from Special:Email.
 
 ==== Changed configuration ====
 * $wgUseCdn, $wgCdnServers, $wgCdnServersNoPurge, and $wgCdnMaxAge – These four
@@ -57,7 +60,8 @@ For notes on 1.33.x and older releases, see HISTORY.
   wikidiff2.moved_paragraph_detection_cutoff.
 
 === New user-facing features in 1.34 ===
-* …
+* Special:Mute has been added as a quick way for users to block unwanted emails
+  from other users originating from Special:EmailUser.
 
 === New developer features in 1.34 ===
 * Language::formatTimePeriod now supports the new 'avoidhours' option to output
index b26549e..5d3adc8 100644 (file)
@@ -1390,6 +1390,7 @@ $wgAutoloadLocalClasses = [
        'SpecialLockdb' => __DIR__ . '/includes/specials/SpecialLockdb.php',
        'SpecialLog' => __DIR__ . '/includes/specials/SpecialLog.php',
        'SpecialMergeHistory' => __DIR__ . '/includes/specials/SpecialMergeHistory.php',
+       'SpecialMute' => __DIR__ . '/includes/specials/SpecialMute.php',
        'SpecialMyLanguage' => __DIR__ . '/includes/specials/SpecialMyLanguage.php',
        'SpecialMycontributions' => __DIR__ . '/includes/specials/redirects/SpecialMycontributions.php',
        'SpecialMypage' => __DIR__ . '/includes/specials/redirects/SpecialMypage.php',
index 73d05ff..96d9726 100644 (file)
@@ -1682,6 +1682,16 @@ $wgEnableEmail = true;
  */
 $wgEnableUserEmail = true;
 
+/**
+ * Set to true to enable the Special Mute page. This allows users
+ * to mute unwanted communications from other users, and is linked
+ * to from emails originating from Special:Email.
+ *
+ * @since 1.34
+ * @deprecated 1.34
+ */
+$wgEnableSpecialMute = false;
+
 /**
  * Set to true to enable user-to-user e-mail blacklist.
  *
index 1053bda..94900d4 100644 (file)
@@ -232,6 +232,7 @@ class SpecialPageFactory {
                'EmailAuthentication',
                'EnableEmail',
                'EnableJavaScriptTest',
+               'EnableSpecialMute',
                'PageLanguageUseDB',
                'SpecialPages',
        ];
@@ -282,9 +283,14 @@ class SpecialPageFactory {
                                $this->list['JavaScriptTest'] = \SpecialJavaScriptTest::class;
                        }
 
+                       if ( $this->options->get( 'EnableSpecialMute' ) ) {
+                               $this->list['Mute'] = \SpecialMute::class;
+                       }
+
                        if ( $this->options->get( 'PageLanguageUseDB' ) ) {
                                $this->list['PageLanguage'] = \SpecialPageLanguage::class;
                        }
+
                        if ( $this->options->get( 'ContentHandlerUseDB' ) ) {
                                $this->list['ChangeContentModel'] = \SpecialChangeContentModel::class;
                        }
index 5f80215..5b07f22 100644 (file)
@@ -387,6 +387,15 @@ class SpecialEmailUser extends UnlistedSpecialPage {
                $text .= $context->msg( 'emailuserfooter',
                        $from->name, $to->name )->inContentLanguage()->text();
 
+               if ( $config->get( 'EnableSpecialMute' ) ) {
+                       $specialMutePage = SpecialPage::getTitleFor( 'Mute', $context->getUser()->getName() );
+                       $text .= "\n" . $context->msg(
+                               'specialmute-email-footer',
+                               $specialMutePage->getCanonicalURL(),
+                               $context->getUser()->getName()
+                       );
+               }
+
                // Check and increment the rate limits
                if ( $context->getUser()->pingLimiter( 'emailuser' ) ) {
                        throw new ThrottledError();
diff --git a/includes/specials/SpecialMute.php b/includes/specials/SpecialMute.php
new file mode 100644 (file)
index 0000000..4f34785
--- /dev/null
@@ -0,0 +1,213 @@
+<?php
+/*
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+use MediaWiki\Preferences\MultiUsernameFilter;
+
+/**
+ * A special page that allows users to modify their notification
+ * preferences
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialMute extends FormSpecialPage {
+
+       /** @var User */
+       private $target;
+
+       /** @var int */
+       private $targetCentralId;
+
+       /** @var bool */
+       private $enableUserEmailBlacklist;
+
+       /** @var bool */
+       private $enableUserEmail;
+
+       /** @var CentralIdLookup */
+       private $centralIdLookup;
+
+       public function __construct() {
+               // TODO: inject all these dependencies once T222388 is resolved
+               $config = RequestContext::getMain()->getConfig();
+               $this->enableUserEmailBlacklist = $config->get( 'EnableUserEmailBlacklist' );
+               $this->enableUserEmail = $config->get( 'EnableUserEmail' );
+
+               $this->centralIdLookup = CentralIdLookup::factory();
+
+               parent::__construct( 'Mute', '', false );
+       }
+
+       /**
+        * Entry point for special pages
+        *
+        * @param string $par
+        */
+       public function execute( $par ) {
+               $this->requireLogin( 'specialmute-login-required' );
+               $this->loadTarget( $par );
+
+               parent::execute( $par );
+
+               $out = $this->getOutput();
+               $out->addModules( 'mediawiki.special.pageLanguage' );
+       }
+
+       /**
+        * @inheritDoc
+        */
+       public function requiresUnblock() {
+               return false;
+       }
+
+       /**
+        * @inheritDoc
+        */
+       protected function getDisplayFormat() {
+               return 'ooui';
+       }
+
+       /**
+        * @inheritDoc
+        */
+       public function onSuccess() {
+               $out = $this->getOutput();
+               $out->addWikiMsg( 'specialmute-success' );
+       }
+
+       /**
+        * @param array $data
+        * @param HTMLForm|null $form
+        * @return bool
+        */
+       public function onSubmit( array $data, HTMLForm $form = null ) {
+               if ( !empty( $data['MuteEmail'] ) ) {
+                       $this->muteEmailsFromTarget();
+               } else {
+                       $this->unmuteEmailsFromTarget();
+               }
+
+               return true;
+       }
+
+       /**
+        * @inheritDoc
+        */
+       public function getDescription() {
+               return $this->msg( 'specialmute' )->text();
+       }
+
+       /**
+        * Un-mute emails from target
+        */
+       private function unmuteEmailsFromTarget() {
+               $blacklist = $this->getBlacklist();
+
+               $key = array_search( $this->targetCentralId, $blacklist );
+               if ( $key !== false ) {
+                       unset( $blacklist[$key] );
+                       $blacklist = implode( "\n", $blacklist );
+
+                       $user = $this->getUser();
+                       $user->setOption( 'email-blacklist', $blacklist );
+                       $user->saveSettings();
+               }
+       }
+
+       /**
+        * Mute emails from target
+        */
+       private function muteEmailsFromTarget() {
+               // avoid duplicates just in case
+               if ( !$this->isTargetBlacklisted() ) {
+                       $blacklist = $this->getBlacklist();
+
+                       $blacklist[] = $this->targetCentralId;
+                       $blacklist = implode( "\n", $blacklist );
+
+                       $user = $this->getUser();
+                       $user->setOption( 'email-blacklist', $blacklist );
+                       $user->saveSettings();
+               }
+       }
+
+       /**
+        * @inheritDoc
+        */
+       protected function alterForm( HTMLForm $form ) {
+               $form->setId( 'mw-specialmute-form' );
+               $form->setHeaderText( $this->msg( 'specialmute-header', $this->target )->parse() );
+               $form->setSubmitTextMsg( 'specialmute-submit' );
+               $form->setSubmitID( 'save' );
+       }
+
+       /**
+        * @inheritDoc
+        */
+       protected function getFormFields() {
+               if ( !$this->enableUserEmailBlacklist || !$this->enableUserEmail ) {
+                       throw new ErrorPageError( 'specialmute', 'specialmute-error-email-blacklist-disabled' );
+               }
+
+               if ( !$this->getUser()->getEmailAuthenticationTimestamp() ) {
+                       throw new ErrorPageError( 'specialmute', 'specialmute-error-email-preferences' );
+               }
+
+               $fields['MuteEmail'] = [
+                       'type' => 'check',
+                       'label-message' => 'specialmute-label-mute-email',
+                       'default' => $this->isTargetBlacklisted(),
+               ];
+
+               return $fields;
+       }
+
+       /**
+        * @param string $username
+        */
+       private function loadTarget( $username ) {
+               $target = User::newFromName( $username );
+               if ( !$target || !$target->getId() ) {
+                       throw new ErrorPageError( 'specialmute', 'specialmute-error-invalid-user' );
+               } else {
+                       $this->target = $target;
+                       $this->targetCentralId = $this->centralIdLookup->centralIdFromLocalUser( $target );
+               }
+       }
+
+       /**
+        * @return bool
+        */
+       private function isTargetBlacklisted() {
+               $blacklist = $this->getBlacklist();
+               return in_array( $this->targetCentralId, $blacklist );
+       }
+
+       /**
+        * @return array
+        */
+       private function getBlacklist() {
+               $blacklist = $this->getUser()->getOption( 'email-blacklist' );
+               if ( !$blacklist ) {
+                       return [];
+               }
+
+               return MultiUsernameFilter::splitIds( $blacklist );
+       }
+}
index 6c9bea5..f66eccf 100644 (file)
@@ -25,6 +25,7 @@ class LoginHelper extends ContextSource {
                'resetpass-no-info',
                'confirmemail_needlogin',
                'prefsnologintext2',
+               'specialmute-login-required',
        ];
 
        /**
index 851a6b2..a9bf065 100644 (file)
        "restrictionsfield-help": "One IP address or CIDR range per line. To enable everything, use:<pre>0.0.0.0/0\n::/0</pre>",
        "edit-error-short": "Error: $1",
        "edit-error-long": "Errors:\n\n$1",
+       "specialmute": "Mute",
+       "specialmute-success": "Your mute preferences have been successfully updated. See all muted users in [[Special:Preferences]].",
+       "specialmute-submit": "Confirm",
+       "specialmute-label-mute-email": "Mute emails from this user",
+       "specialmute-header": "Please select your mute preferences for [[User:$1]].",
+       "specialmute-error-invalid-user": "The username requested could not be found.",
+       "specialmute-error-email-blacklist-disabled": "Muting users from sending you emails is not enabled.",
+       "specialmute-error-email-preferences": "You must confirm your email address before you can mute a user. You may do so from [[Special:Preferences]].",
+       "specialmute-email-footer": "[$1 Manage email preferences for $2.]",
+       "specialmute-login-required": "Please log in to change your mute preferences.",
        "revid": "revision $1",
        "pageid": "page ID $1",
        "interfaceadmin-info": "$1\n\nPermissions for editing of sitewide CSS/JS/JSON files were recently separated from the <code>editinterface</code> right. If you do not understand why you are getting this error, see [[mw:MediaWiki_1.32/interface-admin]].",
index e0da190..49c33f2 100644 (file)
        "restrictionsfield-help": "Placeholder text displayed in restriction fields (e.g. on Special:BotPassword).",
        "edit-error-short": "Error message. Parameters:\n* $1 - the error details\nSee also:\n* {{msg-mw|edit-error-long}}\n{{Identical|Error}}",
        "edit-error-long": "Error message. Parameters:\n* $1 - the error details\nSee also:\n* {{msg-mw|edit-error-short}}\n{{Identical|Error}}",
+       "specialmute": "The name of the special page [[Special:Mute]].",
+       "specialmute-success": "The content of [[Special:Mute]] with a successful message indicating that your mute preferences have been updated after the form has been submitted.",
+       "specialmute-submit": "Submit button on [[Special:Mute]] form.",
+       "specialmute-label-mute-email": "Label for the checkbox that mutes/unmutes emails from the specified user.",
+       "specialmute-header": "Used as header text on [[Special:Mute]]. Shown before the form with the muting options.\n* $1 - User selected for muting",
+       "specialmute-error-invalid-user": "Error displayed when the username cannot be found.",
+       "specialmute-error-email-blacklist-disabled": "Error displayed when email blacklist is not enabled.",
+       "specialmute-error-email-preferences": "Error displayed when the user has not confirmed their email address.",
+       "specialmute-email-footer": "Email footer linking to [[Special:Mute]] preselecting the sender to manage muting options.\n* $1 - Url linking to [[Special:Mute]].\n* $2 - The user sending the email.",
+       "specialmute-login-required": "Error displayed when a user tries to access [[Special:Mute]] before logging in.",
        "revid": "Used to format a revision ID number in text. Parameters:\n* $1 - Revision ID number.\n{{Identical|Revision}}",
        "pageid": "Used to format a page ID number in text. Parameters:\n* $1 - Page ID number.",
        "interfaceadmin-info": "Part of the error message shown when someone with the <code>editinterface</code> right but without the appropriate <code>editsite*</code> right tries to edit a sitewide CSS/JSON/JS page.",
index 666b28f..22313a4 100644 (file)
@@ -447,6 +447,7 @@ $specialPageAliases = [
        'Mostlinkedtemplates'       => [ 'MostTranscludedPages', 'MostLinkedTemplates', 'MostUsedTemplates' ],
        'Mostrevisions'             => [ 'MostRevisions' ],
        'Movepage'                  => [ 'MovePage' ],
+       'Mute'                      => [ 'Mute' ],
        'Mycontributions'           => [ 'MyContributions' ],
        'MyLanguage'                => [ 'MyLanguage' ],
        'Mypage'                    => [ 'MyPage' ],
index b90ead4..9c26986 100644 (file)
@@ -2150,7 +2150,10 @@ return [
                ],
        ],
        'mediawiki.special.pageLanguage' => [
-               'scripts' => 'resources/src/mediawiki.special.pageLanguage.js',
+               'scripts' => [
+                       'resources/src/mediawiki.special.mute.js',
+                       'resources/src/mediawiki.special.pageLanguage.js'
+               ],
                'dependencies' => [
                        'oojs-ui-core',
                ],
diff --git a/resources/src/mediawiki.special.mute.js b/resources/src/mediawiki.special.mute.js
new file mode 100644 (file)
index 0000000..3d494d0
--- /dev/null
@@ -0,0 +1,23 @@
+( function () {
+       'use strict';
+
+       $( function () {
+               var $inputs = $( '#mw-specialmute-form input:checkbox' ),
+                       saveButton, $saveButton = $( '#save' );
+
+               function isFormChanged() {
+                       return $inputs.is( function () {
+                               return this.checked !== this.defaultChecked;
+                       } );
+               }
+
+               if ( $saveButton.length ) {
+                       saveButton = OO.ui.infuse( $saveButton );
+                       saveButton.setDisabled( !isFormChanged() );
+
+                       $inputs.on( 'change', function () {
+                               saveButton.setDisabled( !isFormChanged() );
+                       } );
+               }
+       } );
+}() );
index 8b70e1f..8538e95 100644 (file)
@@ -4,8 +4,10 @@
 ( function () {
        $( function () {
                // Select the 'Language select' option if user is trying to select language
-               OO.ui.infuse( $( '#mw-pl-languageselector' ) ).on( 'change', function () {
-                       OO.ui.infuse( $( '#mw-pl-options' ) ).setValue( '2' );
-               } );
+               if ( $( '#mw-pl-languageselector' ).length ) {
+                       OO.ui.infuse( $( '#mw-pl-languageselector' ) ).on( 'change', function () {
+                               OO.ui.infuse( $( '#mw-pl-options' ) ).setValue( '2' );
+                       } );
+               }
        } );
 }() );
diff --git a/tests/phpunit/includes/specials/SpecialMuteTest.php b/tests/phpunit/includes/specials/SpecialMuteTest.php
new file mode 100644 (file)
index 0000000..e31357c
--- /dev/null
@@ -0,0 +1,110 @@
+<?php
+
+/**
+ * @group SpecialPage
+ * @covers SpecialMute
+ */
+class SpecialMuteTest extends SpecialPageTestBase {
+
+       protected function setUp() {
+               parent::setUp();
+
+               $this->setMwGlobals( [
+                       'wgEnableUserEmailBlacklist' => true
+               ] );
+       }
+
+       /**
+        * @inheritDoc
+        */
+       protected function newSpecialPage() {
+               return new SpecialMute();
+       }
+
+       /**
+        * @covers SpecialMute::execute
+        * @expectedExceptionMessage username requested could not be found
+        * @expectedException ErrorPageError
+        */
+       public function testInvalidTarget() {
+               $user = $this->getTestUser()->getUser();
+               $this->executeSpecialPage(
+                       'InvalidUser', null, 'qqx', $user
+               );
+       }
+
+       /**
+        * @covers SpecialMute::execute
+        * @expectedExceptionMessage Muting users from sending you emails is not enabled
+        * @expectedException ErrorPageError
+        */
+       public function testEmailBlacklistNotEnabled() {
+               $this->setMwGlobals( [
+                       'wgEnableUserEmailBlacklist' => false
+               ] );
+
+               $user = $this->getTestUser()->getUser();
+               $this->executeSpecialPage(
+                       $user->getName(), null, 'qqx', $user
+               );
+       }
+
+       /**
+        * @covers SpecialMute::execute
+        * @expectedException UserNotLoggedIn
+        */
+       public function testUserNotLoggedIn() {
+               $this->executeSpecialPage( 'TestUser' );
+       }
+
+       /**
+        * @covers SpecialMute::execute
+        */
+       public function testMuteAddsUserToEmailBlacklist() {
+               $this->setMwGlobals( [
+                       'wgCentralIdLookupProvider' => 'local',
+               ] );
+
+               $targetUser = $this->getTestUser()->getUser();
+
+               $loggedInUser = $this->getMutableTestUser()->getUser();
+               $loggedInUser->setOption( 'email-blacklist', "999" );
+               $loggedInUser->confirmEmail();
+               $loggedInUser->saveSettings();
+
+               $fauxRequest = new FauxRequest( [ 'wpMuteEmail' => 1 ], true );
+               list( $html, ) = $this->executeSpecialPage(
+                       $targetUser->getName(), $fauxRequest, 'qqx', $loggedInUser
+               );
+
+               $this->assertContains( 'specialmute-success', $html );
+               $this->assertEquals(
+                       "999\n" . $targetUser->getId(),
+                       $loggedInUser->getOption( 'email-blacklist' )
+               );
+       }
+
+       /**
+        * @covers SpecialMute::execute
+        */
+       public function testUnmuteRemovesUserFromEmailBlacklist() {
+               $this->setMwGlobals( [
+                       'wgCentralIdLookupProvider' => 'local',
+               ] );
+
+               $targetUser = $this->getTestUser()->getUser();
+
+               $loggedInUser = $this->getMutableTestUser()->getUser();
+               $loggedInUser->setOption( 'email-blacklist', "999\n" . $targetUser->getId() );
+               $loggedInUser->confirmEmail();
+               $loggedInUser->saveSettings();
+
+               $fauxRequest = new FauxRequest( [ 'wpMuteEmail' => false ], true );
+               list( $html, ) = $this->executeSpecialPage(
+                       $targetUser->getName(), $fauxRequest, 'qqx', $loggedInUser
+               );
+
+               $this->assertContains( 'specialmute-success', $html );
+               $this->assertEquals( "999", $loggedInUser->getOption( 'email-blacklist' ) );
+       }
+}