Add dynamic secondary action to mute/unmute page-linked notifications

Also adds an API module for muting and unmuting pages (and users).

Bug: T46787
Bug: T115264
Change-Id: Icf4e4bfa9fd7fa27b4c40892e3d5ce000eb22d5a
This commit is contained in:
Roan Kattouw 2020-04-27 20:39:40 -07:00 committed by Gergő Tisza
parent 5e9eac03d0
commit 28f432b150
No known key found for this signature in database
GPG key ID: C34FEC97E6257F96
10 changed files with 235 additions and 26 deletions

View file

@ -24,7 +24,8 @@
"APIModules": {
"echomarkread": "ApiEchoMarkRead",
"echomarkseen": "ApiEchoMarkSeen",
"echoarticlereminder": "ApiEchoArticleReminder"
"echoarticlereminder": "ApiEchoArticleReminder",
"echomute": "ApiEchoMute"
},
"DefaultUserOptions": {
"echo-email-frequency": 0,
@ -721,6 +722,9 @@
"rtl": "Echo/modules/icons/userTalk-rtl.svg"
}
},
"unbell": {
"path": "Echo/modules/icons/unbell.svg"
},
"userSpeechBubble": {
"path": "Echo/modules/icons/user-speech-bubble.svg"
}
@ -994,6 +998,7 @@
"ApiEchoArticleReminder": "includes/api/ApiEchoArticleReminder.php",
"ApiEchoMarkRead": "includes/api/ApiEchoMarkRead.php",
"ApiEchoMarkSeen": "includes/api/ApiEchoMarkSeen.php",
"ApiEchoMute": "includes/api/ApiEchoMute.php",
"ApiEchoNotifications": "includes/api/ApiEchoNotifications.php",
"ApiEchoUnreadNotificationPages": "includes/api/ApiEchoUnreadNotificationPages.php",
"BackfillUnreadWikis": "maintenance/backfillUnreadWikis.php",

View file

@ -24,6 +24,11 @@
"apihelp-echomarkseen-example-1": "Mark notifications of all types as seen",
"apihelp-echomarkseen-param-type": "Type of notifications to mark as seen: 'alert', 'message' or 'all'.",
"apihelp-echomarkseen-param-timestampFormat": "Timestamp format to use for output, 'ISO_8601' or 'MW'. 'MW' is deprecated here, so all clients should switch to 'ISO_8601'. This parameter will be removed, and 'ISO_8601' will become the only output format.",
"apihelp-echomute-description": "Mute or unmute notifications from certain users or pages.",
"apihelp-echomute-summary": "Mute or unmute notifications from certain users or pages.",
"apihelp-echomute-param-type": "Which mute list to add to or remove from",
"apihelp-echomute-param-mute": "Pages or users to add to the mute list",
"apihelp-echomute-param-unmute": "Pages or users to remove from the mute list",
"apihelp-query+notifications-description": "Get notifications waiting for the current user.",
"apihelp-query+notifications-summary": "Get notifications waiting for the current user.",
"apihelp-query+notifications-param-prop": "Details to request.",

View file

@ -26,6 +26,11 @@
"apihelp-echomarkseen-example-1": "{{doc-apihelp-example|echomarkseen}}",
"apihelp-echomarkseen-param-type": "{{doc-apihelp-param|echomarkseen|type}}",
"apihelp-echomarkseen-param-timestampFormat": "{{doc-apihelp-param|echomarkseen|timestampFormat}}",
"apihelp-echomute-description": "{{doc-apihelp-description|echomute}}",
"apihelp-echomute-summary": "{{doc-apihelp-summary|echomute}}",
"apihelp-echomute-param-type": "{{doc-apihelp-param|echomute|type}}",
"apihelp-echomute-param-mute": "{{doc-apihelp-param|echomute|mute}}",
"apihelp-echomute-param-unmute": "{{doc-apihelp-param|echomute|unmute}}",
"apihelp-query+notifications-description": "{{doc-apihelp-description|query+notifications}}",
"apihelp-query+notifications-summary": "{{doc-apihelp-summary|query+notifications}}",
"apihelp-query+notifications-param-prop": "{{doc-apihelp-param|query+notifications|prop}}",

View file

@ -129,6 +129,12 @@
"echo-notification-markasunread": "Mark as unread",
"echo-notification-markasread-tooltip": "Mark as read",
"echo-notification-more-options-tooltip": "More options",
"notification-dynamic-actions-mute-page-linked": "{{GENDER:$2|Mute}} link notifications on \"$1\"",
"notification-dynamic-actions-mute-page-linked-confirmation": "\"Page link\" notifications are now disabled for the page \"$1\"",
"notification-dynamic-actions-mute-page-linked-confirmation-description": "{{GENDER:$2|You}} can manage your muted pages in [$1 your preferences] anytime.",
"notification-dynamic-actions-unmute-page-linked": "{{GENDER:$2|Unmute}} link notifications on \"$1\"",
"notification-dynamic-actions-unmute-page-linked-confirmation": "\"Page link\" notifications are now enabled for the page \"$1\"",
"notification-dynamic-actions-unmute-page-linked-confirmation-description": "{{GENDER:$2|You}} can manage your muted pages in [$1 your preferences] anytime.",
"notification-dynamic-actions-unwatch": "{{GENDER:$3|Stop}} watching new activity on \"$1\"",
"notification-dynamic-actions-unwatch-confirmation": "{{GENDER:$3|You}} are no longer watching the page \"$1\"",
"notification-dynamic-actions-unwatch-confirmation-description": "{{GENDER:$3|You}} can watch [$2 this page] anytime.",

View file

@ -131,6 +131,12 @@
"echo-notification-markasunread": "Label for the button to mark the notification as unread.",
"echo-notification-markasread-tooltip": "Tooltip for the button to mark the notification as read.",
"echo-notification-more-options-tooltip": "Tooltip for the button to show the hidden secondary actions.",
"notification-dynamic-actions-mute-page-linked": "Text for the action offering the user to mute link notifications for a page from within a notification.\n\nParameters:\n* $1 - Page name\n* $2 - Current user name for gender purposes\n\n{{related|Notification-dynamic}}",
"notification-dynamic-actions-mute-page-linked-confirmation": "Title for the confirmation text for muting link notifications for a page from within a notification.\n\nParameters:\n* $1 - Page name\n* $2 - Current user name for gender purposes\n\n{{related|Notification-dynamic}}",
"notification-dynamic-actions-mute-page-linked-confirmation-description": "Description for the confirmation text for muting link notifications for a page from within a notification.\n\nParameters:\n* $1 - Page name\n* $2 - Current user name for gender purposes\n\n{{related|Notification-dynamic}}",
"notification-dynamic-actions-unmute-page-linked": "Text for the action offering the user to unmute link notifications for a page from within a notification.\n\nParameters:\n* $1 - Page name\n* $2 - Current user name for gender purposes\n\n{{related|Notification-dynamic}}",
"notification-dynamic-actions-unmute-page-linked-confirmation": "Title for the confirmation text for unmuting link notifications for a page from within a notification.\n\nParameters:\n* $1 - Page name\n* $2 - Current user name for gender purposes\n\n{{related|Notification-dynamic}}",
"notification-dynamic-actions-unmute-page-linked-confirmation-description": "Description for the confirmation text for unmuting link notifications for a page from within a notification.\n\nParameters:\n* $1 - Page name\n* $2 - Current user name for gender purposes\n\n{{related|Notification-dynamic}}",
"notification-dynamic-actions-unwatch": "Text for the action offering the user to unwatch a page from within a notification.\n\nParameters:\n* $1 - Page name\n* $2 - Page URL\n* $3 - Current user name for gender purposes\n\n{{related|Notification-dynamic}}",
"notification-dynamic-actions-unwatch-confirmation": "Title for the confirmation text for unwatching a page from within a notification.\n\nParameters:\n* $1 - Page name\n* $2 - Page URL\n* $3 - Current user name for gender purposes\n\n{{related|Notification-dynamic}}",
"notification-dynamic-actions-unwatch-confirmation-description": "Description for the confirmation text for unwatching a page from within a notification.\n\nParameters:\n* $1 - Page name\n* $2 - Page URL\n* $3 - Current user name for gender purposes\n\n{{related|Notification-dynamic}}",

View file

@ -0,0 +1,128 @@
<?php
use MediaWiki\MediaWikiServices;
class ApiEchoMute extends ApiBase {
private $centralIdLookup = null;
private static $muteLists = [
'user' => [
'pref' => 'echo-notifications-blacklist',
'type' => 'user',
],
'page-linked-title' => [
'pref' => 'echo-notifications-page-linked-title-muted-list',
'type' => 'title'
],
];
public function execute() {
$user = $this->getUser()->getInstanceForUpdate();
if ( !$user || $user->isAnon() ) {
$this->dieWithError(
[ 'apierror-mustbeloggedin', $this->msg( 'action-editmyoptions' ) ],
'notloggedin'
);
}
$this->checkUserRightsAny( 'editmyoptions' );
$params = $this->extractRequestParams();
$mutelistInfo = self::$muteLists[ $params['type'] ];
$prefValue = $user->getOption( $mutelistInfo['pref'] );
$ids = $this->parsePref( $prefValue, $mutelistInfo['type'] );
$changed = false;
$addIds = $this->lookupIds( $params['mute'], $mutelistInfo['type'] );
foreach ( $addIds as $id ) {
if ( !in_array( $id, $ids ) ) {
$ids[] = $id;
$changed = true;
}
}
$removeIds = $this->lookupIds( $params['unmute'], $mutelistInfo['type'] );
foreach ( $removeIds as $id ) {
$index = array_search( $id, $ids );
if ( $index !== false ) {
array_splice( $ids, $index, 1 );
$changed = true;
}
}
if ( $changed ) {
$user->setOption( $mutelistInfo['pref'], $this->serializePref( $ids, $mutelistInfo['type'] ) );
$user->saveSettings();
}
$this->getResult()->addValue( null, $this->getModuleName(), 'success' );
}
private function getCentralIdLookup() {
if ( $this->centralIdLookup === null ) {
$this->centralIdLookup = CentralIdLookup::factory();
}
return $this->centralIdLookup;
}
private function lookupIds( $names, $type ) {
if ( $type === 'title' ) {
$linkBatch = MediaWikiServices::getInstance()->getLinkBatchFactory()->newLinkBatch();
foreach ( $names as $name ) {
$linkBatch->addObj( Title::newFromText( $name ) );
}
$linkBatch->execute();
$ids = [];
foreach ( $names as $name ) {
$title = Title::newFromText( $name );
if ( $title instanceof Title && $title->getArticleID() > 0 ) {
$ids[] = $title->getArticleID();
}
}
return $ids;
} elseif ( $type === 'user' ) {
return $this->getCentralIdLookup()->centralIdsFromNames( $names, CentralIdLookup::AUDIENCE_PUBLIC );
}
}
private function parsePref( $prefValue, $type ) {
return preg_split( '/\n/', $prefValue, -1, PREG_SPLIT_NO_EMPTY );
}
private function serializePref( $ids, $type ) {
return implode( "\n", $ids );
}
public function getAllowedParams( $flags = 0 ) {
return [
'type' => [
ApiBase::PARAM_REQUIRED => true,
ApiBase::PARAM_TYPE => array_keys( self::$muteLists ),
],
'mute' => [
ApiBase::PARAM_ISMULTI => true,
],
'unmute' => [
ApiBase::PARAM_ISMULTI => true,
]
];
}
public function needsToken() {
return 'csrf';
}
public function getTokenSalt() {
return '';
}
public function mustBePosted() {
return true;
}
public function isWriteMode() {
return true;
}
}

View file

@ -246,10 +246,6 @@ class EchoNotificationController {
self::$blacklistByUser = new MapCacheLRU( self::$maxRecipientCacheSize );
}
if ( self::$mutedPageLinkedTitlesCache === null ) {
self::$mutedPageLinkedTitlesCache = new MapCacheLRU( self::$maxUsersTitleCacheSize );
}
// Ensure we have a blacklist for the user
if ( !self::$blacklistByUser->has( (string)$user->getId() ) ) {
$blacklist = new EchoContainmentSet( $user );
@ -277,17 +273,20 @@ class EchoNotificationController {
return $blacklist->contains( $event->getAgent()->getName() ) ||
( $wgEchoPerUserBlacklist &&
$event->getType() === 'page-linked' &&
self::isPageLinkedTitleMutedByUser( $event, $user ) );
self::isPageLinkedTitleMutedByUser( $event->getTitle(), $user ) );
}
/**
* Check if the event title is in the users page-linked event muted list.
* Check if a title is in the user's page-linked event blacklist.
*
* @param EchoEvent $event
* @param Title $title
* @param User $user
* @return bool
*/
private static function isPageLinkedTitleMutedByUser( EchoEvent $event, User $user ) {
public static function isPageLinkedTitleMutedByUser( Title $title, User $user ) {
if ( self::$mutedPageLinkedTitlesCache === null ) {
self::$mutedPageLinkedTitlesCache = new MapCacheLRU( self::$maxUsersTitleCacheSize );
}
if ( !self::$mutedPageLinkedTitlesCache->has( (string)$user->getId() ) ) {
$pageLinkedTitleMutedList = new EchoContainmentSet( $user );
$pageLinkedTitleMutedList->addTitleIDsFromUserOption(
@ -297,7 +296,7 @@ class EchoNotificationController {
} else {
$pageLinkedTitleMutedList = self::$mutedPageLinkedTitlesCache->get( (string)$user->getId() );
}
return $pageLinkedTitleMutedList->contains( (string)$event->getTitle()->getArticleID() );
return $pageLinkedTitleMutedList->contains( (string)$title->getArticleID() );
}
/**

View file

@ -1,5 +1,7 @@
<?php
use MediaWiki\MediaWikiServices;
class EchoPageLinkedPresentationModel extends EchoEventPresentationModel {
private $pageFrom;
@ -53,7 +55,60 @@ class EchoPageLinkedPresentationModel extends EchoEventPresentationModel {
];
}
return [ $whatLinksHereLink, $diffLink ];
return [ $whatLinksHereLink, $diffLink, $this->getMuteLink() ];
}
protected function getMuteLink() {
if ( !MediaWikiServices::getInstance()->getMainConfig()->get( 'EchoPerUserBlacklist' ) ) {
return null;
}
$title = $this->event->getTitle();
$isPageMuted = EchoNotificationController::isPageLinkedTitleMutedByUser( $title, $this->getUser() );
$action = $isPageMuted ? 'unmute' : 'mute';
$prefTitle = SpecialPage::getTitleFor( 'Preferences', false, 'mw-prefsection-echo-mutedpageslist' );
$data = [
'tokenType' => 'csrf',
'params' => [
'action' => 'echomute',
'type' => 'page-linked-title',
],
'messages' => [
'confirmation' => [
// notification-dynamic-actions-mute-page-linked-confirmation
// notification-dynamic-actions-unmute-page-linked-confirmation
'title' => $this
->msg( 'notification-dynamic-actions-' . $action . '-page-linked-confirmation' )
->params(
$this->getTruncatedTitleText( $title ),
$this->getViewingUserForGender()
),
// notification-dynamic-actions-mute-page-linked-confirmation-description
// notification-dynamic-actions-unmute-page-linked-confirmation-description
'description' => $this
->msg( 'notification-dynamic-actions-' . $action . '-page-linked-confirmation-description' )
->params(
$prefTitle->getFullURL(),
$this->getViewingUserForGender()
)
]
]
];
$data['params'][$isPageMuted ? 'unmute' : 'mute'] = $title->getPrefixedText();
return $this->getDynamicActionLink(
$prefTitle,
$isPageMuted ? 'bell' : 'unbell',
// notification-dynamic-actions-mute-page-linked
// notification-dynamic-actions-unmute-page-linked
$this->msg( 'notification-dynamic-actions-' . $action . '-page-linked' )
->params(
$this->getTruncatedTitleText( $title ),
$this->getViewingUserForGender()
),
null,
$data,
[]
);
}
protected function getHeaderMessageKey() {

7
modules/icons/unbell.svg Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewbox="0 0 20 20">
<title>
unbell
</title>
<path d="M1.22 0L0 1.22l4.334 4.335A5.38 5.38 0 004 7v6l-2 2v1h12.78l4 4L20 18.78 17.22 16 5.23 4.01 1.22 0zM10 0C8.47 0 8.4 1.46 8.46 2.15a5.38 5.38 0 00-1.92.729l9.46 9.46V7a5.38 5.38 0 00-4.46-4.85C11.6 1.46 11.53 0 10 0zM7 17a3 3 0 003 3 3 3 0 003-3H7z"/>
</svg>

After

Width:  |  Height:  |  Size: 420 B

View file

@ -10,34 +10,34 @@ class NotificationControllerUnitTest extends MediaWikiUnitTestCase {
/**
* @dataProvider PageLinkedTitleMutedByUserDataProvider
* @covers ::isPageLinkedTitleMutedByUser
* @param EchoEvent $echoEvent
* @param Title $title
* @param User $user
* @param bool $expected
*/
public function testIsPageLinkedTitleMutedByUser(
EchoEvent $echoEvent, User $user, bool $expected ): void {
Title $title, User $user, bool $expected ): void {
$wrapper = TestingAccessWrapper::newFromClass( EchoNotificationController::class );
$wrapper->mutedPageLinkedTitlesCache = $this->getMapCacheLruMock();
$this->assertSame(
$expected,
$wrapper->isPageLinkedTitleMutedByUser( $echoEvent, $user )
$wrapper->isPageLinkedTitleMutedByUser( $title, $user )
);
}
public function PageLinkedTitleMutedByUserDataProvider() :array {
return [
[
$this->getMockEvent( 123 ),
$this->getMockTitle( 123 ),
$this->getMockUser( [] ),
false
],
[
$this->getMockEvent( 123 ),
$this->getMockTitle( 123 ),
$this->getMockUser( [ 123, 456, 789 ] ),
true
],
[
$this->getMockEvent( 456 ),
$this->getMockTitle( 456 ),
$this->getMockUser( [ 489 ] ),
false
]
@ -45,20 +45,13 @@ class NotificationControllerUnitTest extends MediaWikiUnitTestCase {
];
}
private function getMockEvent( int $articleID ) {
$event = $this->getMockBuilder( EchoEvent::class )
->disableOriginalConstructor()
->getMock();
$event->method( 'getAgent' )
->willReturn( $this->getMockUser() );
private function getMockTitle( int $articleID ) {
$title = $this->getMockBuilder( Title::class )
->disableOriginalConstructor()
->getMock();
$title->method( 'getArticleID' )
->willReturn( $articleID );
$event->method( 'getTitle' )
->willReturn( $title );
return $event;
return $title;
}
private function getMockUser( $mutedTitlePreferences = [] ) {