summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'Echo/includes')
-rw-r--r--Echo/includes/AttributeManager.php68
-rw-r--r--Echo/includes/Bundleable.php2
-rw-r--r--Echo/includes/Bundler.php2
-rw-r--r--Echo/includes/DataOutputFormatter.php26
-rw-r--r--Echo/includes/DeferredMarkAsDeletedUpdate.php5
-rw-r--r--Echo/includes/DiscussionParser.php194
-rw-r--r--Echo/includes/EchoCachedList.php22
-rw-r--r--Echo/includes/EchoContainmentSet.php24
-rw-r--r--Echo/includes/EchoDbFactory.php22
-rw-r--r--Echo/includes/EchoDiffParser.php16
-rw-r--r--Echo/includes/EchoHooks.php775
-rw-r--r--Echo/includes/EchoOnWikiList.php6
-rw-r--r--Echo/includes/EchoServices.php40
-rw-r--r--Echo/includes/EchoSummaryParser.php5
-rw-r--r--Echo/includes/EmailBatch.php91
-rw-r--r--Echo/includes/EventLogging.php2
-rw-r--r--Echo/includes/ForeignNotifications.php11
-rw-r--r--Echo/includes/ForeignWikiRequest.php28
-rw-r--r--Echo/includes/NotifUser.php193
-rw-r--r--Echo/includes/Notifier.php6
-rw-r--r--Echo/includes/Push/NotificationRequestJob.php26
-rw-r--r--Echo/includes/Push/NotificationServiceClient.php86
-rw-r--r--Echo/includes/Push/PushNotifier.php47
-rw-r--r--Echo/includes/Push/Subscription.php57
-rw-r--r--Echo/includes/Push/SubscriptionManager.php123
-rw-r--r--Echo/includes/ResourceLoaderEchoImageModule.php2
-rw-r--r--Echo/includes/SeenTime.php41
-rw-r--r--Echo/includes/UnreadWikis.php8
-rw-r--r--Echo/includes/UserLocator.php23
-rw-r--r--Echo/includes/api/ApiCrossWiki.php8
-rw-r--r--Echo/includes/api/ApiEchoArticleReminder.php3
-rw-r--r--Echo/includes/api/ApiEchoMarkRead.php2
-rw-r--r--Echo/includes/api/ApiEchoMarkSeen.php28
-rw-r--r--Echo/includes/api/ApiEchoMute.php130
-rw-r--r--Echo/includes/api/ApiEchoNotifications.php95
-rw-r--r--Echo/includes/api/ApiEchoUnreadNotificationPages.php17
-rw-r--r--Echo/includes/api/Push/ApiEchoPushSubscriptions.php103
-rw-r--r--Echo/includes/api/Push/ApiEchoPushSubscriptionsCreate.php115
-rw-r--r--Echo/includes/api/Push/ApiEchoPushSubscriptionsDelete.php103
-rw-r--r--Echo/includes/cache/LocalCache.php8
-rw-r--r--Echo/includes/cache/RevisionLocalCache.php9
-rw-r--r--Echo/includes/cache/TitleLocalCache.php2
-rw-r--r--Echo/includes/controller/ModerationController.php8
-rw-r--r--Echo/includes/controller/NotificationController.php103
-rw-r--r--Echo/includes/formatters/EchoEventDigestFormatter.php12
-rw-r--r--Echo/includes/formatters/EchoEventFormatter.php12
-rw-r--r--Echo/includes/formatters/EchoForeignPresentationModel.php5
-rw-r--r--Echo/includes/formatters/EchoHtmlDigestEmailFormatter.php2
-rw-r--r--Echo/includes/formatters/EchoIcon.php4
-rw-r--r--Echo/includes/formatters/EchoModelFormatter.php4
-rw-r--r--Echo/includes/formatters/EchoPlainTextEmailFormatter.php2
-rw-r--r--Echo/includes/formatters/EditUserTalkPresentationModel.php36
-rw-r--r--Echo/includes/formatters/EventPresentationModel.php28
-rw-r--r--Echo/includes/formatters/MentionInSummaryPresentationModel.php10
-rw-r--r--Echo/includes/formatters/MentionPresentationModel.php47
-rw-r--r--Echo/includes/formatters/MentionStatusPresentationModel.php18
-rw-r--r--Echo/includes/formatters/PageLinkedPresentationModel.php71
-rw-r--r--Echo/includes/formatters/PresentationModelSection.php (renamed from Echo/includes/formatters/PresentationModelSectionTrait.php)75
-rw-r--r--Echo/includes/formatters/RevertedPresentationModel.php15
-rw-r--r--Echo/includes/formatters/SpecialNotificationsFormatter.php5
-rw-r--r--Echo/includes/formatters/UserRightsPresentationModel.php2
-rw-r--r--Echo/includes/formatters/WatchlistChangePresentationModel.php103
-rw-r--r--Echo/includes/gateway/UserNotificationGateway.php32
-rw-r--r--Echo/includes/iterator/NotRecursiveIterator.php1
-rw-r--r--Echo/includes/jobs/NotificationDeleteJob.php16
-rw-r--r--Echo/includes/jobs/NotificationJob.php10
-rw-r--r--Echo/includes/mapper/AbstractMapper.php7
-rw-r--r--Echo/includes/mapper/EventMapper.php117
-rw-r--r--Echo/includes/mapper/NotificationMapper.php128
-rw-r--r--Echo/includes/mapper/TargetPageMapper.php4
-rw-r--r--Echo/includes/model/Event.php159
-rw-r--r--Echo/includes/model/Notification.php74
-rw-r--r--Echo/includes/model/TargetPage.php2
-rw-r--r--Echo/includes/ooui/LabelIconWidget.php18
-rw-r--r--Echo/includes/schemaUpdate.php12
-rw-r--r--Echo/includes/special/NotificationPager.php19
-rw-r--r--Echo/includes/special/SpecialDisplayNotificationsConfiguration.php40
-rw-r--r--Echo/includes/special/SpecialNotifications.php13
-rw-r--r--Echo/includes/special/SpecialNotificationsMarkRead.php6
79 files changed, 2618 insertions, 1176 deletions
diff --git a/Echo/includes/AttributeManager.php b/Echo/includes/AttributeManager.php
index 72e609b3..9851c8b3 100644
--- a/Echo/includes/AttributeManager.php
+++ b/Echo/includes/AttributeManager.php
@@ -54,7 +54,7 @@ class EchoAttributeManager {
* An EchoAttributeManager instance created from global variables
* @var self
*/
- protected static $globalVarInstance = null;
+ protected static $globalVarInstance;
/**
* @param array[] $notifications Notification attributes
@@ -119,9 +119,9 @@ class EchoAttributeManager {
public function getUserCallable( $type, $locator = self::ATTR_LOCATORS ) {
if ( isset( $this->notifications[$type][$locator] ) ) {
return (array)$this->notifications[$type][$locator];
- } else {
- return [];
}
+
+ return [];
}
/**
@@ -132,20 +132,15 @@ class EchoAttributeManager {
* @return string[]
*/
public function getUserEnabledEvents( User $user, $notifyType ) {
- $eventTypesToLoad = $this->notifications;
- foreach ( $eventTypesToLoad as $eventType => $eventData ) {
- $category = $this->getNotificationCategory( $eventType );
- // Make sure the user is eligible to receive this type of notification
- if ( !$this->getCategoryEligibility( $user, $category ) ) {
- unset( $eventTypesToLoad[$eventType] );
- }
- if ( !$user->getOption( 'echo-subscriptions-' . $notifyType . '-' . $category ) ) {
- unset( $eventTypesToLoad[$eventType] );
+ return array_values( array_filter(
+ array_keys( $this->notifications ),
+ function ( $eventType ) use ( $user, $notifyType ) {
+ $category = $this->getNotificationCategory( $eventType );
+ return $this->isNotifyTypeAvailableForCategory( $category, $notifyType ) &&
+ $this->getCategoryEligibility( $user, $category ) &&
+ $user->getOption( "echo-subscriptions-$notifyType-$category" );
}
- }
- $eventTypes = array_keys( $eventTypesToLoad );
-
- return $eventTypes;
+ ) );
}
/**
@@ -308,6 +303,22 @@ class EchoAttributeManager {
}
/**
+ * Get notify type availability for all notify types for a given category.
+ *
+ * This means whether users *can* turn notifications for this category and format
+ * on, regardless of the default or a particular user's preferences.
+ *
+ * @param string $category Category name
+ * @return array [ 'web' => bool, 'email' => bool ]
+ */
+ public function getNotifyTypeAvailabilityForCategory( $category ) {
+ return array_merge(
+ $this->defaultNotifyTypeAvailability,
+ $this->notifyTypeAvailabilityByCategory[$category] ?? []
+ );
+ }
+
+ /**
* Checks whether the specified notify type is available for the specified
* category.
*
@@ -319,11 +330,7 @@ class EchoAttributeManager {
* @return bool
*/
public function isNotifyTypeAvailableForCategory( $category, $notifyType ) {
- if ( isset( $this->notifyTypeAvailabilityByCategory[$category][$notifyType] ) ) {
- return $this->notifyTypeAvailabilityByCategory[$category][$notifyType];
- } else {
- return $this->defaultNotifyTypeAvailability[$notifyType];
- }
+ return $this->getNotifyTypeAvailabilityForCategory( $category )[$notifyType];
}
/**
@@ -367,11 +374,22 @@ class EchoAttributeManager {
* @return string
*/
public function getNotificationSection( $notificationType ) {
- if ( isset( $this->notifications[$notificationType]['section'] ) ) {
- return $this->notifications[$notificationType]['section'];
- }
+ return $this->notifications[$notificationType]['section'] ?? 'alert';
+ }
- return 'alert';
+ /**
+ * Get notification types that allow their own agent to be notified.
+ *
+ * @return string[] Notification types
+ */
+ public function getNotifyAgentEvents() {
+ $events = [];
+ foreach ( $this->notifications as $event => $attribs ) {
+ if ( $attribs['canNotifyAgent'] ?? false ) {
+ $events[] = $event;
+ }
+ }
+ return $events;
}
}
diff --git a/Echo/includes/Bundleable.php b/Echo/includes/Bundleable.php
index 1610328e..c68b5705 100644
--- a/Echo/includes/Bundleable.php
+++ b/Echo/includes/Bundleable.php
@@ -18,7 +18,7 @@ interface Bundleable {
/**
* @param Bundleable[] $bundleables other object that have been bundled with this one
*/
- public function setBundledElements( $bundleables );
+ public function setBundledElements( array $bundleables );
/**
* @return mixed the key by which this object should be sorted during the bundling process
diff --git a/Echo/includes/Bundler.php b/Echo/includes/Bundler.php
index 5c162687..f91bece5 100644
--- a/Echo/includes/Bundler.php
+++ b/Echo/includes/Bundler.php
@@ -20,7 +20,7 @@ class Bundler {
* @param Bundleable[] $bundleables
* @return Bundleable[] Grouped notifications sorted by timestamp DESC
*/
- public function bundle( $bundleables ) {
+ public function bundle( array $bundleables ) {
$groups = [];
$bundled = [];
diff --git a/Echo/includes/DataOutputFormatter.php b/Echo/includes/DataOutputFormatter.php
index 7e0badd1..1b4abfd9 100644
--- a/Echo/includes/DataOutputFormatter.php
+++ b/Echo/includes/DataOutputFormatter.php
@@ -1,5 +1,7 @@
<?php
+use MediaWiki\Revision\RevisionRecord;
+
/**
* Utility class that formats a notification in the format specified
*/
@@ -9,10 +11,10 @@ class EchoDataOutputFormatter {
* @var string[] type => class
*/
protected static $formatters = [
- 'flyout' => 'EchoFlyoutFormatter',
- 'model' => 'EchoModelFormatter',
- 'special' => 'SpecialNotificationsFormatter',
- 'html' => 'SpecialNotificationsFormatter',
+ 'flyout' => EchoFlyoutFormatter::class,
+ 'model' => EchoModelFormatter::class,
+ 'special' => SpecialNotificationsFormatter::class,
+ 'html' => SpecialNotificationsFormatter::class,
];
/**
@@ -31,14 +33,14 @@ class EchoDataOutputFormatter {
*/
public static function formatOutput(
EchoNotification $notification,
- $format = false,
+ $format,
User $user,
Language $lang
) {
$event = $notification->getEvent();
$timestamp = $notification->getTimestamp();
$utcTimestampIso8601 = wfTimestamp( TS_ISO_8601, $timestamp );
- $utcTimestampUnix = wfTimestamp( TS_UNIX, $timestamp );
+ $utcTimestampUnix = (int)wfTimestamp( TS_UNIX, $timestamp );
$utcTimestampMW = wfTimestamp( TS_MW, $timestamp );
$bundledIds = null;
@@ -57,7 +59,7 @@ class EchoDataOutputFormatter {
$timestampMw = self::getUserLocalTime( $user, $timestamp );
// Start creating date section header
- $now = wfTimestamp();
+ $now = (int)wfTimestamp();
$dateFormat = substr( $timestampMw, 0, 8 );
$timeDiff = $now - $utcTimestampUnix;
// Most notifications would be more than two days ago, check this
@@ -107,7 +109,7 @@ class EchoDataOutputFormatter {
if ( $title ) {
$output['title'] = [
'full' => $title->getPrefixedText(),
- 'namespace' => $title->getNSText(),
+ 'namespace' => $title->getNsText(),
'namespace-key' => $title->getNamespace(),
'text' => $title->getText(),
];
@@ -115,7 +117,7 @@ class EchoDataOutputFormatter {
$agent = $event->getAgent();
if ( $agent ) {
- if ( $event->userCan( Revision::DELETED_USER, $user ) ) {
+ if ( $event->userCan( RevisionRecord::DELETED_USER, $user ) ) {
$output['agent'] = [
'id' => $agent->getId(),
'name' => $agent->getName(),
@@ -185,9 +187,9 @@ class EchoDataOutputFormatter {
/** @var EchoEventFormatter $formatter */
$formatter = new $class( $user, $lang );
return $formatter->format( $event );
- } else {
- return false;
}
+
+ return false;
}
/**
@@ -208,7 +210,7 @@ class EchoDataOutputFormatter {
* Helper function for converting UTC timezone to a user's timezone
*
* @param User $user
- * @param string $ts
+ * @param string|int $ts
* @param int $format output format
*
* @return string
diff --git a/Echo/includes/DeferredMarkAsDeletedUpdate.php b/Echo/includes/DeferredMarkAsDeletedUpdate.php
index 8b0ba1a7..ffd584d8 100644
--- a/Echo/includes/DeferredMarkAsDeletedUpdate.php
+++ b/Echo/includes/DeferredMarkAsDeletedUpdate.php
@@ -37,10 +37,11 @@ class EchoDeferredMarkAsDeletedUpdate implements DeferrableUpdate {
function ( EchoEvent $event ) {
if ( !$event->getTitle() && $event->getTitle( true ) ) {
// It is very likely this event was found
- // unreaderable because of slave lag.
+ // unreaderable because of replica lag.
// Do not moderate it at this time.
LoggerFactory::getInstance( 'Echo' )->debug(
- 'EchoDeferredMarkAsDeletedUpdate: Event {eventId} was found unrenderable but its associated title exists on Master. Skipping.',
+ 'EchoDeferredMarkAsDeletedUpdate: Event {eventId} was found unrenderable ' .
+ ' but its associated title exists on Master. Skipping.',
[
'eventId' => $event->getId(),
'title' => $event->getTitle()->getPrefixedText(),
diff --git a/Echo/includes/DiscussionParser.php b/Echo/includes/DiscussionParser.php
index f83e80f2..4a98b277 100644
--- a/Echo/includes/DiscussionParser.php
+++ b/Echo/includes/DiscussionParser.php
@@ -1,32 +1,35 @@
<?php
use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Revision\SlotRecord;
abstract class EchoDiscussionParser {
const HEADER_REGEX = '^(==+)\h*([^=].*)\h*\1$';
- static protected $timestampRegex;
- static protected $revisionInterpretationCache = [];
- static protected $diffParser;
+ protected static $timestampRegex;
+ protected static $revisionInterpretationCache = [];
+ protected static $diffParser;
/**
- * Given a Revision object, generates EchoEvent objects for
+ * Given a RevisionRecord object, generates EchoEvent objects for
* the discussion-related actions that occurred in that Revision.
*
- * @param Revision $revision
+ * @param RevisionRecord $revision
* @param bool $isRevert
* @return null
*/
- static function generateEventsForRevision( Revision $revision, $isRevert ) {
+ public static function generateEventsForRevision( RevisionRecord $revision, $isRevert ) {
global $wgEchoMentionsOnMultipleSectionEdits;
global $wgEchoMentionOnChanges;
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
- // use slave database if there is a previous revision
- if ( $revision->getPrevious() ) {
- $title = Title::newFromID( $revision->getPage() );
+ // use replica database if there is a previous revision
+ if ( $store->getPreviousRevision( $revision ) ) {
+ $title = Title::newFromID( $revision->getPageId() );
// use master database for new page
} else {
- $title = Title::newFromID( $revision->getPage(), Title::GAID_FOR_UPDATE );
+ $title = Title::newFromID( $revision->getPageId(), Title::GAID_FOR_UPDATE );
}
// not a valid title
@@ -36,8 +39,8 @@ abstract class EchoDiscussionParser {
$interpretation = self::getChangeInterpretationForRevision( $revision );
- $userID = $revision->getUser();
- $userName = $revision->getUserText();
+ $userID = $revision->getUser()->getId();
+ $userName = $revision->getUser()->getName();
$user = $userID != 0 ? User::newFromId( $userID ) : User::newFromName( $userName, false );
foreach ( $interpretation as $action ) {
@@ -58,8 +61,8 @@ abstract class EchoDiscussionParser {
self::generateMentionEvents( $action['header'], $userLinks, $content, $revision, $user );
} elseif ( $action['type'] === 'unknown-signed-change' ) {
$userLinks = array_diff_key(
- self::getUserLinks( $action['new_content'], $title ) ?: [],
- self::getUserLinks( $action['old_content'], $title ) ?: []
+ self::getUserLinks( $action['new_content'], $title ),
+ self::getUserLinks( $action['old_content'], $title )
);
$header = self::extractHeader( $action['full-section'] );
@@ -74,12 +77,16 @@ abstract class EchoDiscussionParser {
// If the recipient is a valid non-anonymous user and hasn't turned
// off their notifications, generate a talk page post Echo notification.
if ( $notifyUser && $notifyUser->getId() ) {
+ $permManager = MediaWikiServices::getInstance()->getPermissionManager();
// If this is a minor edit, only notify if the agent doesn't have talk page minor
// edit notification blocked
- if ( !$revision->isMinor() || !$user->isAllowed( 'nominornewtalk' ) ) {
+ if ( !$revision->isMinor() || !$permManager->userHasRight( $user, 'nominornewtalk' ) ) {
$section = self::detectSectionTitleAndText( $interpretation, $title );
if ( $section['section-text'] === '' ) {
- $section['section-text'] = $revision->getComment();
+ $comment = $revision->getComment( RevisionRecord::FOR_PUBLIC, $notifyUser );
+ if ( $comment ) {
+ $section['section-text'] = $comment->text;
+ }
}
EchoEvent::create( [
'type' => 'edit-user-talk',
@@ -102,7 +109,7 @@ abstract class EchoDiscussionParser {
if ( $wgEchoMaxMentionsInEditSummary > 0 && !$user->isBot() && !$isRevert ) {
$summaryParser = new EchoSummaryParser();
- $usersInSummary = $summaryParser->parse( $revision->getComment() );
+ $usersInSummary = $summaryParser->parse( $revision->getComment()->text );
// Don't allow pinging yourself
unset( $usersInSummary[$userName] );
@@ -117,7 +124,7 @@ abstract class EchoDiscussionParser {
if ( $count >= $wgEchoMaxMentionsInEditSummary ) {
break;
}
- $mentionedUsers[] = $summaryUser;
+ $mentionedUsers[$summaryUser->getId()] = $summaryUser->getId();
$count++;
}
@@ -189,19 +196,19 @@ abstract class EchoDiscussionParser {
* @param string $header The subject line for the discussion.
* @param int[] $userLinks
* @param string $content The content of the post, as a wikitext string.
- * @param Revision $revision
+ * @param RevisionRecord $revision
* @param User $agent The user who made the comment.
*/
public static function generateMentionEvents(
$header,
- $userLinks,
+ array $userLinks,
$content,
- Revision $revision,
+ RevisionRecord $revision,
User $agent
) {
global $wgEchoMaxMentionsCount, $wgEchoMentionStatusNotifications;
- $title = $revision->getTitle();
+ $title = Title::newFromLinkTarget( $revision->getPageAsLinkTarget() );
if ( !$title ) {
return;
}
@@ -212,7 +219,9 @@ abstract class EchoDiscussionParser {
return;
}
- $userMentions = self::getUserMentions( $title, $revision->getUser( Revision::RAW ), $userLinks );
+ $userMentions = self::getUserMentions(
+ $title, $revision->getUser( RevisionRecord::RAW )->getId(), $userLinks
+ );
$overallMentionsCount = self::getOverallUserMentionsCount( $userMentions );
if ( $overallMentionsCount === 0 ) {
return;
@@ -228,7 +237,6 @@ abstract class EchoDiscussionParser {
'extra' => [
'max-mentions' => $wgEchoMaxMentionsCount,
'section-title' => $header,
- 'notifyAgent' => true
],
'agent' => $agent,
] );
@@ -261,7 +269,6 @@ abstract class EchoDiscussionParser {
'subject-name' => User::newFromId( $mentionedUserId )->getName(),
'section-title' => $header,
'revid' => $revision->getId(),
- 'notifyAgent' => true
],
'agent' => $agent,
] );
@@ -278,7 +285,6 @@ abstract class EchoDiscussionParser {
'subject-name' => $anonymousUser,
'section-title' => $header,
'revid' => $revision->getId(),
- 'notifyAgent' => true
],
'agent' => $agent,
] );
@@ -295,7 +301,6 @@ abstract class EchoDiscussionParser {
'subject-name' => $unknownUser,
'section-title' => $header,
'revid' => $revision->getId(),
- 'notifyAgent' => true
],
'agent' => $agent,
] );
@@ -304,7 +309,7 @@ abstract class EchoDiscussionParser {
}
}
- private static function getOverallUserMentionsCount( $userMentions ) {
+ private static function getOverallUserMentionsCount( array $userMentions ) {
return count( $userMentions, COUNT_RECURSIVE ) - count( $userMentions );
}
@@ -390,7 +395,7 @@ abstract class EchoDiscussionParser {
/**
* @param string $content
* @param Title $title
- * @return int[]|false
+ * @return int[]
* Array of links in the user namespace with DBKey => ID.
*/
private static function getUserLinks( $content, Title $title ) {
@@ -398,7 +403,7 @@ abstract class EchoDiscussionParser {
$links = $output->getLinks();
if ( !isset( $links[NS_USER] ) || !is_array( $links[NS_USER] ) ) {
- return false;
+ return [];
}
return $links[NS_USER];
@@ -418,7 +423,7 @@ abstract class EchoDiscussionParser {
*
* @return ParserOutput
*/
- static function parseNonEditWikitext( $wikitext, Article $article ) {
+ private static function parseNonEditWikitext( $wikitext, Article $article ) {
static $cache = [];
$cacheKey = md5( $wikitext ) . ':' . $article->getTitle()->getPrefixedText();
@@ -427,10 +432,11 @@ abstract class EchoDiscussionParser {
return $cache[$cacheKey];
}
- global $wgParser;
- $options = new ParserOptions;
+ $parser = MediaWikiServices::getInstance()->getParser();
+
+ $options = new ParserOptions( $article->getContext()->getUser() );
$options->setTidy( true );
- $output = $wgParser->parse( $wikitext, $article->getTitle(), $options );
+ $output = $parser->parse( $wikitext, $article->getTitle(), $options );
$cache[$cacheKey] = $output;
return $output;
@@ -440,31 +446,38 @@ abstract class EchoDiscussionParser {
* Given a Revision object, returns a talk-page-centric interpretation
* of the changes made in it.
*
- * @param Revision $revision
+ * @param RevisionRecord $revision
* @see EchoDiscussionParser::interpretDiff
* @return array[] See {@see interpretDiff} for details.
*/
- static function getChangeInterpretationForRevision( Revision $revision ) {
+ private static function getChangeInterpretationForRevision( RevisionRecord $revision ) {
if ( $revision->getId() && isset( self::$revisionInterpretationCache[$revision->getId()] ) ) {
return self::$revisionInterpretationCache[$revision->getId()];
}
- $userID = $revision->getUser();
- $userName = $revision->getUserText();
+ $userIdentity = $revision->getUser();
+ $userID = $userIdentity ? $userIdentity->getId() : 0;
+ $userName = $userIdentity ? $userIdentity->getName() : '';
$user = $userID != 0 ? User::newFromId( $userID ) : User::newFromName( $userName, false );
+
$prevText = '';
if ( $revision->getParentId() ) {
- $prevRevision = Revision::newFromId( $revision->getParentId() );
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $prevRevision = $store->getRevisionById( $revision->getParentId() );
if ( $prevRevision ) {
- $prevText = ContentHandler::getContentText( $prevRevision->getContent() );
+ $prevText = ContentHandler::getContentText( $prevRevision->getContent( SlotRecord::MAIN ) ) ?: '';
}
}
$changes = self::getMachineReadableDiff(
$prevText,
- ContentHandler::getContentText( $revision->getContent() )
+ ContentHandler::getContentText( $revision->getContent( SlotRecord::MAIN ) )
+ );
+ $output = self::interpretDiff(
+ $changes,
+ $user->getName(),
+ Title::newFromLinkTarget( $revision->getPageAsLinkTarget() )
);
- $output = self::interpretDiff( $changes, $user->getName(), $revision->getTitle() );
self::$revisionInterpretationCache[$revision->getId()] = $output;
@@ -477,7 +490,7 @@ abstract class EchoDiscussionParser {
*
* @todo Expand recognisable actions.
*
- * @param array $changes Output of EchoEvent::getMachineReadableDiff
+ * @param array[] $changes Output of EchoEvent::getMachineReadableDiff
* @param string $username
* @param Title|null $title
* @return array[] Array of associative arrays.
@@ -505,7 +518,7 @@ abstract class EchoDiscussionParser {
* but it contains multiple signatures.
* - unknown: Unrecognised change type.
*/
- static function interpretDiff( $changes, $username, Title $title = null ) {
+ public static function interpretDiff( array $changes, $username, Title $title = null ) {
// One extra item in $changes for _info
$actions = [];
$signedSections = [];
@@ -557,7 +570,10 @@ abstract class EchoDiscussionParser {
if ( !empty( $sectionSignedUsers ) ) {
$signedSections[] = $sectionSpan;
if ( !$section['header'] ) {
- $fullSection = self::getFullSection( $changes['_info']['rhs'], $change['right-pos'] );
+ $fullSection = self::getFullSection(
+ $changes['_info']['rhs'],
+ $change['right-pos']
+ );
$section['header'] = self::extractHeader( $fullSection );
}
$actions[] = [
@@ -621,11 +637,11 @@ abstract class EchoDiscussionParser {
return $actions;
}
- static function getSignedUsers( $content, $title ) {
+ private static function getSignedUsers( $content, $title ) {
return array_keys( self::extractSignatures( $content, $title ) );
}
- static function hasNewSignature( $oldContent, $newContent, $username, $title ) {
+ private static function hasNewSignature( $oldContent, $newContent, $username, $title ) {
$oldSignedUsers = self::getSignedUsers( $oldContent, $title );
$newSignedUsers = self::getSignedUsers( $newContent, $title );
@@ -639,7 +655,7 @@ abstract class EchoDiscussionParser {
* @param array[] $actions
* @return array[] Converted actions
*/
- static function convertToUnknownSignedChanges( array $signedSections, array $actions ) {
+ private static function convertToUnknownSignedChanges( array $signedSections, array $actions ) {
return array_map( function ( $action ) use( $signedSections ) {
if (
$action['type'] === 'unknown-change' &&
@@ -657,7 +673,12 @@ abstract class EchoDiscussionParser {
}, $actions );
}
- static function isInSignedSection( $line, array $signedSections ) {
+ /**
+ * @param int $line
+ * @param array[] $signedSections
+ * @return bool
+ */
+ private static function isInSignedSection( $line, array $signedSections ) {
foreach ( $signedSections as $section ) {
if ( $line > $section[0] && $line <= $section[1] ) {
return true;
@@ -674,7 +695,7 @@ abstract class EchoDiscussionParser {
* @param int $offset The line to find the full section for.
* @return string Content of the section.
*/
- static function getFullSection( array $lines, $offset ) {
+ public static function getFullSection( array $lines, $offset ) {
$start = self::getSectionStartIndex( $offset, $lines );
$end = self::getSectionEndIndex( $offset, $lines );
$content = implode( "\n", array_slice( $lines, $start, $end - $start ) );
@@ -689,7 +710,7 @@ abstract class EchoDiscussionParser {
* @param string[] $lines
* @return int[] Tuple [$firstLine, $lastLine]
*/
- static function getSectionSpan( $offset, $lines ) {
+ private static function getSectionSpan( $offset, array $lines ) {
return [
self::getSectionStartIndex( $offset, $lines ),
self::getSectionEndIndex( $offset, $lines )
@@ -702,7 +723,7 @@ abstract class EchoDiscussionParser {
* @param string[] $lines
* @return int
*/
- static function getSectionStartIndex( $offset, array $lines ) {
+ private static function getSectionStartIndex( $offset, array $lines ) {
for ( $i = $offset - 1; $i >= 0; $i-- ) {
if ( self::getSectionCount( $lines[$i] ) ) {
break;
@@ -718,7 +739,7 @@ abstract class EchoDiscussionParser {
* @param array $lines
* @return int
*/
- static function getSectionEndIndex( $offset, array $lines ) {
+ private static function getSectionEndIndex( $offset, array $lines ) {
$lastLine = count( $lines );
for ( $i = $offset; $i < $lastLine; $i++ ) {
if ( self::getSectionCount( $lines[$i] ) ) {
@@ -735,13 +756,10 @@ abstract class EchoDiscussionParser {
* @param string $text The text.
* @return int Number of section headers found.
*/
- static function getSectionCount( $text ) {
+ public static function getSectionCount( $text ) {
$text = trim( $text );
- $matches = [];
- preg_match_all( '/' . self::HEADER_REGEX . '/um', $text, $matches );
-
- return count( $matches[0] );
+ return (int)preg_match_all( '/' . self::HEADER_REGEX . '/um', $text );
}
/**
@@ -750,7 +768,7 @@ abstract class EchoDiscussionParser {
* @param string $text The text of the section.
* @return string|false The title of the section or false if not found
*/
- static function extractHeader( $text ) {
+ public static function extractHeader( $text ) {
$text = trim( $text );
$matches = [];
@@ -812,7 +830,7 @@ abstract class EchoDiscussionParser {
* @param Title|null $title
* @return string
*/
- static function stripSignature( $text, Title $title = null ) {
+ private static function stripSignature( $text, Title $title = null ) {
$output = self::getUserFromLine( $text, $title );
if ( $output === false ) {
$timestampPos = self::getTimestampPosition( $text );
@@ -834,7 +852,7 @@ abstract class EchoDiscussionParser {
* @param string $text The text to strip out the section header from.
* @return string The same text, with the section header stripped out.
*/
- static function stripHeader( $text ) {
+ private static function stripHeader( $text ) {
$text = preg_replace( '/' . self::HEADER_REGEX . '/um', '', $text );
return $text;
@@ -849,7 +867,7 @@ abstract class EchoDiscussionParser {
* @param Title|null $title
* @return bool
*/
- static function isSignedComment( $text, $user = false, Title $title = null ) {
+ public static function isSignedComment( $text, $user = false, Title $title = null ) {
$userData = self::getUserFromLine( $text, $title );
if ( $userData === false ) {
@@ -869,7 +887,7 @@ abstract class EchoDiscussionParser {
* @param string $line The line to search for a signature on
* @return int|false Integer position
*/
- static function getTimestampPosition( $line ) {
+ public static function getTimestampPosition( $line ) {
$timestampRegex = self::getTimestampRegex();
$tsMatches = [];
if ( !preg_match(
@@ -901,7 +919,7 @@ abstract class EchoDiscussionParser {
* of a change, 'old_content' and 'new_content'
* * 'left_pos' and 'right_pos' (in lines) of the change.
*/
- static function getMachineReadableDiff( $oldText, $newText ) {
+ public static function getMachineReadableDiff( $oldText, $newText ) {
if ( !isset( self::$diffParser ) ) {
self::$diffParser = new EchoDiffParser;
}
@@ -917,7 +935,7 @@ abstract class EchoDiscussionParser {
* @return string[] Associative array, the key is the username, the value
* is the last signature that was found.
*/
- static function extractSignatures( $text, Title $title = null ) {
+ private static function extractSignatures( $text, Title $title = null ) {
$lines = explode( "\n", $text );
$output = [];
@@ -930,10 +948,7 @@ abstract class EchoDiscussionParser {
// Look for the last user link on the line.
$userData = self::getUserFromLine( $line, $title );
if ( $userData === false ) {
- // print "F\t$lineNumber\t$line\n";
continue;
- } else {
- // print "S\t$lineNumber\n";
}
list( $signaturePos, $user ) = $userData;
@@ -976,7 +991,7 @@ abstract class EchoDiscussionParser {
* empty, but Parser::pstPass2 should have normalized that for us
* already.
*/
- $match = explode( '|', $match );
+ $match = explode( '|', $match, 2 );
$title = Title::newFromText( $match[0] );
// figure out if we the link is related to a user
@@ -1008,7 +1023,7 @@ abstract class EchoDiscussionParser {
* - Second element is the normalised user name.
*/
public static function getUserFromLine( $line, Title $title = null ) {
- global $wgParser;
+ $parser = MediaWikiServices::getInstance()->getParser();
/*
* First we call extractUsersFromLine to get all the potential usernames
@@ -1023,11 +1038,11 @@ abstract class EchoDiscussionParser {
// discovered the signature from
// don't validate the username - anon (IP) is fine!
$user = User::newFromName( $username, false );
- $sig = $wgParser->preSaveTransform(
+ $sig = $parser->preSaveTransform(
'~~~',
$title ?: Title::newMainPage(),
$user,
- new ParserOptions()
+ new ParserOptions( $user )
);
// see if we can find this user's generated signature in the content
@@ -1047,12 +1062,12 @@ abstract class EchoDiscussionParser {
*
* @param string $line The line to search.
* @param string $linkPrefix The prefix to search for.
- * @param bool $failureOffset
+ * @param int|false $failureOffset
* @return array|false False for failure, array for success.
* - First element is the string offset of the link.
* - Second element is the user the link refers to.
*/
- static function getLinkFromLine( $line, $linkPrefix, $failureOffset = false ) {
+ private static function getLinkFromLine( $line, $linkPrefix, $failureOffset = false ) {
$offset = 0;
// If extraction failed at another offset, try again.
@@ -1068,14 +1083,12 @@ abstract class EchoDiscussionParser {
$linkPos = strripos( $line, $linkPrefix, $offset );
if ( $linkPos === false ) {
- // print "I\tNo match for $linkPrefix\n";
return false;
}
$linkUser = self::extractUserFromLink( $line, $linkPrefix, $linkPos );
if ( $linkUser === false ) {
- // print "E\tExtraction failed\t$linkPrefix\n";
// Look for another place.
return self::getLinkFromLine( $line, $linkPrefix, $linkPos );
} else {
@@ -1091,7 +1104,7 @@ abstract class EchoDiscussionParser {
* @param int $offset Optionally, the offset of the start of the link.
* @return bool|string Type description
*/
- static function extractUserFromLink( $text, $prefix, $offset = 0 ) {
+ private static function extractUserFromLink( $text, $prefix, $offset = 0 ) {
$userPart = substr( $text, strlen( $prefix ) + $offset );
$userMatches = [];
@@ -1101,8 +1114,6 @@ abstract class EchoDiscussionParser {
$userMatches
) ) {
// user link is invalid
- // print "I\tUser link invalid\t$userPart\n";
- // print "E\tCannot find user info to extract\n";
return false;
}
@@ -1113,7 +1124,6 @@ abstract class EchoDiscussionParser {
User::getCanonicalName( $user ) === false
) {
// Not a real username
- // print "E\tInvalid username\n";
return false;
}
@@ -1127,7 +1137,7 @@ abstract class EchoDiscussionParser {
* @throws MWException
* @return string regular expression fragment.
*/
- static function getTimestampRegex() {
+ public static function getTimestampRegex() {
if ( self::$timestampRegex !== null ) {
return self::$timestampRegex;
}
@@ -1135,26 +1145,26 @@ abstract class EchoDiscussionParser {
// Step 1: Get an exemplar timestamp
$title = Title::newMainPage();
$user = User::newFromName( 'Test' );
- $options = new ParserOptions;
+ $options = new ParserOptions( $user );
- global $wgParser;
+ $parser = MediaWikiServices::getInstance()->getParser();
$exemplarTimestamp =
- $wgParser->preSaveTransform( '~~~~~', $title, $user, $options );
+ $parser->preSaveTransform( '~~~~~', $title, $user, $options );
// Step 2: Generalise it
// Trim off the timezone to replace at the end
$output = $exemplarTimestamp;
$tzRegex = '/\h*\(\w+\)\h*$/';
$tzMatches = [];
- if ( preg_match( $tzRegex, $output, $tzMatches ) ) {
- $output = preg_replace( $tzRegex, '', $output );
+ if ( preg_match( $tzRegex, $output, $tzMatches, PREG_OFFSET_CAPTURE ) ) {
+ $output = substr( $output, 0, $tzMatches[0][1] );
}
$output = preg_quote( $output, '/' );
$output = preg_replace( '/[^\d\W]+/u', '[^\d\W]+', $output );
$output = preg_replace( '/\d+/u', '\d+', $output );
if ( $tzMatches ) {
- $output .= preg_quote( $tzMatches[0] );
+ $output .= preg_quote( $tzMatches[0][0] );
}
if ( !preg_match( "/$output/u", $exemplarTimestamp ) ) {
@@ -1174,9 +1184,9 @@ abstract class EchoDiscussionParser {
* @param Title|null $title Page from which the text snippet is being extracted
* @return string
*/
- static function getTextSnippet( $text, Language $lang, $length = 150, $title = null ) {
+ public static function getTextSnippet( $text, Language $lang, $length = 150, $title = null ) {
// Parse wikitext
- $html = MessageCache::singleton()->parse( $text, $title )->getText( [
+ $html = MediaWikiServices::getInstance()->getMessageCache()->parse( $text, $title )->getText( [
'enableSectionEditLinks' => false
] );
$plaintext = trim( Sanitizer::stripAllTags( $html ) );
@@ -1190,7 +1200,7 @@ abstract class EchoDiscussionParser {
* @param int $length Length in characters (not bytes); default 150
* @return string
*/
- static function getTextSnippetFromSummary( $text, Language $lang, $length = 150 ) {
+ public static function getTextSnippetFromSummary( $text, Language $lang, $length = 150 ) {
// Parse wikitext with summary parser
$html = Linker::formatLinksInComment( Sanitizer::escapeHtmlAllowEntities( $text ) );
$plaintext = trim( Sanitizer::stripAllTags( $html ) );
@@ -1200,12 +1210,12 @@ abstract class EchoDiscussionParser {
/**
* Extract an edit excerpt from a revision
*
- * @param Revision $revision
+ * @param RevisionRecord $revision
* @param Language $lang
* @param int $length Length in characters (not bytes); default 150
* @return string
*/
- public static function getEditExcerpt( Revision $revision, Language $lang, $length = 150 ) {
+ public static function getEditExcerpt( RevisionRecord $revision, Language $lang, $length = 150 ) {
$interpretation = self::getChangeInterpretationForRevision( $revision );
$section = self::detectSectionTitleAndText( $interpretation );
return $lang->truncateForVisual( $section['section-title'] . ' ' . $section['section-text'], $length );
diff --git a/Echo/includes/EchoCachedList.php b/Echo/includes/EchoCachedList.php
index b10342e8..fc4c159f 100644
--- a/Echo/includes/EchoCachedList.php
+++ b/Echo/includes/EchoCachedList.php
@@ -1,27 +1,37 @@
<?php
/**
- * Caches an EchoContainmentList within a BagOStuff(memcache, etc) to prevent needing
+ * Caches an EchoContainmentList within WANObjectCache to prevent needing
* to load the nested list from a potentially slow source (mysql, etc).
*/
class EchoCachedList implements EchoContainmentList {
const ONE_WEEK = 4233600;
const ONE_DAY = 86400;
+ /** @var WANObjectCache */
protected $cache;
+ /** @var string */
protected $partialCacheKey;
+ /** @var EchoContainmentList */
protected $nestedList;
+ /** @var int */
protected $timeout;
+ /** @var string[]|null */
private $result;
/**
- * @param BagOStuff $cache Bag to stored cached data in.
+ * @param WANObjectCache $cache Bag to stored cached data in.
* @param string $partialCacheKey Partial cache key, $nestedList->getCacheKey() will be appended
* to this to construct the cache key used.
* @param EchoContainmentList $nestedList The nested EchoContainmentList to cache the result of.
* @param int $timeout How long in seconds to cache the nested list, defaults to 1 week.
*/
- public function __construct( BagOStuff $cache, $partialCacheKey, EchoContainmentList $nestedList, $timeout = self::ONE_WEEK ) {
+ public function __construct(
+ WANObjectCache $cache,
+ $partialCacheKey,
+ EchoContainmentList $nestedList,
+ $timeout = self::ONE_WEEK
+ ) {
$this->cache = $cache;
$this->partialCacheKey = $partialCacheKey;
$this->nestedList = $nestedList;
@@ -57,6 +67,10 @@ class EchoCachedList implements EchoContainmentList {
* @inheritDoc
*/
public function getCacheKey() {
- return $this->partialCacheKey . '_' . $this->nestedList->getCacheKey();
+ return $this->cache->makeGlobalKey(
+ 'echo-containment-list',
+ $this->partialCacheKey,
+ $this->nestedList->getCacheKey()
+ );
}
}
diff --git a/Echo/includes/EchoContainmentSet.php b/Echo/includes/EchoContainmentSet.php
index 986379c3..2f79634e 100644
--- a/Echo/includes/EchoContainmentSet.php
+++ b/Echo/includes/EchoContainmentSet.php
@@ -67,18 +67,38 @@ class EchoContainmentSet {
}
/**
+ * Add a list of title IDs from a user preference to the set of lists
+ * checked by self::contains().
+ *
+ * @param string $preferenceName
+ */
+ public function addTitleIDsFromUserOption( string $preferenceName ) :void {
+ $preference = $this->recipient->getOption( $preferenceName, [] );
+ if ( !is_string( $preference ) ) {
+ // We expect the preference data to be saved as a string via the
+ // preferences form; if the user modified their data so it's no
+ // longer a string, ignore it.
+ return;
+ }
+ $titleIDs = preg_split( '/\n/', $preference, -1, PREG_SPLIT_NO_EMPTY );
+ $this->addArray( $titleIDs );
+ }
+
+ /**
* Add a list from a wiki page to the set of lists checked by self::contains(). Data
* from wiki pages is cached via the BagOStuff. Caching is disabled when passing a null
* $cache object.
*
* @param int $namespace An NS_* constant representing the mediawiki namespace of the page containing the list.
* @param string $title The title of the page containing the list.
- * @param BagOStuff|null $cache An object to cache the page with or null for no cache.
+ * @param WANObjectCache|null $cache An object to cache the page with or null for no cache.
* @param string $cacheKeyPrefix A prefix to be combined with the pages latest revision id and used as a cache key.
*
* @throws MWException
*/
- public function addOnWiki( $namespace, $title, BagOStuff $cache = null, $cacheKeyPrefix = '' ) {
+ public function addOnWiki(
+ $namespace, $title, WANObjectCache $cache = null, $cacheKeyPrefix = ''
+ ) {
$list = new EchoOnWikiList( $namespace, $title );
if ( $cache ) {
if ( $cacheKeyPrefix === '' ) {
diff --git a/Echo/includes/EchoDbFactory.php b/Echo/includes/EchoDbFactory.php
index 608f1b0d..831b9b54 100644
--- a/Echo/includes/EchoDbFactory.php
+++ b/Echo/includes/EchoDbFactory.php
@@ -81,19 +81,19 @@ class MWEchoDbFactory {
/**
* Get the database connection for Echo
* @param int $db Index of the connection to get
- * @param mixed $groups Query groups.
+ * @param string[] $groups Query groups.
* @return \Wikimedia\Rdbms\IDatabase
*/
- public function getEchoDb( $db, $groups = [] ) {
+ public function getEchoDb( $db, array $groups = [] ) {
return $this->getLB()->getConnection( $db, $groups );
}
/**
* @param int $db Index of the connection to get
- * @param array $groups Query groups
+ * @param string[] $groups Query groups
* @return bool|\Wikimedia\Rdbms\IDatabase false if no shared db is configured
*/
- public function getSharedDb( $db, $groups = [] ) {
+ public function getSharedDb( $db, array $groups = [] ) {
if ( !$this->shared ) {
return false;
}
@@ -109,11 +109,11 @@ class MWEchoDbFactory {
*
* @deprecated Use newFromDefault() instead to create a db factory
* @param int $db Index of the connection to get
- * @param mixed $groups Query groups.
+ * @param string[] $groups Query groups.
* @param string|bool $wiki The wiki ID, or false for the current wiki
* @return \Wikimedia\Rdbms\IDatabase
*/
- public static function getDB( $db, $groups = [], $wiki = false ) {
+ public static function getDB( $db, array $groups = [], $wiki = false ) {
global $wgEchoCluster;
$services = MediaWikiServices::getInstance();
@@ -133,15 +133,15 @@ class MWEchoDbFactory {
}
/**
- * Wait for the slaves of the database
+ * Wait for the replicas of the database
*/
- public function waitForSlaves() {
+ public function waitForReplicas() {
$this->waitFor( $this->getMasterPosition() );
}
/**
* Get the current master position for the wiki and echo
- * db when they have at least one slave in their cluster.
+ * db when they have at least one replica in their cluster.
*
* @return array
*/
@@ -153,7 +153,7 @@ class MWEchoDbFactory {
$lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
if ( $lb->getServerCount() > 1 ) {
$position['wikiDb'] = $lb->getMasterPos();
- };
+ }
if ( $this->cluster ) {
$lb = $this->getLB();
@@ -167,7 +167,7 @@ class MWEchoDbFactory {
/**
* Receives the output of self::getMasterPosition. Waits
- * for slaves to catch up to the master position at that
+ * for replicas to catch up to the master position at that
* point.
*
* @param array $position
diff --git a/Echo/includes/EchoDiffParser.php b/Echo/includes/EchoDiffParser.php
index 0670ef45..7f9a8aa6 100644
--- a/Echo/includes/EchoDiffParser.php
+++ b/Echo/includes/EchoDiffParser.php
@@ -158,9 +158,9 @@ class EchoDiffParser {
}
if ( $change === null ) {
return $this->changeSet;
- } else {
- return array_merge( $this->changeSet, $change->getChangeSet() );
}
+
+ return array_merge( $this->changeSet, $change->getChangeSet() );
}
/**
@@ -170,7 +170,7 @@ class EchoDiffParser {
* @param EchoDiffGroup|null $change Changes the immediately previous lines
*
* @throws MWException
- * @return EchoDiffGroup Changes to this line and any changed lines immediately previous
+ * @return EchoDiffGroup|null Changes to this line and any changed lines immediately previous
*/
protected function parseLine( $line, EchoDiffGroup $change = null ) {
if ( $line ) {
@@ -191,9 +191,11 @@ class EchoDiffParser {
$change = null;
}
// @@ -start,numLines +start,numLines @@
- list( , $left, $right ) = explode( ' ', $line );
- list( $this->leftPos ) = explode( ',', substr( $left, 1 ) );
- list( $this->rightPos ) = explode( ',', substr( $right, 1 ) );
+ list( , $left, $right ) = explode( ' ', $line, 3 );
+ list( $this->leftPos ) = explode( ',', substr( $left, 1 ), 2 );
+ list( $this->rightPos ) = explode( ',', substr( $right, 1 ), 2 );
+ $this->leftPos = (int)$this->leftPos;
+ $this->rightPos = (int)$this->rightPos;
// -1 because diff is 1 indexed and we are 0 indexed
$this->leftPos--;
@@ -214,6 +216,7 @@ class EchoDiffParser {
throw new MWException( 'Positional error: left' );
}
if ( $change === null ) {
+ // @phan-suppress-next-line PhanTypeMismatchArgument
$change = new EchoDiffGroup( $this->leftPos, $this->rightPos );
}
$change->subtract( $line );
@@ -225,6 +228,7 @@ class EchoDiffParser {
throw new MWException( 'Positional error: right' );
}
if ( $change === null ) {
+ // @phan-suppress-next-line PhanTypeMismatchArgument
$change = new EchoDiffGroup( $this->leftPos, $this->rightPos );
}
$change->add( $line );
diff --git a/Echo/includes/EchoHooks.php b/Echo/includes/EchoHooks.php
index 7a32f1fe..a71ddddc 100644
--- a/Echo/includes/EchoHooks.php
+++ b/Echo/includes/EchoHooks.php
@@ -1,15 +1,27 @@
<?php
-use MediaWiki\Auth\AuthManager;
+use MediaWiki\Hook\RecentChange_saveHook;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
+use MediaWiki\Preferences\MultiTitleFilter;
use MediaWiki\Preferences\MultiUsernameFilter;
+use MediaWiki\Revision\RevisionRecord;
+use MediaWiki\Storage\EditResult;
+use MediaWiki\User\UserIdentity;
-class EchoHooks {
+class EchoHooks implements RecentChange_saveHook {
/**
- * @var Revision
+ * @var RevisionRecord
*/
private static $lastRevertedRevision = null;
+ /**
+ * @var Config
+ */
+ private $config;
+
+ public function __construct( Config $config ) {
+ $this->config = $config;
+ }
/**
* @param array &$defaults
@@ -46,6 +58,12 @@ class EchoHooks {
'mention-success' => [
'web' => false,
],
+ 'watchlist' => [
+ 'web' => false,
+ ],
+ 'minor-watchlist' => [
+ 'web' => false,
+ ],
];
foreach ( $wgEchoNotificationCategories as $category => $categoryData ) {
@@ -66,7 +84,8 @@ class EchoHooks {
*/
public static function initEchoExtension() {
global $wgEchoNotifications, $wgEchoNotificationCategories, $wgEchoNotificationIcons,
- $wgEchoMentionStatusNotifications, $wgAllowArticleReminderNotification, $wgAPIModules;
+ $wgEchoMentionStatusNotifications, $wgAllowArticleReminderNotification, $wgAPIModules,
+ $wgEchoWatchlistNotifications, $wgEchoSeenTimeCacheType, $wgMainStash;
// allow extensions to define their own event
Hooks::run( 'BeforeCreateEchoEvent',
@@ -83,6 +102,17 @@ class EchoHooks {
unset( $wgEchoNotificationCategories['article-reminder'] );
unset( $wgAPIModules['echoarticlereminder'] );
}
+
+ // Only allow watchlist notifications when enabled
+ if ( !$wgEchoWatchlistNotifications ) {
+ unset( $wgEchoNotificationCategories['watchlist'] );
+ unset( $wgEchoNotificationCategories['minor-watchlist'] );
+ }
+
+ // Default $wgEchoSeenTimeCacheType to $wgMainStash
+ if ( $wgEchoSeenTimeCacheType === null ) {
+ $wgEchoSeenTimeCacheType = $wgMainStash;
+ }
}
/**
@@ -91,7 +121,6 @@ class EchoHooks {
*
* @param array &$testModules
* @param ResourceLoader $resourceLoader
- * @return bool
*/
public static function onResourceLoaderTestModules( array &$testModules,
ResourceLoader $resourceLoader
@@ -124,15 +153,13 @@ class EchoHooks {
}
}
}
-
- return true;
}
/**
* Handler for ResourceLoaderRegisterModules hook
- * @param ResourceLoader &$resourceLoader
+ * @param ResourceLoader $resourceLoader
*/
- public static function onResourceLoaderRegisterModules( ResourceLoader &$resourceLoader ) {
+ public static function onResourceLoaderRegisterModules( ResourceLoader $resourceLoader ) {
global $wgExtensionDirectory, $wgEchoNotificationIcons, $wgEchoSecondaryIcons;
$resourceLoader->register( 'ext.echo.emailicons', [
'class' => 'ResourceLoaderEchoImageModule',
@@ -155,7 +182,7 @@ class EchoHooks {
*/
public static function onLoadExtensionSchemaUpdates( DatabaseUpdater $updater ) {
global $wgEchoCluster;
- if ( $wgEchoCluster !== false ) {
+ if ( $wgEchoCluster ) {
// DatabaseUpdater does not support other databases, so skip
return;
}
@@ -192,7 +219,7 @@ class EchoHooks {
"$dir/db_patches/patch-drop-echo_target_page-etp_user.sql" );
}
- $updater->addExtensionField( 'echo_notification', 'notification_bundle_base',
+ $updater->addExtensionField( 'echo_notification', 'notification_bundle_hash',
"$dir/db_patches/patch-notification-bundling-field.sql" );
// This index was renamed twice, first from type_page to event_type and
// later from event_type to echo_event_type
@@ -202,8 +229,10 @@ class EchoHooks {
}
$updater->dropExtensionTable( 'echo_subscription',
"$dir/db_patches/patch-drop-echo_subscription.sql" );
- $updater->dropExtensionField( 'echo_event', 'event_timestamp',
- "$dir/db_patches/patch-drop-echo_event-event_timestamp.sql" );
+ if ( $updater->getDB()->getType() !== 'sqlite' ) {
+ $updater->dropExtensionField( 'echo_event', 'event_timestamp',
+ "$dir/db_patches/patch-drop-echo_event-event_timestamp.sql" );
+ }
$updater->addExtensionField( 'echo_email_batch', 'eeb_event_hash',
"$dir/db_patches/patch-email_batch-new-field.sql" );
$updater->addExtensionField( 'echo_event', 'event_page_id',
@@ -214,7 +243,7 @@ class EchoHooks {
"$dir/db_patches/patch-alter-user_timestamp-index.sql" );
$updater->addExtensionIndex( 'echo_notification', 'echo_notification_event',
"$dir/db_patches/patch-add-notification_event-index.sql" );
- $updater->addPostDatabaseUpdateMaintenance( 'RemoveOrphanedEvents' );
+ $updater->addPostDatabaseUpdateMaintenance( RemoveOrphanedEvents::class );
$updater->addExtensionField( 'echo_event', 'event_deleted',
"$dir/db_patches/patch-add-echo_event-event_deleted.sql" );
$updater->addExtensionIndex( 'echo_notification', 'echo_notification_user_read_timestamp',
@@ -225,6 +254,28 @@ class EchoHooks {
"$dir/db_patches/patch-add-event_page_id-index.sql" );
$updater->dropExtensionIndex( 'echo_notification', 'user_event',
"$dir/db_patches/patch-notification-pk.sql" );
+ // Can't use addPostDatabaseUpdateMaintenance() here because that would
+ // run the migration script after dropping the fields
+ $updater->addExtensionUpdate( [ 'runMaintenance', UpdateEchoSchemaForSuppression::class,
+ 'extensions/Echo/maintenance/updateEchoSchemaForSuppression.php' ] );
+ $updater->dropExtensionField( 'echo_event', 'event_page_namespace',
+ "$dir/db_patches/patch-drop-echo_event-event_page_namespace.sql" );
+ $updater->dropExtensionField( 'echo_event', 'event_page_title',
+ "$dir/db_patches/patch-drop-echo_event-event_page_title.sql" );
+ if ( $updater->getDB()->getType() !== 'sqlite' ) {
+ $updater->dropExtensionField( 'echo_notification', 'notification_bundle_base',
+ "$dir/db_patches/patch-drop-notification_bundle_base.sql" );
+ $updater->dropExtensionField( 'echo_notification', 'notification_bundle_display_hash',
+ "$dir/db_patches/patch-drop-notification_bundle_display_hash.sql" );
+ }
+ $updater->dropExtensionIndex( 'echo_notification', 'echo_notification_user_hash_timestamp',
+ "$dir/db_patches/patch-drop-user-hash-timestamp-index.sql" );
+
+ $updater->addExtensionTable( 'echo_push_provider', "$dir/db_patches/echo_push_provider.sql" );
+ $updater->addExtensionTable( 'echo_push_subscription', "$dir/db_patches/echo_push_subscription.sql" );
+
+ $updater->modifyExtensionField( 'echo_unread_wikis', 'euw_wiki',
+ "$dir/db_patches/patch-increase-varchar-echo_unread_wikis-euw_wiki.sql" );
}
/**
@@ -236,8 +287,6 @@ class EchoHooks {
* 'edit-user-talk-' + namespace + title, email digest/email bundling would use this hash as
* a key to identify bundle-able event. For web bundling, we bundle further based on user's
* visit to the overlay, we would generate a display hash based on the hash of $bundleString
- *
- * @return bool
*/
public static function onEchoGetBundleRules( $event, &$bundleString ) {
switch ( $event->getType() ) {
@@ -259,80 +308,59 @@ class EchoHooks {
case 'mention-failure':
$bundleString = 'mention-status-' . $event->getExtraParam( 'revid' );
break;
+ case 'watchlist-change':
+ case 'minor-watchlist-change':
+ $bundleString = 'watchlist-change';
+ if ( $event->getTitle() ) {
+ $bundleString .= '-' . $event->getTitle()->getNamespace()
+ . '-' . $event->getTitle()->getDBkey();
+ }
+ break;
}
-
- return true;
- }
-
- /**
- * Handler for the GetBetaFeaturePreferences hook.
- * @see https://www.mediawiki.org/wiki/Manual:Hooks/GetBetaFeaturePreferences
- *
- * @param User $user User to get preferences for
- * @param array &$preferences Preferences array
- *
- * @return bool true in all cases
- */
- public static function getBetaFeaturePreferences( User $user, array &$preferences ) {
- global $wgExtensionAssetsPath, $wgEchoUseCrossWikiBetaFeature, $wgEchoCrossWikiNotifications;
-
- if ( $wgEchoUseCrossWikiBetaFeature && $wgEchoCrossWikiNotifications ) {
- $preferences['echo-cross-wiki-notifications'] = [
- 'label-message' => 'echo-pref-beta-feature-cross-wiki-message',
- 'desc-message' => 'echo-pref-beta-feature-cross-wiki-description',
- // Paths to images that represents the feature.
- 'screenshot' => [
- 'rtl' => "$wgExtensionAssetsPath/Echo/images/betafeatures-icon-notifications-rtl.svg",
- 'ltr' => "$wgExtensionAssetsPath/Echo/images/betafeatures-icon-notifications-ltr.svg",
- ],
- 'info-link' => 'https://www.mediawiki.org/wiki/Special:Mylanguage/Help:Notifications/Cross-wiki',
- // Link to discussion about the feature - talk pages might work
- 'discussion-link' => 'https://www.mediawiki.org/wiki/Help_talk:Notifications',
- ];
- }
-
- return true;
}
/**
* Handler for GetPreferences hook.
- * @see http://www.mediawiki.org/wiki/Manual:Hooks/GetPreferences
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/GetPreferences
*
* @param User $user User to get preferences for
* @param array &$preferences Preferences array
*
* @throws MWException
- * @return bool true in all cases
*/
public static function getPreferences( $user, &$preferences ) {
global $wgEchoEnableEmailBatch,
$wgEchoNotifiers, $wgEchoNotificationCategories, $wgEchoNotifications,
- $wgAllowHTMLEmail, $wgEchoUseCrossWikiBetaFeature,
- $wgEchoCrossWikiNotifications, $wgEchoPerUserBlacklist;
+ $wgAllowHTMLEmail, $wgEchoPollForUpdates,
+ $wgEchoCrossWikiNotifications, $wgEchoPerUserBlacklist,
+ $wgEchoWatchlistNotifications;
$attributeManager = EchoAttributeManager::newFromGlobalVars();
// Show email frequency options
- $never = wfMessage( 'echo-pref-email-frequency-never' )->escaped();
- $immediately = wfMessage( 'echo-pref-email-frequency-immediately' )->escaped();
$freqOptions = [
- $never => EchoEmailFrequency::NEVER,
- $immediately => EchoEmailFrequency::IMMEDIATELY,
+ 'echo-pref-email-frequency-never' => EchoEmailFrequency::NEVER,
+ 'echo-pref-email-frequency-immediately' => EchoEmailFrequency::IMMEDIATELY,
];
// Only show digest options if email batch is enabled
if ( $wgEchoEnableEmailBatch ) {
- $daily = wfMessage( 'echo-pref-email-frequency-daily' )->escaped();
- $weekly = wfMessage( 'echo-pref-email-frequency-weekly' )->escaped();
$freqOptions += [
- $daily => EchoEmailFrequency::DAILY_DIGEST,
- $weekly => EchoEmailFrequency::WEEKLY_DIGEST,
+ 'echo-pref-email-frequency-daily' => EchoEmailFrequency::DAILY_DIGEST,
+ 'echo-pref-email-frequency-weekly' => EchoEmailFrequency::WEEKLY_DIGEST,
];
}
$preferences['echo-email-frequency'] = [
'type' => 'select',
'label-message' => 'echo-pref-send-me',
'section' => 'echo/emailsettings',
- 'options' => $freqOptions
+ 'options-messages' => $freqOptions
+ ];
+
+ $preferences['echo-dont-email-read-notifications'] = [
+ 'type' => 'toggle',
+ 'label-message' => 'echo-pref-dont-email-read-notifications',
+ 'section' => 'echo/emailsettings',
+ 'hide-if' => [ 'OR', [ '===', 'echo-email-frequency', '-1' ], [ '===', 'echo-email-frequency', '0' ] ]
];
// Display information about the user's currently set email address
@@ -343,9 +371,10 @@ class EchoHooks {
[],
[ 'returnto' => $prefsTitle->getFullText() ]
);
- $emailAddress = $user->getEmail() && $user->isAllowed( 'viewmyprivateinfo' )
+ $permManager = MediaWikiServices::getInstance()->getPermissionManager();
+ $emailAddress = $user->getEmail() && $permManager->userHasRight( $user, 'viewmyprivateinfo' )
? htmlspecialchars( $user->getEmail() ) : '';
- if ( $user->isAllowed( 'editmyprivateinfo' ) && self::isEmailChangeAllowed() ) {
+ if ( $permManager->userHasRight( $user, 'editmyprivateinfo' ) && self::isEmailChangeAllowed() ) {
if ( $emailAddress === '' ) {
$emailAddress .= $link;
} else {
@@ -368,9 +397,9 @@ class EchoHooks {
'type' => 'select',
'label-message' => 'echo-pref-email-format',
'section' => 'echo/emailsettings',
- 'options' => [
- wfMessage( 'echo-pref-email-format-html' )->escaped() => EchoEmailFormat::HTML,
- wfMessage( 'echo-pref-email-format-plain-text' )->escaped() => EchoEmailFormat::PLAIN_TEXT,
+ 'options-messages' => [
+ 'echo-pref-email-format-html' => EchoEmailFormat::HTML,
+ 'echo-pref-email-format-plain-text' => EchoEmailFormat::PLAIN_TEXT,
]
];
}
@@ -391,10 +420,11 @@ class EchoHooks {
asort( $categoriesAndPriorities );
$validSortedCategories = array_keys( $categoriesAndPriorities );
- // Show subscription options. IMPORTANT: 'echo-subscriptions-email-edit-user-talk' is a
- // virtual option, its value is saved to existing talk page notification option
- // 'enotifusertalkpages', see onUserLoadOptions() and onUserSaveOptions() for more
- // information on how it is handled. Doing it in this way, we can avoid keeping running
+ // Show subscription options. IMPORTANT: 'echo-subscriptions-email-edit-user-talk',
+ // 'echo-subscriptions-email-watchlist', and 'echo-subscriptions-email-minor-watchlist' are
+ // virtual options, their values are saved to existing notification options 'enotifusertalkpages',
+ // 'enotifwatchlistpages', and 'enotifminoredits', see onUserLoadOptions() and onUserSaveOptions()
+ // for more information on how it is handled. Doing it in this way, we can avoid keeping running
// massive data migration script to keep these two options synced when echo is enabled on
// new wikis or Echo is disabled and re-enabled for some reason. We can update the name
// if Echo is ever merged to core
@@ -450,7 +480,7 @@ class EchoHooks {
'tooltips' => $tooltips,
];
- if ( !$wgEchoUseCrossWikiBetaFeature && $wgEchoCrossWikiNotifications ) {
+ if ( $wgEchoCrossWikiNotifications ) {
$preferences['echo-cross-wiki-notifications'] = [
'type' => 'toggle',
'label-message' => 'echo-pref-cross-wiki-notifications',
@@ -458,17 +488,32 @@ class EchoHooks {
];
}
- // If we're using Echo to handle user talk page post notifications,
- // hide the old (non-Echo) preference for this. If Echo is moved to core
- // we'll want to remove this old user option entirely. For now, though,
+ if ( $wgEchoPollForUpdates ) {
+ $preferences['echo-show-poll-updates'] = [
+ 'type' => 'toggle',
+ 'label-message' => 'echo-pref-show-poll-updates',
+ 'help-message' => 'echo-pref-show-poll-updates-help',
+ 'section' => 'echo/echopollupdates'
+ ];
+ }
+
+ // If we're using Echo to handle user talk page post or watchlist notifications,
+ // hide the old (non-Echo) preferences for them. If Echo is moved to core
+ // we'll want to remove the old user options entirely. For now, though,
// we need to keep it defined in case Echo is ever uninstalled.
// Otherwise, that preference could be lost entirely. This hiding logic
- // is not abstracted since there is only a single preference in core
- // that is potentially made obsolete by Echo.
+ // is not abstracted since there are only three preferences in core
+ // that are potentially made obsolete by Echo.
if ( isset( $wgEchoNotifications['edit-user-talk'] ) ) {
$preferences['enotifusertalkpages']['type'] = 'hidden';
unset( $preferences['enotifusertalkpages']['section'] );
}
+ if ( $wgEchoWatchlistNotifications && isset( $wgEchoNotifications['watchlist-change'] ) ) {
+ $preferences['enotifwatchlistpages']['type'] = 'hidden';
+ unset( $preferences['enotifusertalkpages']['section'] );
+ $preferences['enotifminoredits']['type'] = 'hidden';
+ unset( $preferences['enotifminoredits']['section'] );
+ }
if ( $wgEchoPerUserBlacklist ) {
$preferences['echo-notifications-blacklist'] = [
@@ -477,9 +522,14 @@ class EchoHooks {
'section' => 'echo/blocknotificationslist',
'filter' => MultiUsernameFilter::class,
];
+ $preferences['echo-notifications-page-linked-title-muted-list'] = [
+ 'type' => 'titlesmultiselect',
+ 'label-message' => 'echo-pref-notifications-page-linked-title-muted-list',
+ 'section' => 'echo/mutedpageslist',
+ 'showMissing' => false,
+ 'filter' => ( new MultiTitleFilter( new TitleFactory() ) )
+ ];
}
-
- return true;
}
/**
@@ -487,71 +537,54 @@ class EchoHooks {
* @return bool
*/
private static function isEmailChangeAllowed() {
- return AuthManager::singleton()->allowsPropertyChange( 'emailaddress' );
+ return MediaWikiServices::getInstance()->getAuthManager()
+ ->allowsPropertyChange( 'emailaddress' );
}
/**
- * Handler for PageContentSaveComplete hook
- * @see http://www.mediawiki.org/wiki/Manual:Hooks/PageContentSaveComplete
+ * Handler for PageSaveComplete hook
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/PageSaveComplete
*
- * @param WikiPage &$wikiPage modified WikiPage
- * @param User &$user User who edited
- * @param Content $content New article text
+ * @param WikiPage $wikiPage modified WikiPage
+ * @param UserIdentity $userIdentity User who edited
* @param string $summary Edit summary
- * @param bool $minoredit Minor edit or not
- * @param bool $watchthis Watch this article?
- * @param string $sectionanchor Section that was edited
- * @param int &$flags Edit flags
- * @param Revision $revision Revision that was created
- * @param Status &$status
- * @param int $baseRevId
- * @param int $undidRevId
- *
- * @return bool true in all cases
+ * @param int $flags Edit flags
+ * @param RevisionRecord $revisionRecord RevisionRecord for the revision that was created
+ * @param EditResult $editResult
*/
- public static function onPageContentSaveComplete(
- WikiPage &$wikiPage,
- &$user,
- $content,
- $summary,
- $minoredit,
- $watchthis,
- $sectionanchor,
- &$flags,
- $revision,
- &$status,
- $baseRevId,
- $undidRevId = 0
+ public static function onPageSaveComplete(
+ WikiPage $wikiPage,
+ UserIdentity $userIdentity,
+ string $summary,
+ int $flags,
+ RevisionRecord $revisionRecord,
+ EditResult $editResult
) {
global $wgEchoNotifications;
- if ( !$revision ) {
- return true;
- }
-
- // unless status is "good" (not only ok, also no warnings or errors), we
- // probably shouldn't process it at all (e.g. null edits)
- if ( !$status->isGood() ) {
- return true;
+ if ( $editResult->isNullEdit() ) {
+ return;
}
$title = $wikiPage->getTitle();
+ $undidRevId = $editResult->getUndidRevId();
// Try to do this after the HTTP response
- DeferredUpdates::addCallableUpdate( function () use ( $revision, $undidRevId ) {
+ DeferredUpdates::addCallableUpdate( function () use ( $revisionRecord, $undidRevId ) {
// This check has to happen during deferred processing, otherwise $lastRevertedRevision
// will not be initialized.
$isRevert = $undidRevId > 0 ||
( self::$lastRevertedRevision &&
- self::$lastRevertedRevision->getId() === $revision->getId() );
- EchoDiscussionParser::generateEventsForRevision( $revision, $isRevert );
+ self::$lastRevertedRevision->getId() === $revisionRecord->getId() );
+ EchoDiscussionParser::generateEventsForRevision( $revisionRecord, $isRevert );
} );
+ $user = User::newFromIdentity( $userIdentity );
// If the user is not an IP and this is not a null edit,
// test for them reaching a congratulatory threshold
- $thresholds = [ 1, 10, 100, 1000, 10000, 100000, 1000000 ];
- if ( $user->isLoggedIn() && $status->value['revision'] ) {
- $thresholdCount = $user->getEditCount();
+ $thresholds = [ 1, 10, 100, 1000, 10000, 100000, 1000000, 10000000 ];
+ if ( $user->isLoggedIn() ) {
+ $thresholdCount = self::getEditCount( $user );
if ( in_array( $thresholdCount, $thresholds ) ) {
DeferredUpdates::addCallableUpdate( function () use ( $user, $title, $thresholdCount ) {
$notificationMapper = new EchoNotificationMapper();
@@ -577,7 +610,6 @@ class EchoHooks {
'agent' => $user,
// Edit threshold notifications are sent to the agent
'extra' => [
- 'notifyAgent' => true,
'editCount' => $thresholdCount,
]
]
@@ -589,15 +621,19 @@ class EchoHooks {
// Handle the case of someone undoing an edit, either through the
// 'undo' link in the article history or via the API.
if ( isset( $wgEchoNotifications['reverted'] ) && $undidRevId ) {
- $undidRevision = Revision::newFromId( $undidRevId );
- if ( $undidRevision && $undidRevision->getTitle()->equals( $title ) ) {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $undidRevision = $store->getRevisionById( $undidRevId );
+ if (
+ $undidRevision &&
+ Title::newFromLinkTarget( $undidRevision->getPageAsLinkTarget() )->equals( $title )
+ ) {
$victimId = $undidRevision->getUser();
if ( $victimId ) { // No notifications for anonymous users
EchoEvent::create( [
'type' => 'reverted',
'title' => $title,
'extra' => [
- 'revid' => $revision->getId(),
+ 'revid' => $revisionRecord->getId(),
'reverted-user-id' => $victimId,
'reverted-revision-id' => $undidRevId,
'method' => 'undo',
@@ -608,8 +644,20 @@ class EchoHooks {
}
}
}
+ }
- return true;
+ /**
+ * @param User $user
+ * @return int
+ */
+ private static function getEditCount( User $user ) {
+ // When this code runs from a maintenance script or unit tests
+ // the deferred update incrementing edit count runs right away
+ // so the edit count is right. Otherwise it lags by one.
+ if ( wfIsCLI() ) {
+ return $user->getEditCount();
+ }
+ return $user->getEditCount() + 1;
}
/**
@@ -619,7 +667,9 @@ class EchoHooks {
* @return bool true - send email, false - do not send email
*/
public static function onEchoAbortEmailNotification( $user, $event ) {
- if ( $event->getType() === 'edit-user-talk' ) {
+ global $wgEchoWatchlistEmailOncePerPage;
+ $type = $event->getType();
+ if ( $type === 'edit-user-talk' ) {
$extra = $event->getExtra();
if ( !empty( $extra['minoredit'] ) ) {
global $wgEnotifMinorEdits;
@@ -628,9 +678,23 @@ class EchoHooks {
return false;
}
}
+ // Mimic core code of only sending watchlist notification emails once per page
+ } elseif ( $type === "watchlist-change" || $type === "minor-watchlist-change" ) {
+ if ( !$wgEchoWatchlistEmailOncePerPage ) {
+ // Don't care about rate limiting
+ return true;
+ }
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $ts = $store->getWatchedItem( $user, $event->getTitle() )->getNotificationTimestamp();
+ // if (ts != null) is not sufficient because, if $wgEchoUseJobQueue is set,
+ // wl_notificationtimestamp will have already been set for the new edit
+ // by the time this code runs.
+ if ( $ts !== null && $ts !== $event->getExtraParam( "timestamp" ) ) {
+ // User has already seen an email for this page before
+ return false;
+ }
}
-
- // Proceed to send talk page notification email
+ // Proceed to send notification email
return true;
}
@@ -652,10 +716,9 @@ class EchoHooks {
/**
* Handler for LocalUserCreated hook.
- * @see http://www.mediawiki.org/wiki/Manual:Hooks/LocalUserCreated
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/LocalUserCreated
* @param User $user User object that was created.
* @param bool $autocreated True when account was auto-created
- * @return bool
*/
public static function onLocalUserCreated( $user, $autocreated ) {
if ( !$autocreated ) {
@@ -667,10 +730,6 @@ class EchoHooks {
EchoEvent::create( [
'type' => 'welcome',
'agent' => $user,
- // Welcome notification is sent to the agent
- 'extra' => [
- 'notifyAgent' => true
- ]
] );
}
@@ -678,13 +737,11 @@ class EchoHooks {
// Set seen time to UNIX epoch, so initially all notifications are unseen.
$seenTime->setTime( wfTimestamp( TS_MW, 1 ), 'all' );
-
- return true;
}
/**
* Handler for UserGroupsChanged hook.
- * @see http://www.mediawiki.org/wiki/Manual:Hooks/UserGroupsChanged
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/UserGroupsChanged
*
* @param User $user user that was changed
* @param string[] $add strings corresponding to groups added
@@ -693,24 +750,22 @@ class EchoHooks {
* @param string|bool $reason Reason given by the user changing the rights
* @param array $oldUGMs
* @param array $newUGMs
- *
- * @return bool
*/
public static function onUserGroupsChanged( $user, $add, $remove, $performer,
$reason = false, array $oldUGMs = [], array $newUGMs = [] ) {
if ( !$performer ) {
// TODO: Implement support for autopromotion
- return true;
+ return;
}
if ( !$user instanceof User ) {
// TODO: Support UserRightsProxy
- return true;
+ return;
}
if ( $user->equals( $performer ) ) {
// Don't notify for self changes
- return true;
+ return;
}
// If any old groups are in $add, those groups are having their expiry
@@ -732,7 +787,7 @@ class EchoHooks {
[
'type' => 'user-rights',
'extra' => [
- 'user' => $user->getID(),
+ 'user' => $user->getId(),
'expiry-changed' => $expiryChanged,
'reason' => $reason,
],
@@ -746,7 +801,7 @@ class EchoHooks {
[
'type' => 'user-rights',
'extra' => [
- 'user' => $user->getID(),
+ 'user' => $user->getId(),
'add' => $reallyAdded,
'remove' => $remove,
'reason' => $reason,
@@ -755,17 +810,14 @@ class EchoHooks {
]
);
}
-
- return true;
}
/**
* Handler for LinksUpdateAfterInsert hook.
- * @see http://www.mediawiki.org/wiki/Manual:Hooks/LinksUpdateAfterInsert
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/LinksUpdateAfterInsert
* @param LinksUpdate $linksUpdate
* @param string $table
- * @param array $insertions
- * @return bool
+ * @param array[] $insertions
*/
public static function onLinksUpdateAfterInsert( $linksUpdate, $table, $insertions ) {
global $wgRequest;
@@ -775,7 +827,7 @@ class EchoHooks {
// @Todo Implement a better solution so it doesn't depend on the checking of
// a specific set of request variables
if ( $wgRequest->getVal( 'wpUndidRevision' ) || $wgRequest->getVal( 'action' ) == 'rollback' ) {
- return true;
+ return;
}
// Handle only
@@ -786,12 +838,12 @@ class EchoHooks {
if ( $table !== 'pagelinks' || !MWNamespace::isContent( $linksUpdate->mTitle->getNamespace() )
|| !$linksUpdate->mRecursive || $linksUpdate->mTitle->isRedirect()
) {
- return true;
+ return;
}
- $revision = $linksUpdate->getRevision();
- $revid = $revision ? $revision->getId() : null;
- $user = $revision ? $revision->getRevisionRecord()->getUser() : null;
+ $revRecord = $linksUpdate->getRevisionRecord();
+ $revid = $revRecord ? $revRecord->getId() : null;
+ $user = $revRecord ? $revRecord->getUser() : null;
// link notification is boundless as you can include infinite number of links in a page
// db insert is expensive, limit it to a reasonable amount, we can increase this limit
@@ -806,7 +858,7 @@ class EchoHooks {
continue;
}
- $linkFromPageId = $linksUpdate->mTitle->getArticleId();
+ $linkFromPageId = $linksUpdate->mTitle->getArticleID();
EchoEvent::create( [
'type' => 'page-linked',
'title' => $title,
@@ -823,29 +875,24 @@ class EchoHooks {
break;
}
}
-
- return true;
}
/**
* Handler for BeforePageDisplay hook.
- * @see http://www.mediawiki.org/wiki/Manual:Hooks/BeforePageDisplay
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforePageDisplay
* @param OutputPage $out
* @param Skin $skin Skin being used.
- * @return bool true in all cases
*/
public static function beforePageDisplay( $out, $skin ) {
- if ( $out->getUser()->isLoggedIn() && $skin->getSkinName() !== 'minerva' ) {
+ if ( $out->getUser()->isLoggedIn() ) {
// Load the module for the Notifications flyout
$out->addModules( [ 'ext.echo.init' ] );
// Load the styles for the Notifications badge
$out->addModuleStyles( [
'ext.echo.styles.badge',
- 'ext.echo.badgeicons'
+ 'oojs-ui.styles.icons-alerts'
] );
}
-
- return true;
}
private static function processMarkAsRead( User $user, WebRequest $request, Title $title ) {
@@ -895,6 +942,7 @@ class EchoHooks {
}
}
} else {
+ $markAsReadIds = array_map( 'intval', $markAsReadIds );
// Look up the notifications on the foreign wiki
$notifUser = MWEchoNotifUser::newFromUser( $user );
$notifInfo = $notifUser->getForeignNotificationInfo( $markAsReadIds, $markAsReadWiki );
@@ -922,18 +970,84 @@ class EchoHooks {
}
/**
+ * Handler for SkinMinervaReplaceNotificationsBadge hook.
+ * @param User $user who needs notifications
+ * @param Title $title of current page
+ * @param string &$badge to replace
+ */
+ public static function onSkinMinervaReplaceNotificationsBadge( $user, $title, &$badge ) {
+ $notificationsTitle = SpecialPage::getTitleFor( 'Notifications' );
+ $count = 0;
+ $countLabel = '';
+ $isZero = true;
+ $hasUnseen = false;
+
+ if ( $title->equals( $notificationsTitle ) ) {
+ // On Special:Notifications show no icon
+ $badge = '';
+ return;
+ }
+
+ // Note: `mw-ui-icon-wikimedia-bellOutline-base20` class is provided by Minerva.
+ // In future we'll likely want to rethink how this works and possibly consolidate this with the desktop badge.
+ // For now, we avoid loading two bells in the same place by reusing the class already defined in Minerva.
+ $notificationIconClass = 'mw-ui-icon mw-ui-icon-wikimedia-bellOutline-base20 mw-ui-icon-element user-button';
+ $url = $notificationsTitle->getLocalURL(
+ [ 'returnto' => $title->getPrefixedText() ] );
+
+ $notifUser = MWEchoNotifUser::newFromUser( $user );
+ $count = $notifUser->getNotificationCount();
+
+ $echoSeenTime = EchoSeenTime::newFromUser( $user );
+ $seenAlertTime = $echoSeenTime->getTime( 'alert', TS_ISO_8601 );
+ $seenMsgTime = $echoSeenTime->getTime( 'message', TS_ISO_8601 );
+
+ $alertNotificationTimestamp = $notifUser->getLastUnreadAlertTime();
+ $msgNotificationTimestamp = $notifUser->getLastUnreadMessageTime();
+
+ $isZero = $count === 0;
+ $hasUnseen = $count > 0 &&
+ (
+ $seenMsgTime !== false && $msgNotificationTimestamp !== false &&
+ $seenMsgTime < $msgNotificationTimestamp->getTimestamp( TS_ISO_8601 )
+ ) ||
+ (
+ $seenAlertTime !== false && $alertNotificationTimestamp !== false &&
+ $seenAlertTime < $alertNotificationTimestamp->getTimestamp( TS_ISO_8601 )
+ );
+
+ $countLabel = EchoNotificationController::formatNotificationCount( $count );
+ $data = [
+ 'notificationIconClass' => $notificationIconClass,
+ 'title' => $hasUnseen ?
+ wfMessage( 'echo-overlay-link' ) :
+ wfMessage( 'echo-none' ),
+ 'url' => $url,
+ 'notificationCountRaw' => $count,
+ 'notificationCountString' => $countLabel,
+ 'isNotificationCountZero' => $isZero,
+ 'hasNotifications' => $hasUnseen,
+ // this variable is used inside the client side which has different handling
+ // for when notifications have been dismissed. Instead of a bell it shows `(0)`.
+ 'hasUnseenNotifications' => $hasUnseen,
+ ];
+ $parser = new TemplateParser( __DIR__ . '/../modules/mobile' );
+ // substitute the badge
+ $badge = $parser->processTemplate( 'NotificationBadge', $data );
+ }
+
+ /**
* Handler for PersonalUrls hook.
* Add a "Notifications" item to the user toolbar ('personal URLs').
- * @see http://www.mediawiki.org/wiki/Manual:Hooks/PersonalUrls
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/PersonalUrls
* @param array &$personal_urls Array of URLs to append to.
* @param Title &$title Title of page being visited.
* @param SkinTemplate $sk
- * @return bool true in all cases
*/
public static function onPersonalUrls( &$personal_urls, &$title, $sk ) {
$user = $sk->getUser();
if ( $user->isAnon() ) {
- return true;
+ return;
}
$subtractions = self::processMarkAsRead( $user, $sk->getOutput()->getRequest(), $title );
@@ -975,8 +1089,8 @@ class EchoHooks {
$sk->getOutput()->setupOOUI(
strtolower( $sk->getSkinName() ), $sk->getOutput()->getLanguage()->getDir() );
- $msgLinkClasses = [ "mw-echo-notifications-badge", "mw-echo-notification-badge-nojs" ];
- $alertLinkClasses = [ "mw-echo-notifications-badge", "mw-echo-notification-badge-nojs" ];
+ $msgLinkClasses = [ "mw-echo-notifications-badge", "mw-echo-notification-badge-nojs","oo-ui-icon-tray" ];
+ $alertLinkClasses = [ "mw-echo-notifications-badge", "mw-echo-notification-badge-nojs", "oo-ui-icon-bell" ];
$hasUnseen = false;
if (
@@ -1016,7 +1130,7 @@ class EchoHooks {
$alertLink = [
'href' => $url,
'text' => $alertText,
- 'active' => ( $url == $title->getLocalUrl() ),
+ 'active' => ( $url == $title->getLocalURL() ),
'class' => $alertLinkClasses,
'data' => [
'counter-num' => $alertCount,
@@ -1031,7 +1145,7 @@ class EchoHooks {
$msgLink = [
'href' => $url,
'text' => $msgText,
- 'active' => ( $url == $title->getLocalUrl() ),
+ 'active' => ( $url == $title->getLocalURL() ),
'class' => $msgLinkClasses,
'data' => [
'counter-num' => $msgCount,
@@ -1044,7 +1158,9 @@ class EchoHooks {
$personal_urls = wfArrayInsertAfter( $personal_urls, $insertUrls, 'userpage' );
if ( $hasUnseen ) {
- // Record that the user is going to see an indicator that they have unread notifications
+ // Record that the user is going to see an indicator that they have unseen notifications
+ // This is part of tracking how likely users are to click a badge with unseen notifications.
+ // The other part is the 'echo.unseen.click' counter, see ext.echo.init.js.
MediaWikiServices::getInstance()->getStatsdDataFactory()->increment( 'echo.unseen' );
}
@@ -1053,20 +1169,20 @@ class EchoHooks {
// * User actually has new messages
// * User is not viewing their user talk page, as user_newtalk
// will not have been cleared yet. (bug T107655).
- if ( $user->getNewtalk() && !$user->getTalkPage()->equals( $title ) ) {
+ $userHasNewMessages = MediaWikiServices::getInstance()
+ ->getTalkPageNotificationManager()->userHasNewMessages( $user );
+ if ( $userHasNewMessages && !$user->getTalkPage()->equals( $title ) ) {
if ( Hooks::run( 'BeforeDisplayOrangeAlert', [ $user, $title ] ) ) {
$personal_urls['mytalk']['text'] = $sk->msg( 'echo-new-messages' )->text();
$personal_urls['mytalk']['class'] = [ 'mw-echo-alert' ];
$sk->getOutput()->addModuleStyles( 'ext.echo.styles.alert' );
}
}
-
- return true;
}
/**
* Handler for AbortTalkPageEmailNotification hook.
- * @see http://www.mediawiki.org/wiki/Manual:Hooks/AbortTalkPageEmailNotification
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/AbortTalkPageEmailNotification
* @param User $targetUser
* @param Title $title
* @return bool
@@ -1088,13 +1204,18 @@ class EchoHooks {
/**
* Handler for AbortWatchlistEmailNotification hook.
- * @see http://www.mediawiki.org/wiki/Manual:Hooks/AbortWatchlistEmailNotification
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/AbortWatchlistEmailNotification
* @param User $targetUser
* @param Title $title
* @param EmailNotification $emailNotification The email notification object that sends non-echo notifications
* @return bool
*/
public static function onSendWatchlistEmailNotification( $targetUser, $title, $emailNotification ) {
+ global $wgEchoNotifications, $wgEchoWatchlistNotifications;
+ if ( $wgEchoWatchlistNotifications && isset( $wgEchoNotifications["watchlist-change"] ) ) {
+ // Let echo handle watchlist notifications entirely
+ return false;
+ }
// If a user is watching his/her own talk page, do not send talk page watchlist
// email notification if the user is receiving Echo talk page notification
if ( $title->isTalkPage() && $targetUser->getTalkPage()->equals( $title ) ) {
@@ -1110,27 +1231,6 @@ class EchoHooks {
return true;
}
- /**
- * Handler for MakeGlobalVariablesScript hook.
- * @see http://www.mediawiki.org/wiki/Manual:Hooks/MakeGlobalVariablesScript
- * @param array &$vars Variables to be added into the output
- * @param OutputPage $outputPage OutputPage instance calling the hook
- * @return bool true in all cases
- */
- public static function makeGlobalVariablesScript( &$vars, OutputPage $outputPage ) {
- global $wgEchoEventLoggingSchemas, $wgEchoEventLoggingVersion;
- $user = $outputPage->getUser();
-
- // Provide info for ext.echo.logger
- if ( $user->isLoggedIn() ) {
- $vars['wgEchoInteractionLogging'] = $wgEchoEventLoggingSchemas['EchoInteraction']['enabled']
- && ExtensionRegistry::getInstance()->isLoaded( 'EventLogging' );
- $vars['wgEchoEventLoggingVersion'] = $wgEchoEventLoggingVersion;
- }
-
- return true;
- }
-
public static function onOutputPageCheckLastModified( array &$modifiedTimes, OutputPage $out ) {
$user = $out->getUser();
if ( $user->isLoggedIn() ) {
@@ -1149,8 +1249,8 @@ class EchoHooks {
* Handler for GetNewMessagesAlert hook.
* We're using the GetNewMessagesAlert hook instead of the
* ArticleEditUpdateNewTalk hook since we still want the user_newtalk data
- * to be updated and availble to client-side tools and the API.
- * @see http://www.mediawiki.org/wiki/Manual:Hooks/GetNewMessagesAlert
+ * to be updated and available to client-side tools and the API.
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/GetNewMessagesAlert
* @param string &$newMessagesAlert An alert that the user has new messages
* or an empty string if the user does not (empty by default)
* @param array $newtalks This will be empty if the user has no new messages
@@ -1167,6 +1267,7 @@ class EchoHooks {
// notifications for talk page messages, disable the new messages alert.
if ( $user->isLoggedIn()
&& isset( $wgEchoNotifications['edit-user-talk'] )
+ && Hooks::run( 'EchoCanAbortNewMessagesAlert' )
) {
// hide new messages alert
return false;
@@ -1177,24 +1278,27 @@ class EchoHooks {
}
/**
- * Handler for ArticleRollbackComplete hook.
- * @see http://www.mediawiki.org/wiki/Manual:Hooks/ArticleRollbackComplete
+ * Handler for RollbackComplete hook.
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/RollbackComplete
*
* @param WikiPage $wikiPage The article that was edited
- * @param User $agent The user who did the rollback
- * @param Revision $newRevision The revision the page was reverted back to
- * @param Revision $oldRevision The revision of the top edit that was reverted
- *
- * @return bool true in all cases
+ * @param UserIdentity $agent The user who did the rollback
+ * @param RevisionRecord $newRevision The revision the page was reverted back to
+ * @param RevisionRecord $oldRevision The revision of the top edit that was reverted
*/
- public static function onRollbackComplete( WikiPage $wikiPage, $agent, $newRevision, $oldRevision ) {
+ public static function onRollbackComplete(
+ WikiPage $wikiPage,
+ UserIdentity $agent,
+ RevisionRecord $newRevision,
+ RevisionRecord $oldRevision
+ ) {
$victimId = $oldRevision->getUser();
- $latestRevision = $wikiPage->getRevision();
+ $latestRevision = $wikiPage->getRevisionRecord();
self::$lastRevertedRevision = $latestRevision;
if (
$victimId && // No notifications for anonymous users
- !$oldRevision->getContent()->equals( $newRevision->getContent() ) // No notifications for null rollbacks
+ !$oldRevision->hasSameContent( $newRevision ) // No notifications for null rollbacks
) {
EchoEvent::create( [
'type' => 'reverted',
@@ -1205,18 +1309,15 @@ class EchoHooks {
'reverted-revision-id' => $oldRevision->getId(),
'method' => 'rollback',
],
- 'agent' => $agent,
+ 'agent' => User::newFromIdentity( $agent ),
] );
}
-
- return true;
}
/**
* Handler for UserSaveSettings hook.
- * @see http://www.mediawiki.org/wiki/Manual:Hooks/UserSaveSettings
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/UserSaveSettings
* @param User $user whose settings were saved
- * @return bool true in all cases
*/
public static function onUserSaveSettings( $user ) {
// Extensions like AbuseFilter might create an account, but
@@ -1229,42 +1330,57 @@ class EchoHooks {
MWEchoNotifUser::newFromUser( $user )->resetNotificationCount();
} );
}
-
- return true;
}
/**
* Handler for UserLoadOptions hook.
- * @see http://www.mediawiki.org/wiki/Manual:Hooks/UserLoadOptions
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/UserLoadOptions
* @param User $user User whose options were loaded
* @param array &$options Options can be modified
- * @return bool true in all cases
*/
public static function onUserLoadOptions( $user, &$options ) {
+ global $wgEchoWatchlistNotifications;
// Use existing enotifusertalkpages option for echo-subscriptions-email-edit-user-talk
if ( isset( $options['enotifusertalkpages'] ) ) {
$options['echo-subscriptions-email-edit-user-talk'] = $options['enotifusertalkpages'];
}
- return true;
+ if ( $wgEchoWatchlistNotifications ) {
+ // Use existing enotifwatchlistpages option for echo-subscriptions-email-watchlist
+ if ( isset( $options['enotifwatchlistpages'] ) ) {
+ $options['echo-subscriptions-email-watchlist'] = $options['enotifwatchlistpages'];
+ }
+
+ // Use existing enotifminoredits option for echo-subscriptions-email-minor-watchlist
+ if ( isset( $options['enotifminoredits'] ) ) {
+ $options['echo-subscriptions-email-minor-watchlist'] = $options['enotifminoredits'];
+ }
+ }
}
/**
* Handler for UserSaveOptions hook.
- * @see http://www.mediawiki.org/wiki/Manual:Hooks/UserSaveOptions
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/UserSaveOptions
* @param User $user User whose options are being saved
* @param array &$options Options can be modified
- * @return bool true in all cases
*/
public static function onUserSaveOptions( $user, &$options ) {
- // echo-subscriptions-email-edit-user-talk is just a virtual option,
- // save the value in the real option enotifusertalkpages
+ global $wgEchoWatchlistNotifications;
+ // save virtual option values in corresponding real option values
if ( isset( $options['echo-subscriptions-email-edit-user-talk'] ) ) {
$options['enotifusertalkpages'] = $options['echo-subscriptions-email-edit-user-talk'];
unset( $options['echo-subscriptions-email-edit-user-talk'] );
}
-
- return true;
+ if ( $wgEchoWatchlistNotifications ) {
+ if ( isset( $options['echo-subscriptions-email-watchlist'] ) ) {
+ $options['enotifwatchlistpages'] = $options['echo-subscriptions-email-watchlist'];
+ unset( $options['echo-subscriptions-email-watchlist'] );
+ }
+ if ( isset( $options['echo-subscriptions-email-minor-watchlist'] ) ) {
+ $options['enotifminoredits'] = $options['echo-subscriptions-email-minor-watchlist'];
+ unset( $options['echo-subscriptions-email-minor-watchlist'] );
+ }
+ }
}
/**
@@ -1290,32 +1406,27 @@ class EchoHooks {
/**
* Handler for UserClearNewTalkNotification hook.
- * @see http://www.mediawiki.org/wiki/Manual:Hooks/UserClearNewTalkNotification
- * @param User $user User whose talk page notification should be marked as read
- * @return bool true in all cases
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/UserClearNewTalkNotification
+ * @param UserIdentity $user User whose talk page notification should be marked as read
*/
- public static function onUserClearNewTalkNotification( User $user ) {
- if ( !$user->isAnon() ) {
- DeferredUpdates::addCallableUpdate( function () use ( $user ) {
- MWEchoNotifUser::newFromUser( $user )->clearUserTalkNotifications();
+ public static function onUserClearNewTalkNotification( UserIdentity $user ) {
+ if ( $user->isRegistered() ) {
+ $userObj = User::newFromIdentity( $user );
+ DeferredUpdates::addCallableUpdate( function () use ( $userObj ) {
+ MWEchoNotifUser::newFromUser( $userObj )->clearUserTalkNotifications();
} );
}
-
- return true;
}
/**
* Handler for ParserTestTables hook, makes sure that Echo's tables are present during tests
- * @see http://www.mediawiki.org/wiki/Manual:Hooks/ParserTestTables
+ * @see https://www.mediawiki.org/wiki/Manual:Hooks/ParserTestTables
* @param array &$tables List of DB tables to be used for parser tests
- * @return bool true in all cases
*/
public static function onParserTestTables( &$tables ) {
$tables[] = 'echo_event';
$tables[] = 'echo_notification';
$tables[] = 'echo_email_batch';
-
- return true;
}
/**
@@ -1325,12 +1436,11 @@ class EchoHooks {
* @param MailAddress $from Adress of sending user
* @param string $subject Subject of the mail
* @param string $text Text of the mail
- * @return bool true in all cases
*/
public static function onEmailUserComplete( $address, $from, $subject, $text ) {
if ( $from->name === $address->name ) {
// nothing to notify
- return true;
+ return;
}
$userTo = User::newFromName( $address->name );
$userFrom = User::newFromName( $from->name );
@@ -1354,15 +1464,12 @@ class EchoHooks {
],
'agent' => $userFrom,
] );
-
- return true;
}
/**
* For integration with the UserMerge extension.
*
* @param array &$updateFields
- * @return bool
*/
public static function onUserMergeAccountFields( &$updateFields ) {
// array( tableName, idField, textField )
@@ -1370,52 +1477,136 @@ class EchoHooks {
$updateFields[] = [ 'echo_event', 'event_agent_id', 'db' => $dbw ];
$updateFields[] = [ 'echo_notification', 'notification_user', 'db' => $dbw, 'options' => [ 'IGNORE' ] ];
$updateFields[] = [ 'echo_email_batch', 'eeb_user_id', 'db' => $dbw, 'options' => [ 'IGNORE' ] ];
-
- return true;
}
public static function onMergeAccountFromTo( User &$oldUser, User &$newUser ) {
- DeferredUpdates::addCallableUpdate( function () use ( $oldUser, $newUser ) {
+ $method = __METHOD__;
+ DeferredUpdates::addCallableUpdate( function () use ( $oldUser, $newUser, $method ) {
+ if ( !$newUser->isAnon() ) {
+ // Select notifications that are now sent to the same user
+ $dbw = MWEchoDbFactory::newFromDefault()->getEchoDb( DB_MASTER );
+ $attributeManager = EchoAttributeManager::newFromGlobalVars();
+ $selfIds = $dbw->selectFieldValues(
+ [ 'echo_notification', 'echo_event' ],
+ 'event_id',
+ [
+ 'notification_user' => $newUser->getId(),
+ 'notification_event = event_id',
+ 'notification_user = event_agent_id',
+ 'event_type NOT IN (' . $dbw->makeList( $attributeManager->getNotifyAgentEvents() ) . ')'
+ ],
+ $method
+ ) ?: [];
+
+ // Select newer welcome notification(s)
+ $welcomeIds = $dbw->selectFieldValues(
+ [ 'echo_notification', 'echo_event' ],
+ 'event_id',
+ [
+ 'notification_user' => $newUser->getId(),
+ 'notification_event = event_id',
+ 'event_type' => 'welcome',
+ ],
+ $method,
+ [
+ 'ORDER BY' => 'notification_timestamp ASC',
+ 'OFFSET' => 1,
+ ]
+ ) ?: [];
+
+ // Select newer milestone notifications (per milestone level)
+ $counts = [];
+ $thankYouIds = [];
+ $thankYouRows = $dbw->select(
+ [ 'echo_notification', 'echo_event' ],
+ EchoEvent::selectFields(),
+ [
+ 'notification_user' => $newUser->getId(),
+ 'notification_event = event_id',
+ 'event_type' => 'thank-you-edit',
+ ],
+ $method,
+ [ 'ORDER BY' => 'notification_timestamp ASC' ]
+ ) ?: [];
+ foreach ( $thankYouRows as $row ) {
+ $event = EchoEvent::newFromRow( $row );
+ $editCount = $event ? $event->getExtraParam( 'editCount' ) : null;
+ if ( $editCount ) {
+ if ( isset( $counts[$editCount] ) ) {
+ $thankYouIds[] = $row->event_id;
+ } else {
+ $counts[$editCount] = true;
+ }
+ }
+ }
+
+ // Delete notifications
+ $ids = array_merge( $selfIds, $welcomeIds, $thankYouIds );
+ // @phan-suppress-next-line PhanImpossibleTypeComparison Each array in the merge may be empty
+ if ( $ids !== [] ) {
+ $dbw->delete(
+ 'echo_notification',
+ [
+ 'notification_user' => $newUser->getId(),
+ 'notification_event' => $ids
+ ],
+ $method
+ );
+ }
+ }
+
MWEchoNotifUser::newFromUser( $oldUser )->resetNotificationCount();
if ( !$newUser->isAnon() ) {
MWEchoNotifUser::newFromUser( $newUser )->resetNotificationCount();
}
} );
-
- return true;
}
public static function onUserMergeAccountDeleteTables( &$tables ) {
$dbw = MWEchoDbFactory::newFromDefault()->getEchoDb( DB_MASTER );
$tables['echo_notification'] = [ 'notification_user', 'db' => $dbw ];
$tables['echo_email_batch'] = [ 'eeb_user_id', 'db' => $dbw ];
-
- return true;
}
/**
* Sets custom login message for redirect from notification page
*
* @param array &$messages
- * @return bool
*/
public static function onLoginFormValidErrorMessages( &$messages ) {
$messages[] = 'echo-notification-loginrequired';
- return true;
}
- public static function onResourceLoaderGetConfigVars( &$vars ) {
- $vars['wgEchoMaxNotificationCount'] = MWEchoNotifUser::MAX_BADGE_COUNT;
+ public static function getConfigVars( ResourceLoaderContext $context, Config $config ) {
+ return [
+ 'EchoMaxNotificationCount' => MWEchoNotifUser::MAX_BADGE_COUNT,
+ 'EchoPollForUpdates' => $config->get( 'EchoPollForUpdates' )
+ ];
+ }
- return true;
+ public static function getLoggerConfigVars( ResourceLoaderContext $context, Config $config ) {
+ $schemas = $config->get( 'EchoEventLoggingSchemas' );
+ return [
+ 'EchoInteractionLogging' => $schemas['EchoInteraction']['enabled'] &&
+ ExtensionRegistry::getInstance()->isLoaded( 'EventLogging' ),
+ 'EchoEventLoggingVersion' => $config->get( 'EchoEventLoggingVersion' )
+ ];
}
+ /**
+ * @param WikiPage &$article
+ * @param User &$user
+ * @param string $reason
+ * @param int $articleId
+ * @param Content|null $content
+ * @param LogEntry $logEntry
+ */
public static function onArticleDeleteComplete(
WikiPage &$article,
User &$user,
$reason,
$articleId,
- Content $content = null,
+ ?Content $content,
LogEntry $logEntry
) {
\DeferredUpdates::addCallableUpdate( function () use ( $articleId ) {
@@ -1423,7 +1614,6 @@ class EchoHooks {
$eventIds = $eventMapper->fetchIdsByPage( $articleId );
EchoModerationController::moderate( $eventIds, true );
} );
- return true;
}
public static function onArticleUndelete( Title $title, $create, $comment, $oldPageId ) {
@@ -1434,7 +1624,74 @@ class EchoHooks {
EchoModerationController::moderate( $eventIds, false );
} );
}
- return true;
+ }
+
+ /**
+ * Handler for SpecialMuteModifyFormFields hook
+ *
+ * @param SpecialMute $specialMute
+ * @param array &$fields
+ */
+ public static function onSpecialMuteModifyFormFields( SpecialMute $specialMute, &$fields ) {
+ $echoPerUserBlacklist = MediaWikiServices::getInstance()->getMainConfig()->get( 'EchoPerUserBlacklist' );
+ if ( $echoPerUserBlacklist ) {
+ $target = $specialMute->getTarget();
+ $fields[ 'echo-notifications-blacklist'] = [
+ 'type' => 'check',
+ 'label-message' => [
+ 'echo-specialmute-label-mute-notifications',
+ $target ? $target->getName() : ''
+ ],
+ 'default' => $specialMute->isTargetBlacklisted( 'echo-notifications-blacklist' ),
+ ];
+ }
+ }
+
+ /**
+ * @param RecentChange $change
+ * @return bool|void
+ * @throws MWException
+ */
+ public function onRecentChange_save( $change ) {
+ if ( !$this->config->get( 'EchoWatchlistNotifications' ) ) {
+ return;
+ }
+ if ( $change->getAttribute( 'rc_minor' ) ) {
+ $type = 'minor-watchlist-change';
+ } else {
+ $type = 'watchlist-change';
+ }
+ EchoEvent::create( [
+ 'type' => $type,
+ 'title' => $change->getTitle(),
+ 'extra' => [
+ 'revid' => $change->getAttribute( "rc_this_oldid" ),
+ 'logid' => $change->getAttribute( "rc_logid" ),
+ 'status' => $change->mExtra["pageStatus"],
+ 'timestamp' => $change->getAttribute( "rc_timestamp" )
+ ],
+ 'agent' => $change->getPerformer()
+ ] );
+ }
+
+ /**
+ * Hook handler for ApiMain::moduleManager.
+ * Used here to put the echopushsubscriptions API module behind our push feature flag.
+ * TODO: Register this the usual way in extension.json when we don't need the feature flag
+ * anymore.
+ * @param ApiModuleManager $moduleManager
+ */
+ public static function onApiMainModuleManager( ApiModuleManager $moduleManager ) {
+ $services = MediaWikiServices::getInstance();
+ $echoConfig = $services->getConfigFactory()->makeConfig( 'Echo' );
+ $pushEnabled = $echoConfig->get( 'EchoEnablePush' );
+ if ( $pushEnabled ) {
+ $moduleManager->addModule(
+ 'echopushsubscriptions',
+ 'action',
+ 'EchoPush\\Api\\ApiEchoPushSubscriptions'
+ );
+ }
}
}
diff --git a/Echo/includes/EchoOnWikiList.php b/Echo/includes/EchoOnWikiList.php
index 5bdc4dd6..43cbab49 100644
--- a/Echo/includes/EchoOnWikiList.php
+++ b/Echo/includes/EchoOnWikiList.php
@@ -17,7 +17,7 @@ class EchoOnWikiList implements EchoContainmentList {
*/
public function __construct( $titleNs, $titleString ) {
$title = Title::newFromText( $titleString, $titleNs );
- if ( $title !== null && $title->getArticleId() ) {
+ if ( $title !== null && $title->getArticleID() ) {
$this->title = $title;
}
}
@@ -30,7 +30,7 @@ class EchoOnWikiList implements EchoContainmentList {
return [];
}
- $article = WikiPage::newFromID( $this->title->getArticleId() );
+ $article = WikiPage::newFromID( $this->title->getArticleID() );
if ( $article === null || !$article->exists() ) {
return [];
}
@@ -49,6 +49,6 @@ class EchoOnWikiList implements EchoContainmentList {
return '';
}
- return $this->title->getLatestRevID();
+ return (string)$this->title->getLatestRevID();
}
}
diff --git a/Echo/includes/EchoServices.php b/Echo/includes/EchoServices.php
new file mode 100644
index 00000000..7633d07b
--- /dev/null
+++ b/Echo/includes/EchoServices.php
@@ -0,0 +1,40 @@
+<?php
+
+use EchoPush\NotificationServiceClient;
+use EchoPush\SubscriptionManager;
+use MediaWiki\MediaWikiServices;
+
+class EchoServices {
+
+ /** @var MediaWikiServices */
+ private $services;
+
+ /** @return EchoServices */
+ public static function getInstance(): EchoServices {
+ return new self( MediaWikiServices::getInstance() );
+ }
+
+ /**
+ * @param MediaWikiServices $services
+ * @return EchoServices
+ */
+ public static function wrap( MediaWikiServices $services ): EchoServices {
+ return new self( $services );
+ }
+
+ /** @param MediaWikiServices $services */
+ public function __construct( MediaWikiServices $services ) {
+ $this->services = $services;
+ }
+
+ /** @return NotificationServiceClient */
+ public function getPushNotificationServiceClient(): NotificationServiceClient {
+ return $this->services->getService( 'EchoPushNotificationServiceClient' );
+ }
+
+ /** @return SubscriptionManager */
+ public function getPushSubscriptionManager(): SubscriptionManager {
+ return $this->services->getService( 'EchoPushSubscriptionManager' );
+ }
+
+}
diff --git a/Echo/includes/EchoSummaryParser.php b/Echo/includes/EchoSummaryParser.php
index a701b101..ab738a8c 100644
--- a/Echo/includes/EchoSummaryParser.php
+++ b/Echo/includes/EchoSummaryParser.php
@@ -30,12 +30,13 @@ class EchoSummaryParser {
$summary = preg_replace( '#/\*.*?\*/#', ' [] ', $summary );
$users = [];
- $regex = '/\[\[([' . Title::legalChars() . ']*+)(?:\|.*?)?\]\]/';
+ $regex = '/\[\[([' . Title::legalChars() . ']++)(?:\|.*?)?\]\]/';
if ( preg_match_all( $regex, $summary, $matches ) ) {
foreach ( $matches[1] as $match ) {
- if ( preg_match( '/^:/', $match ) ) {
+ if ( $match[0] === ':' ) {
continue;
}
+
$title = Title::newFromText( $match );
if ( $title
&& $title->isLocal()
diff --git a/Echo/includes/EmailBatch.php b/Echo/includes/EmailBatch.php
index df400013..320f2aa4 100644
--- a/Echo/includes/EmailBatch.php
+++ b/Echo/includes/EmailBatch.php
@@ -1,5 +1,6 @@
<?php
+use MediaWiki\MediaWikiServices;
use Wikimedia\Rdbms\IResultWrapper;
/**
@@ -65,9 +66,9 @@ class MWEchoEmailBatch {
* @return MWEchoEmailBatch|false
*/
public static function newFromUserId( $userId, $enforceFrequency = true ) {
- $user = User::newFromId( intval( $userId ) );
+ $user = User::newFromId( (int)$userId );
- $userEmailSetting = intval( $user->getOption( 'echo-email-frequency' ) );
+ $userEmailSetting = (int)$user->getOption( 'echo-email-frequency' );
// clear all existing events if user decides not to receive emails
if ( $userEmailSetting == -1 ) {
@@ -96,7 +97,7 @@ class MWEchoEmailBatch {
// 3. user has switched from batch to instant email, send events left in the queue
if ( $userLastBatch ) {
// use 20 as hours per day to get estimate
- $nextBatch = wfTimestamp( TS_UNIX, $userLastBatch ) + $userEmailSetting * 20 * 60 * 60;
+ $nextBatch = (int)wfTimestamp( TS_UNIX, $userLastBatch ) + $userEmailSetting * 20 * 60 * 60;
if ( $enforceFrequency && wfTimestamp( TS_MW, $nextBatch ) > wfTimestampNow() ) {
return false;
}
@@ -148,10 +149,10 @@ class MWEchoEmailBatch {
* @return bool true if event exists false otherwise
*/
protected function setLastEvent() {
- $dbr = MWEchoDbFactory::getDB( DB_REPLICA );
+ $dbr = MWEchoDbFactory::newFromDefault()->getEchoDb( DB_REPLICA );
$res = $dbr->selectField(
[ 'echo_email_batch' ],
- [ 'MAX( eeb_event_id )' ],
+ 'MAX( eeb_event_id )',
[ 'eeb_user_id' => $this->mUser->getId() ],
__METHOD__
);
@@ -160,9 +161,9 @@ class MWEchoEmailBatch {
$this->lastEvent = $res;
return true;
- } else {
- return false;
}
+
+ return false;
}
/**
@@ -190,7 +191,7 @@ class MWEchoEmailBatch {
// composite index, favor insert performance, storage space over read
// performance in this case
if ( $validEvents ) {
- $dbr = MWEchoDbFactory::getDB( DB_REPLICA );
+ $dbr = MWEchoDbFactory::newFromDefault()->getEchoDb( DB_REPLICA );
$conds = [
'eeb_user_id' => $this->mUser->getId(),
@@ -198,14 +199,31 @@ class MWEchoEmailBatch {
'event_type' => $validEvents
];
+ $tables = [ 'echo_email_batch', 'echo_event' ];
+
+ if ( $this->mUser->getOption( 'echo-dont-email-read-notifications' ) ) {
+ $conds += [
+ 'notification_event = event_id',
+ 'notification_read_timestamp' => null
+ ];
+ array_push( $tables, 'echo_notification' );
+ }
+
// See setLastEvent() for more detail for this variable
if ( $this->lastEvent ) {
- $conds[] = 'eeb_event_id <= ' . intval( $this->lastEvent );
+ $conds[] = 'eeb_event_id <= ' . (int)$this->lastEvent;
}
+ $fields = array_merge( EchoEvent::selectFields(), [
+ 'eeb_id',
+ 'eeb_user_id',
+ 'eeb_event_priority',
+ 'eeb_event_id',
+ 'eeb_event_hash',
+ ] );
$res = $dbr->select(
- [ 'echo_email_batch', 'echo_event' ],
- [ '*' ],
+ $tables,
+ $fields,
$conds,
__METHOD__,
[
@@ -227,22 +245,42 @@ class MWEchoEmailBatch {
}
/**
- * Clear "processed" events in the queue, processed could be: email sent, invalid, users do not want to receive emails
+ * Clear "processed" events in the queue,
+ * processed could be: email sent, invalid, users do not want to receive emails
*/
public function clearProcessedEvent() {
- $conds = [ 'eeb_user_id' => $this->mUser->getId() ];
-
- // there is a processed cutoff point
+ global $wgUpdateRowsPerQuery;
+ $eventMapper = new EchoEventMapper();
+ $dbFactory = MWEchoDbFactory::newFromDefault();
+ $dbw = $dbFactory->getEchoDb( DB_MASTER );
+ $dbr = $dbFactory->getEchoDb( DB_REPLICA );
+ $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+ $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
+ $domainId = $dbw->getDomainID();
+
+ $iterator = new BatchRowIterator( $dbr, 'echo_email_batch', 'eeb_event_id', $wgUpdateRowsPerQuery );
+ $iterator->addConditions( [ 'eeb_user_id' => $this->mUser->getId() ] );
if ( $this->lastEvent ) {
- $conds[] = 'eeb_event_id <= ' . intval( $this->lastEvent );
+ // There is a processed cutoff point
+ $iterator->addConditions( [ 'eeb_event_id <= ' . (int)$this->lastEvent ] );
}
+ foreach ( $iterator as $batch ) {
+ $eventIds = [];
+ foreach ( $batch as $row ) {
+ $eventIds[] = $row->eeb_event_id;
+ }
+ $dbw->delete( 'echo_email_batch', [
+ 'eeb_user_id' => $this->mUser->getId(),
+ 'eeb_event_id' => $eventIds
+ ], __METHOD__ );
- $dbw = MWEchoDbFactory::newFromDefault()->getEchoDb( DB_MASTER );
- $dbw->delete(
- 'echo_email_batch',
- $conds,
- __METHOD__
- );
+ // Find out which events are now orphaned, i.e. no longer referenced in echo_email_batch
+ // (besides the rows we just deleted) or in echo_notification, and delete them
+ $eventMapper->deleteOrphanedEvents( $eventIds, $this->mUser->getId(), 'echo_email_batch' );
+
+ $lbFactory->commitAndWaitForReplication(
+ __METHOD__, $ticket, [ 'domain' => $domainId ] );
+ }
}
/**
@@ -297,15 +335,13 @@ class MWEchoEmailBatch {
* @param int $eventId
* @param int $priority
* @param string $hash
- *
- * @throws MWException
*/
public static function addToQueue( $userId, $eventId, $priority, $hash ) {
if ( !$userId || !$eventId ) {
return;
}
- $dbw = MWEchoDbFactory::getDB( DB_MASTER );
+ $dbw = MWEchoDbFactory::newFromDefault()->getEchoDb( DB_MASTER );
$row = [
'eeb_user_id' => $userId,
@@ -328,15 +364,14 @@ class MWEchoEmailBatch {
* @param int $startUserId
* @param int $batchSize
*
- * @throws MWException
* @return IResultWrapper|bool
*/
public static function getUsersToNotify( $startUserId, $batchSize ) {
- $dbr = MWEchoDbFactory::getDB( DB_REPLICA );
+ $dbr = MWEchoDbFactory::newFromDefault()->getEchoDb( DB_REPLICA );
$res = $dbr->select(
[ 'echo_email_batch' ],
[ 'eeb_user_id' ],
- [ 'eeb_user_id > ' . intval( $startUserId ) ],
+ [ 'eeb_user_id > ' . (int)$startUserId ],
__METHOD__,
[ 'ORDER BY' => 'eeb_user_id', 'LIMIT' => $batchSize ]
);
diff --git a/Echo/includes/EventLogging.php b/Echo/includes/EventLogging.php
index 9ffba03a..5db4771f 100644
--- a/Echo/includes/EventLogging.php
+++ b/Echo/includes/EventLogging.php
@@ -77,7 +77,7 @@ class MWEchoEventLogging {
if ( isset( $extra['source'] ) ) {
$data['eventSource'] = (string)$extra['source'];
}
- if ( $deliveryMethod == 'email' ) {
+ if ( $deliveryMethod === 'email' ) {
$data['deliveryMethod'] = 'email';
} else {
// whitelist valid delivery methods so it is always valid
diff --git a/Echo/includes/ForeignNotifications.php b/Echo/includes/ForeignNotifications.php
index f4299d95..301c09ab 100644
--- a/Echo/includes/ForeignNotifications.php
+++ b/Echo/includes/ForeignNotifications.php
@@ -3,7 +3,8 @@
/**
* Caches the result of EchoUnreadWikis::getUnreadCounts() and interprets the results in various useful ways.
*
- * If the user has disabled cross-wiki notifications in their preferences (see {@see EchoForeignNotifications::isEnabledByUser}), this class
+ * If the user has disabled cross-wiki notifications in their preferences
+ * (see {@see EchoForeignNotifications::isEnabledByUser}), this class
* won't do anything and will behave as if the user has no foreign notifications. For example, getCount() will
* return 0. If you need to get foreign notification information for a user even though they may not have
* enabled the preference, set $forceEnable=true in the constructor.
@@ -71,7 +72,7 @@ class EchoForeignNotifications {
if ( $section === EchoAttributeManager::ALL ) {
$count = array_sum( $this->counts );
} else {
- $count = isset( $this->counts[$section] ) ? $this->counts[$section] : 0;
+ $count = $this->counts[$section] ?? 0;
}
return MWEchoNotifUser::capNotificationCount( $count );
@@ -98,7 +99,7 @@ class EchoForeignNotifications {
return $max;
}
- return isset( $this->timestamps[$section] ) ? $this->timestamps[$section] : false;
+ return $this->timestamps[$section] ?? false;
}
/**
@@ -117,7 +118,7 @@ class EchoForeignNotifications {
return array_unique( $all );
}
- return isset( $this->wikis[$section] ) ? $this->wikis[$section] : [];
+ return $this->wikis[$section] ?? [];
}
public function getWikiTimestamp( $wiki, $section = EchoAttributeManager::ALL ) {
@@ -136,7 +137,7 @@ class EchoForeignNotifications {
}
return $max;
}
- return isset( $this->wikiTimestamps[$wiki][$section] ) ? $this->wikiTimestamps[$wiki][$section] : false;
+ return $this->wikiTimestamps[$wiki][$section] ?? false;
}
protected function populate() {
diff --git a/Echo/includes/ForeignWikiRequest.php b/Echo/includes/ForeignWikiRequest.php
index b3daea33..6a7f35b3 100644
--- a/Echo/includes/ForeignWikiRequest.php
+++ b/Echo/includes/ForeignWikiRequest.php
@@ -1,10 +1,32 @@
<?php
use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
use MediaWiki\Session\SessionManager;
class EchoForeignWikiRequest {
+ /** @var User */
+ protected $user;
+
+ /** @var array */
+ protected $params;
+
+ /** @var array */
+ protected $wikis;
+
+ /** @varstring|null */
+ protected $wikiParam;
+
+ /** @var string */
+ protected $method;
+
+ /** @var string|null */
+ protected $tokenType;
+
+ /** @var string[]|null */
+ protected $csrfTokens;
+
/**
* @param User $user
* @param array $params Request parameters
@@ -94,10 +116,12 @@ class EchoForeignWikiRequest {
* This method fetches the tokens for all requested wikis at once and caches the result.
*
* @param string $wiki Name of the wiki to get a token for
+ * @suppress PhanTypeInvalidCallableArraySize getRequestParams can take an array, too (phan bug)
* @return string Token
*/
protected function getCsrfToken( $wiki ) {
if ( $this->csrfTokens === null ) {
+ $this->csrfTokens = [];
$reqs = $this->getRequestParams( 'GET', [
'action' => 'query',
'meta' => 'tokens',
@@ -131,7 +155,7 @@ class EchoForeignWikiRequest {
$reqs[$wiki] = [
'method' => $method,
'url' => $api['url'],
- $queryKey => is_callable( $params ) ? call_user_func( $params, $wiki ) : $params
+ $queryKey => is_callable( $params ) ? $params( $wiki ) : $params
];
}
@@ -170,7 +194,7 @@ class EchoForeignWikiRequest {
* @throws Exception
*/
protected function doRequests( array $reqs ) {
- $http = new MultiHttpClient( [] );
+ $http = MediaWikiServices::getInstance()->getHttpRequestFactory()->createMultiClient();
$responses = $http->runMulti( $reqs );
$results = [];
diff --git a/Echo/includes/NotifUser.php b/Echo/includes/NotifUser.php
index 59dd68a0..8f965cd5 100644
--- a/Echo/includes/NotifUser.php
+++ b/Echo/includes/NotifUser.php
@@ -1,6 +1,7 @@
<?php
use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\Database;
/**
* Entity that represents a notification target user
@@ -38,9 +39,9 @@ class MWEchoNotifUser {
private $targetPageMapper;
/**
- * @var EchoForeignNotifications
+ * @var EchoForeignNotifications|null
*/
- private $foreignNotifications = null;
+ private $foreignNotifications;
/**
* @var array[]|null
@@ -55,7 +56,7 @@ class MWEchoNotifUser {
/**
* @var array[]|null
*/
- private $mForeignData = null;
+ private $mForeignData;
// The max notification count shown in badge
@@ -107,7 +108,11 @@ class MWEchoNotifUser {
return new MWEchoNotifUser(
$user,
MediaWikiServices::getInstance()->getMainWANObjectCache(),
- new EchoUserNotificationGateway( $user, MWEchoDbFactory::newFromDefault() ),
+ new EchoUserNotificationGateway(
+ $user,
+ MWEchoDbFactory::newFromDefault(),
+ MediaWikiServices::getInstance()->getMainConfig()
+ ),
new EchoNotificationMapper(),
new EchoTargetPageMapper()
);
@@ -160,7 +165,8 @@ class MWEchoNotifUser {
* If $wgEchoCrossWikiNotifications is disabled, the $global parameter is ignored.
*
* @param string $section Notification section
- * @param bool|string $global Whether to include foreign notifications. If set to 'preference', uses the user's preference.
+ * @param bool|string $global Whether to include foreign notifications.
+ * If set to 'preference', uses the user's preference.
* @return int
*/
public function getNotificationCount( $section = EchoAttributeManager::ALL, $global = 'preference' ) {
@@ -207,7 +213,8 @@ class MWEchoNotifUser {
* If $wgEchoCrossWikiNotifications is disabled, the $global parameter is ignored.
*
* @param string $section Notification section
- * @param bool|string $global Whether to include foreign notifications. If set to 'preference', uses the user's preference.
+ * @param bool|string $global Whether to include foreign notifications.
+ * If set to 'preference', uses the user's preference.
* @return bool|MWTimestamp Timestamp of latest unread message, or false if there are no unread messages.
*/
public function getLastUnreadNotificationTime( $section = EchoAttributeManager::ALL, $global = 'preference' ) {
@@ -249,13 +256,22 @@ class MWEchoNotifUser {
// After this 'mark read', is there any unread edit-user-talk
// remaining? If not, we should clear the newtalk flag.
- if ( $this->mUser->getNewtalk() ) {
+ $talkPageNotificationManager = MediaWikiServices::getInstance()
+ ->getTalkPageNotificationManager();
+ if ( $talkPageNotificationManager->userHasNewMessages( $this->mUser ) ) {
$attributeManager = EchoAttributeManager::newFromGlobalVars();
$categoryMap = $attributeManager->getEventsByCategory();
$usertalkTypes = $categoryMap['edit-user-talk'];
- $unreadEditUserTalk = $this->notifMapper->fetchUnreadByUser( $this->mUser, 1, null, $usertalkTypes, null, DB_MASTER );
+ $unreadEditUserTalk = $this->notifMapper->fetchUnreadByUser(
+ $this->mUser,
+ 1,
+ null,
+ $usertalkTypes,
+ null,
+ DB_MASTER
+ );
if ( $unreadEditUserTalk === [] ) {
- $this->mUser->setNewtalk( false );
+ $talkPageNotificationManager->removeUserHasNewMessages( $this->mUser );
}
}
}
@@ -282,13 +298,22 @@ class MWEchoNotifUser {
// After this 'mark unread', is there any unread edit-user-talk?
// If so, we should add the edit-user-talk flag
- if ( !$this->mUser->getNewtalk() ) {
+ $talkPageNotificationManager = MediaWikiServices::getInstance()
+ ->getTalkPageNotificationManager();
+ if ( !$talkPageNotificationManager->userHasNewMessages( $this->mUser ) ) {
$attributeManager = EchoAttributeManager::newFromGlobalVars();
$categoryMap = $attributeManager->getEventsByCategory();
$usertalkTypes = $categoryMap['edit-user-talk'];
- $unreadEditUserTalk = $this->notifMapper->fetchUnreadByUser( $this->mUser, 1, null, $usertalkTypes, null, DB_MASTER );
+ $unreadEditUserTalk = $this->notifMapper->fetchUnreadByUser(
+ $this->mUser,
+ 1,
+ null,
+ $usertalkTypes,
+ null,
+ DB_MASTER
+ );
if ( $unreadEditUserTalk !== [] ) {
- $this->mUser->setNewtalk( true );
+ $talkPageNotificationManager->setUserHasNewMessages( $this->mUser );
}
}
}
@@ -329,9 +354,8 @@ class MWEchoNotifUser {
// such case so to keep the code running
if ( $notif->getEvent() ) {
return $notif->getEvent()->getId();
- } else {
- return 0;
}
+ return 0;
}, $notifs )
);
@@ -538,13 +562,28 @@ class MWEchoNotifUser {
$totals = [ 'count' => 0, 'timestamp' => -1 ];
foreach ( EchoAttributeManager::$sections as $section ) {
- $eventTypesToLoad = $attributeManager->getUserEnabledEventsbySections( $this->mUser, 'web', [ $section ] );
+ $eventTypesToLoad = $attributeManager->getUserEnabledEventsbySections(
+ $this->mUser,
+ 'web',
+ [ $section ]
+ );
- $count = (int)$this->userNotifGateway->getCappedNotificationCount( $dbSource, $eventTypesToLoad, self::MAX_BADGE_COUNT + 1 );
+ $count = (int)$this->userNotifGateway->getCappedNotificationCount(
+ $dbSource,
+ $eventTypesToLoad,
+ self::MAX_BADGE_COUNT + 1
+ );
$result[$section]['count'] = $count;
$totals['count'] += $count;
- $notifications = $this->notifMapper->fetchUnreadByUser( $this->mUser, 1, null, $eventTypesToLoad, null, $dbSource );
+ $notifications = $this->notifMapper->fetchUnreadByUser(
+ $this->mUser,
+ 1,
+ null,
+ $eventTypesToLoad,
+ null,
+ $dbSource
+ );
if ( $notifications ) {
$notification = reset( $notifications );
$timestamp = $notification->getTimestamp();
@@ -578,7 +617,10 @@ class MWEchoNotifUser {
$localTimestamp = $localData[$section]['timestamp'];
$foreignTimestamp = $this->getForeignTimestamp( $section );
- $globalTimestamp = max( $localTimestamp, $foreignTimestamp ? $foreignTimestamp->getTimestamp( TS_MW ) : -1 );
+ $globalTimestamp = max(
+ $localTimestamp,
+ $foreignTimestamp ? $foreignTimestamp->getTimestamp( TS_MW ) : -1
+ );
$result[$section]['timestamp'] = $globalTimestamp;
$totals['timestamp'] = max( $totals['timestamp'], $globalTimestamp );
}
@@ -596,9 +638,9 @@ class MWEchoNotifUser {
if ( $wgAllowHTMLEmail ) {
return $this->mUser->getOption( 'echo-email-format' );
- } else {
- return EchoEmailFormat::PLAIN_TEXT;
}
+
+ return EchoEmailFormat::PLAIN_TEXT;
}
/**
@@ -609,7 +651,7 @@ class MWEchoNotifUser {
*/
protected function getMemcKey( $key ) {
global $wgEchoCacheVersion;
- return wfMemcKey( $key, $this->mUser->getId(), $wgEchoCacheVersion );
+ return $this->cache->makeKey( $key, $this->mUser->getId(), $wgEchoCacheVersion );
}
/**
@@ -625,7 +667,7 @@ class MWEchoNotifUser {
if ( !$globalId ) {
return false;
}
- return wfGlobalCacheKey( $key, $globalId, $wgEchoCacheVersion );
+ return $this->cache->makeGlobalKey( $key, $globalId, $wgEchoCacheVersion );
}
/**
@@ -641,86 +683,14 @@ class MWEchoNotifUser {
}
/**
- * Get data about foreign notifications from the foreign wikis' APIs.
- *
- * This is used when $wgEchoSectionTransition or $wgEchoBundleTransition is enabled,
- * to deal with untrustworthy echo_unread_wikis entries. This method fetches the list of
- * wikis that have any unread notifications at all from the echo_unread_wikis table, then
- * queries their APIs to find the per-section counts and timestamps for those wikis.
- *
- * The results of this function are cached in the NotifUser object.
- * @return array[] [ (str) wiki => [ (str) section => [ 'count' => (int) count, 'timestamp' => (str) ts ] ] ]
- */
- protected function getForeignData() {
- if ( $this->mForeignData ) {
- return $this->mForeignData;
- }
-
- $potentialWikis = $this->getForeignNotifications()->getWikis( EchoAttributeManager::ALL );
- $foreignReq = new EchoForeignWikiRequest(
- $this->mUser,
- [
- 'action' => 'query',
- 'meta' => 'notifications',
- 'notprop' => 'count|list',
- 'notgroupbysection' => '1',
- 'notunreadfirst' => '1',
- ],
- $potentialWikis,
- 'notwikis'
- );
- $foreignResults = $foreignReq->execute();
-
- $this->mForeignData = [];
- foreach ( $foreignResults as $wiki => $result ) {
- if ( !isset( $result['query']['notifications'] ) ) {
- continue;
- }
- $data = $result['query']['notifications'];
- foreach ( EchoAttributeManager::$sections as $section ) {
- if ( isset( $data[$section]['rawcount'] ) ) {
- $this->mForeignData[$wiki][$section]['count'] = $data[$section]['rawcount'];
- }
- if ( isset( $data[$section]['list'][0] ) ) {
- $this->mForeignData[$wiki][$section]['timestamp'] = $data[$section]['list'][0]['timestamp']['mw'];
- }
- }
- }
- return $this->mForeignData;
- }
-
- /**
* Get the number of foreign notifications in a given section.
* @param string $section One of EchoAttributeManager::$sections
* @return int Number of foreign notifications
*/
protected function getForeignCount( $section = EchoAttributeManager::ALL ) {
- global $wgEchoSectionTransition, $wgEchoBundleTransition;
- $count = 0;
- if (
- // In section transition mode, we don't trust the individual echo_unread_wikis rows
- // but we do trust that alert+message=all. In bundle transition mode, we don't trust
- // that either, but we do trust that wikis with rows in the table have unread notifications
- // and wikis without rows in the table don't.
- ( $wgEchoSectionTransition && $section !== EchoAttributeManager::ALL ) ||
- $wgEchoBundleTransition
- ) {
- $foreignData = $this->getForeignData();
- foreach ( $foreignData as $data ) {
- if ( $section === EchoAttributeManager::ALL ) {
- foreach ( $data as $subData ) {
- if ( isset( $subData['count'] ) ) {
- $count += $subData['count'];
- }
- }
- } elseif ( isset( $data[$section]['count'] ) ) {
- $count += $data[$section]['count'];
- }
- }
- } else {
- $count += $this->getForeignNotifications()->getCount( $section );
- }
- return self::capNotificationCount( $count );
+ return self::capNotificationCount(
+ $this->getForeignNotifications()->getCount( $section )
+ );
}
/**
@@ -730,34 +700,7 @@ class MWEchoNotifUser {
* there aren't any
*/
protected function getForeignTimestamp( $section = EchoAttributeManager::ALL ) {
- global $wgEchoSectionTransition, $wgEchoBundleTransition;
-
- if (
- // In section transition mode, we don't trust the individual echo_unread_wikis rows
- // but we do trust that alert+message=all. In bundle transition mode, we don't trust
- // that either, but we do trust that wikis with rows in the table have unread notifications
- // and wikis without rows in the table don't.
- ( $wgEchoSectionTransition && $section !== EchoAttributeManager::ALL ) ||
- $wgEchoBundleTransition
- ) {
- $foreignTime = -1;
- $foreignData = $this->getForeignData();
- foreach ( $foreignData as $data ) {
- if ( $section === EchoAttributeManager::ALL ) {
- foreach ( $data as $subData ) {
- if ( isset( $subData['timestamp'] ) ) {
- $foreignTime = max( $foreignTime, $subData['timestamp'] );
- }
- }
- } elseif ( isset( $data[$section]['timestamp'] ) ) {
- $foreignTime = max( $foreignTime, $data[$section]['timestamp'] );
- }
- }
- $foreignTime = $foreignTime === -1 ? false : new MWTimestamp( $foreignTime );
- } else {
- $foreignTime = $this->getForeignNotifications()->getTimestamp( $section );
- }
- return $foreignTime;
+ return $this->getForeignNotifications()->getTimestamp( $section );
}
/**
diff --git a/Echo/includes/Notifier.php b/Echo/includes/Notifier.php
index e00a3368..c2a6a72e 100644
--- a/Echo/includes/Notifier.php
+++ b/Echo/includes/Notifier.php
@@ -69,6 +69,7 @@ class EchoNotifier {
) {
Hooks::run( 'EchoGetBundleRules', [ $event, &$bundleString ] );
}
+ // @phan-suppress-next-line PhanImpossibleCondition May be set by hook
if ( $bundleString ) {
$bundleHash = md5( $bundleString );
}
@@ -89,7 +90,10 @@ class EchoNotifier {
// instant email notification
$toAddress = MailAddress::newFromUser( $user );
- $fromAddress = new MailAddress( $wgPasswordSender, wfMessage( 'emailsender' )->inContentLanguage()->text() );
+ $fromAddress = new MailAddress(
+ $wgPasswordSender,
+ wfMessage( 'emailsender' )->inContentLanguage()->text()
+ );
$replyAddress = new MailAddress( $wgNoReplyAddress );
// Since we are sending a single email, should set the bundle hash to null
// if it is set with a value from somewhere else
diff --git a/Echo/includes/Push/NotificationRequestJob.php b/Echo/includes/Push/NotificationRequestJob.php
new file mode 100644
index 00000000..e228584d
--- /dev/null
+++ b/Echo/includes/Push/NotificationRequestJob.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace EchoPush;
+
+use EchoServices;
+use Job;
+
+class NotificationRequestJob extends Job {
+
+ /**
+ * @return bool success
+ */
+ public function run(): bool {
+ $centralId = $this->params['centralId'];
+ $echoServices = EchoServices::getInstance();
+ $subscriptionManager = $echoServices->getPushSubscriptionManager();
+ $subscriptions = $subscriptionManager->getSubscriptionsForUser( $centralId );
+ if ( count( $subscriptions ) === 0 ) {
+ return true;
+ }
+ $serviceClient = $echoServices->getPushNotificationServiceClient();
+ $serviceClient->sendCheckEchoRequests( $subscriptions );
+ return true;
+ }
+
+}
diff --git a/Echo/includes/Push/NotificationServiceClient.php b/Echo/includes/Push/NotificationServiceClient.php
new file mode 100644
index 00000000..39d1ab43
--- /dev/null
+++ b/Echo/includes/Push/NotificationServiceClient.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace EchoPush;
+
+use MediaWiki\Http\HttpRequestFactory;
+use MWHttpRequest;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+use Status;
+
+class NotificationServiceClient implements LoggerAwareInterface {
+
+ use LoggerAwareTrait;
+
+ /** @var HttpRequestFactory */
+ private $httpRequestFactory;
+
+ /** @var string */
+ private $endpointBase;
+
+ /**
+ * @param HttpRequestFactory $httpRequestFactory
+ * @param string $endpointBase push service notification request endpoint base URL
+ */
+ public function __construct( HttpRequestFactory $httpRequestFactory, string $endpointBase ) {
+ $this->httpRequestFactory = $httpRequestFactory;
+ $this->endpointBase = $endpointBase;
+ }
+
+ /**
+ * Send a CHECK_ECHO notification request to the push service for each subscription found.
+ * TODO: Update the service to handle multiple providers in a single request (T254379)
+ * @param array $subscriptions Subscriptions for which to send the message
+ */
+ public function sendCheckEchoRequests( array $subscriptions ): void {
+ $tokensByProvider = [];
+ foreach ( $subscriptions as $subscription ) {
+ $provider = $subscription->getProvider();
+ if ( !isset( $tokensByProvider[$provider] ) ) {
+ $tokensByProvider[$provider] = [];
+ }
+ $tokensByProvider[$provider][] = $subscription->getToken();
+ }
+ foreach ( array_keys( $tokensByProvider ) as $provider ) {
+ $tokens = $tokensByProvider[$provider];
+ $payload = [ 'deviceTokens' => $tokens, 'messageType' => 'checkEchoV1' ];
+ $this->sendRequest( $provider, $payload );
+ }
+ }
+
+ /**
+ * Send a notification request for a single push provider
+ * @param string $provider Provider endpoint to which to send the message
+ * @param array $payload message payload
+ */
+ private function sendRequest( string $provider, array $payload ): void {
+ $request = $this->constructRequest( $provider, $payload );
+ $status = $request->execute();
+ if ( !$status->isOK() ) {
+ $errors = $status->getErrorsByType( 'error' );
+ $this->logger->warning(
+ Status::wrap( $status )->getMessage( false, false, 'en' )->serialize(),
+ [
+ 'error' => $errors,
+ 'caller' => __METHOD__,
+ 'content' => $request->getContent()
+ ]
+ );
+ }
+ }
+
+ /**
+ * Construct a MWHttpRequest object based on the subscription and payload.
+ * @param string $provider
+ * @param array $payload
+ * @return MWHttpRequest
+ */
+ private function constructRequest( string $provider, array $payload ): MWHttpRequest {
+ $url = "$this->endpointBase/$provider";
+ $opts = [ 'method' => 'POST', 'postData' => json_encode( $payload ) ];
+ $req = $this->httpRequestFactory->create( $url, $opts );
+ $req->setHeader( 'Content-Type', 'application/json; charset=utf-8' );
+ return $req;
+ }
+
+}
diff --git a/Echo/includes/Push/PushNotifier.php b/Echo/includes/Push/PushNotifier.php
new file mode 100644
index 00000000..93c7126c
--- /dev/null
+++ b/Echo/includes/Push/PushNotifier.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace EchoPush;
+
+use CentralIdLookup;
+use EchoAttributeManager;
+use EchoEvent;
+use JobQueueGroup;
+use User;
+
+class PushNotifier {
+
+ /**
+ * Submits a notification derived from an Echo event to each push notifications service
+ * subscription found for a user, via a configured service handler implementation
+ * @param User $user
+ * @param EchoEvent $event
+ */
+ public static function notifyWithPush( User $user, EchoEvent $event ): void {
+ $attributeManager = EchoAttributeManager::newFromGlobalVars();
+ $userEnabledEvents = $attributeManager->getUserEnabledEvents( $user, 'push' );
+ if ( in_array( $event->getType(), $userEnabledEvents ) ) {
+ JobQueueGroup::singleton()->push( self::createJob( $user, $event ) );
+ }
+ }
+
+ /**
+ * @param User $user
+ * @param EchoEvent|null $event
+ * @return NotificationRequestJob
+ */
+ private static function createJob( User $user, EchoEvent $event = null ):
+ NotificationRequestJob {
+ $centralId = CentralIdLookup::factory()->centralIdFromLocalUser( $user );
+ $params = [ 'centralId' => $centralId ];
+ // below params are only needed for debug logging (T255068)
+ if ( $event !== null ) {
+ $params['eventId'] = $event->getId();
+ $params['eventType'] = $event->getType();
+ if ( $event->getAgent() !== null ) {
+ $params['agent'] = $event->getAgent()->getId();
+ }
+ }
+ return new NotificationRequestJob( 'EchoPushNotificationRequest', $params );
+ }
+
+}
diff --git a/Echo/includes/Push/Subscription.php b/Echo/includes/Push/Subscription.php
new file mode 100644
index 00000000..92c7b904
--- /dev/null
+++ b/Echo/includes/Push/Subscription.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace EchoPush;
+
+use Wikimedia\Timestamp\ConvertibleTimestamp;
+
+class Subscription {
+
+ /** @var string */
+ private $provider;
+
+ /** @var string */
+ private $token;
+
+ /** @var ConvertibleTimestamp */
+ private $updated;
+
+ /**
+ * Construct a subscription from a DB result row.
+ * @param object $row echo_push_subscription row from IResultWrapper::fetchRow
+ * @return Subscription
+ */
+ public static function newFromRow( object $row ) {
+ return new self(
+ $row->epp_name,
+ $row->eps_token,
+ new ConvertibleTimestamp( $row->eps_updated )
+ );
+ }
+
+ /**
+ * @param string $provider
+ * @param string $token
+ * @param ConvertibleTimestamp $updated
+ */
+ public function __construct( string $provider, string $token, ConvertibleTimestamp $updated ) {
+ $this->provider = $provider;
+ $this->token = $token;
+ $this->updated = $updated;
+ }
+
+ /** @return string provider */
+ public function getProvider(): string {
+ return $this->provider;
+ }
+
+ /** @return string token */
+ public function getToken(): string {
+ return $this->token;
+ }
+
+ /** @return ConvertibleTimestamp last updated timestamp */
+ public function getUpdated(): ConvertibleTimestamp {
+ return $this->updated;
+ }
+
+}
diff --git a/Echo/includes/Push/SubscriptionManager.php b/Echo/includes/Push/SubscriptionManager.php
new file mode 100644
index 00000000..312186c2
--- /dev/null
+++ b/Echo/includes/Push/SubscriptionManager.php
@@ -0,0 +1,123 @@
+<?php
+
+namespace EchoPush;
+
+use CentralIdLookup;
+use EchoAbstractMapper;
+use IDatabase;
+use MediaWiki\Storage\NameTableStore;
+use User;
+use Wikimedia\Rdbms\DBError;
+
+class SubscriptionManager extends EchoAbstractMapper {
+
+ /** @var IDatabase */
+ private $dbw;
+
+ /** @var IDatabase */
+ private $dbr;
+
+ /** @var CentralIdLookup */
+ private $centralIdLookup;
+
+ /** @var NameTableStore */
+ private $pushProviderStore;
+
+ /**
+ * @param IDatabase $dbw primary DB connection (for writes)
+ * @param IDatabase $dbr replica DB connection (for reads)
+ * @param CentralIdLookup $centralIdLookup
+ * @param NameTableStore $pushProviderStore
+ */
+ public function __construct(
+ IDatabase $dbw,
+ IDatabase $dbr,
+ CentralIdLookup $centralIdLookup,
+ NameTableStore $pushProviderStore
+ ) {
+ parent::__construct();
+ $this->dbw = $dbw;
+ $this->dbr = $dbr;
+ $this->centralIdLookup = $centralIdLookup;
+ $this->pushProviderStore = $pushProviderStore;
+ }
+
+ /**
+ * Store push subscription information for a user.
+ * @param User $user
+ * @param string $provider Provider name string (validated by presence in the PARAM_TYPE array)
+ * @param string $token Subscriber token provided by the push provider
+ * @return bool true if the subscription was created; false if the token already exists
+ */
+ public function create( User $user, string $provider, string $token ): bool {
+ $this->dbw->insert(
+ 'echo_push_subscription',
+ [
+ 'eps_user' => $this->getCentralId( $user ),
+ 'eps_provider' => $this->pushProviderStore->acquireId( $provider ),
+ 'eps_token' => $token,
+ 'eps_token_sha256' => hash( 'sha256', $token ),
+ 'eps_updated' => $this->dbw->timestamp()
+ ],
+ __METHOD__,
+ [ 'IGNORE' ]
+ );
+ return (bool)$this->dbw->affectedRows();
+ }
+
+ /**
+ * Get all registered subscriptions for a user (by central ID).
+ * @param int $centralId
+ * @return array array of Subscription objects
+ */
+ public function getSubscriptionsForUser( int $centralId ) {
+ $res = $this->dbr->select(
+ [ 'echo_push_subscription', 'echo_push_provider' ],
+ '*',
+ [ 'eps_user' => $centralId ],
+ __METHOD__,
+ [],
+ [ 'echo_push_provider' => [ 'INNER JOIN', [ 'eps_provider = epp_id' ] ] ]
+ );
+ $result = [];
+ foreach ( $res as $row ) {
+ $result[] = Subscription::newFromRow( $row );
+ }
+ return $result;
+ }
+
+ /**
+ * Delete a push subscription for a user.
+ * Note: Selecting for the user in addition to the token should be redundant, since tokens
+ * are globally unique and user-specific, but it's probably safest to keep it as a sanity check.
+ * Also, currently the eps_user column is indexed but eps_token is not.
+ * @param User $user
+ * @param string $token Delete the subscription with this token
+ * @return int number of rows deleted
+ * @throws DBError
+ */
+ public function delete( User $user, string $token ): int {
+ $this->dbw->delete(
+ 'echo_push_subscription',
+ [
+ 'eps_user' => $this->getCentralId( $user ),
+ 'eps_token' => $token,
+ ],
+ __METHOD__
+ );
+ return $this->dbw->affectedRows();
+ }
+
+ /**
+ * Get the user's central ID.
+ * @param User $user
+ * @return int
+ */
+ private function getCentralId( User $user ): int {
+ return $this->centralIdLookup->centralIdFromLocalUser(
+ $user,
+ CentralIdLookup::AUDIENCE_RAW
+ );
+ }
+
+}
diff --git a/Echo/includes/ResourceLoaderEchoImageModule.php b/Echo/includes/ResourceLoaderEchoImageModule.php
index c0b103a4..5cb6f955 100644
--- a/Echo/includes/ResourceLoaderEchoImageModule.php
+++ b/Echo/includes/ResourceLoaderEchoImageModule.php
@@ -56,7 +56,7 @@ class ResourceLoaderEchoImageModule extends ResourceLoaderImageModule {
$this->definition[ 'images' ] = $images;
$this->definition[ 'selector' ] = '.oo-ui-icon-{name}';
- // Parent
+
parent::loadFromDefinition();
}
}
diff --git a/Echo/includes/SeenTime.php b/Echo/includes/SeenTime.php
index d008a52b..bec18ef7 100644
--- a/Echo/includes/SeenTime.php
+++ b/Echo/includes/SeenTime.php
@@ -1,5 +1,7 @@
<?php
+use MediaWiki\MediaWikiServices;
+
/**
* A small wrapper around ObjectCache to manage
* storing the last time a user has seen notifications
@@ -39,18 +41,23 @@ class EchoSeenTime {
* @return BagOStuff
*/
private static function cache() {
- static $c = null;
-
- // Use main stash for persistent storage, and
- // wrap it with CachedBagOStuff for an in-process
- // cache. (T144534)
- if ( $c === null ) {
- $c = new CachedBagOStuff(
- ObjectCache::getMainStashInstance()
- );
+ static $wrappedCache = null;
+
+ // Use a configurable cache backend (T222851) and wrap it with CachedBagOStuff
+ // for an in-process cache (T144534)
+ if ( $wrappedCache === null ) {
+ $cacheConfig = MediaWikiServices::getInstance()->getMainConfig()->get( 'EchoSeenTimeCacheType' );
+ if ( $cacheConfig === null ) {
+ // EchoHooks::initEchoExtension sets EchoSeenTimeCacheType to $wgMainStash if it's
+ // null, so this can only happen if $wgMainStash is also null
+ throw new UnexpectedValueException(
+ 'Either $wgEchoSeenTimeCacheType or $wgMainStash must be set'
+ );
+ }
+ return new CachedBagOStuff( ObjectCache::getInstance( $cacheConfig ) );
}
- return $c;
+ return $wrappedCache;
}
/**
@@ -82,11 +89,9 @@ class EchoSeenTime {
}
if ( $data === false ) {
- // There is still no time set, so set time to the UNIX epoch.
// We can't remember their real seen time, so reset everything to
// unseen.
$data = wfTimestamp( TS_MW, 1 );
- $this->setTime( $data, $type );
}
return wfTimestamp( $format, $data );
}
@@ -113,9 +118,9 @@ class EchoSeenTime {
// the real cache
$key = $this->getMemcKey( $type );
$cache = self::cache();
- $cache->set( $key, $time, 0, BagOStuff::WRITE_CACHE_ONLY );
+ $cache->set( $key, $time, $cache::TTL_YEAR, BagOStuff::WRITE_CACHE_ONLY );
DeferredUpdates::addCallableUpdate( function () use ( $key, $time, $cache ) {
- $cache->set( $key, $time );
+ $cache->set( $key, $time, $cache::TTL_YEAR );
} );
}
@@ -136,7 +141,9 @@ class EchoSeenTime {
* @return string Memcached key
*/
protected function getMemcKey( $type = 'all' ) {
- $localKey = wfMemcKey( 'echo', 'seen', $type, 'time', $this->user->getId() );
+ $localKey = self::cache()->makeKey(
+ 'echo', 'seen', $type, 'time', $this->user->getId()
+ );
if ( !$this->user->getOption( 'echo-cross-wiki-notifications' ) ) {
return $localKey;
@@ -149,6 +156,8 @@ class EchoSeenTime {
return $localKey;
}
- return wfGlobalCacheKey( 'echo', 'seen', $type, 'time', $globalId );
+ return self::cache()->makeGlobalKey(
+ 'echo', 'seen', $type, 'time', $globalId
+ );
}
}
diff --git a/Echo/includes/UnreadWikis.php b/Echo/includes/UnreadWikis.php
index f2b9441b..ef02df64 100644
--- a/Echo/includes/UnreadWikis.php
+++ b/Echo/includes/UnreadWikis.php
@@ -4,9 +4,7 @@
* Manages what wikis a user has unread notifications on
*/
class EchoUnreadWikis {
- /**
- * @var string
- */
+
const DEFAULT_TS = '00000000000000';
/**
@@ -52,7 +50,7 @@ class EchoUnreadWikis {
}
/**
- * @return array[]
+ * @return array[][]
*/
public function getUnreadCounts() {
$dbr = $this->getDB( DB_REPLICA );
@@ -128,7 +126,7 @@ class EchoUnreadWikis {
$dbw->upsert(
'echo_unread_wikis',
$conditions + $values,
- [ 'euw_user', 'euw_wiki' ],
+ [ [ 'euw_user', 'euw_wiki' ] ],
$values,
__METHOD__
);
diff --git a/Echo/includes/UserLocator.php b/Echo/includes/UserLocator.php
index 2e49e739..85cbecfc 100644
--- a/Echo/includes/UserLocator.php
+++ b/Echo/includes/UserLocator.php
@@ -1,5 +1,7 @@
<?php
+use MediaWiki\MediaWikiServices;
+
class EchoUserLocator {
/**
* Return all users watching the event title.
@@ -9,7 +11,7 @@ class EchoUserLocator {
*
* @param EchoEvent $event
* @param int $batchSize
- * @return User[]
+ * @return User[]|Iterator<User>
*/
public static function locateUsersWatchingTitle( EchoEvent $event, $batchSize = 500 ) {
$title = $event->getTitle();
@@ -40,7 +42,7 @@ class EchoUserLocator {
}
/**
- * If the event occured on the talk page of a registered
+ * If the event occurred on the talk page of a registered
* user return that user.
*
* @param EchoEvent $event
@@ -55,9 +57,9 @@ class EchoUserLocator {
$user = User::newFromName( $title->getDBkey() );
if ( $user && !$user->isAnon() ) {
return [ $user->getId() => $user ];
- } else {
- return [];
}
+
+ return [];
}
/**
@@ -70,9 +72,9 @@ class EchoUserLocator {
$agent = $event->getAgent();
if ( $agent && !$agent->isAnon() ) {
return [ $agent->getId() => $agent ];
- } else {
- return [];
}
+
+ return [];
}
/**
@@ -90,7 +92,7 @@ class EchoUserLocator {
}
$dbr = wfGetDB( DB_REPLICA );
- $revQuery = Revision::getQueryInfo();
+ $revQuery = MediaWikiServices::getInstance()->getRevisionStore()->getQueryInfo();
$res = $dbr->selectRow(
$revQuery['tables'],
[ 'rev_user' => $revQuery['fields']['rev_user'] ],
@@ -106,9 +108,9 @@ class EchoUserLocator {
$user = User::newFromId( $res->rev_user );
if ( $user ) {
return [ $user->getId() => $user ];
- } else {
- return [];
}
+
+ return [];
}
/**
@@ -131,7 +133,8 @@ class EchoUserLocator {
$userIds = $event->getExtraParam( $key );
if ( !$userIds ) {
continue;
- } elseif ( !is_array( $userIds ) ) {
+ }
+ if ( !is_array( $userIds ) ) {
$userIds = [ $userIds ];
}
foreach ( $userIds as $userId ) {
diff --git a/Echo/includes/api/ApiCrossWiki.php b/Echo/includes/api/ApiCrossWiki.php
index a4d0cba2..b3f75b7e 100644
--- a/Echo/includes/api/ApiCrossWiki.php
+++ b/Echo/includes/api/ApiCrossWiki.php
@@ -1,5 +1,5 @@
<?php
-
+// @phan-file-suppress PhanUndeclaredMethod This is a trait, and phan is confused by $this
/**
* Trait that adds cross-wiki functionality to an API module. For mixing into ApiBase subclasses.
*
@@ -23,7 +23,7 @@ trait ApiCrossWiki {
* @return array[]
* @throws Exception
*/
- protected function getFromForeign( $wikis = null, array $paramOverrides = [] ) {
+ protected function getFromForeign( array $wikis = null, array $paramOverrides = [] ) {
$wikis = $wikis ?? $this->getRequestedForeignWikis();
if ( $wikis === [] ) {
return [];
@@ -32,7 +32,7 @@ trait ApiCrossWiki {
$foreignReq = new EchoForeignWikiRequest(
$this->getUser(),
$paramOverrides + $this->getForeignQueryParams(),
- $wikis !== null ? $wikis : $this->getRequestedForeignWikis(),
+ $wikis,
$this->getModulePrefix() . 'wikis',
$tokenType !== false ? $tokenType : null
);
@@ -69,7 +69,7 @@ trait ApiCrossWiki {
// if wiki is omitted from params, that's because crosswiki is/was not
// available, and it'll default to current wiki
- $wikis = isset( $params['wikis'] ) ? $params['wikis'] : [ wfWikiID() ];
+ $wikis = $params['wikis'] ?? [ wfWikiID() ];
if ( array_search( '*', $wikis ) !== false ) {
// expand `*` to all foreign wikis with unread notifications + local
diff --git a/Echo/includes/api/ApiEchoArticleReminder.php b/Echo/includes/api/ApiEchoArticleReminder.php
index dcc779d5..6c8c4363 100644
--- a/Echo/includes/api/ApiEchoArticleReminder.php
+++ b/Echo/includes/api/ApiEchoArticleReminder.php
@@ -25,7 +25,6 @@ class ApiEchoArticleReminder extends ApiBase {
'agent' => $user,
'title' => $this->getTitleFromTitleOrPageId( $params ),
'extra' => [
- 'notifyAgent' => true,
'comment' => $params['comment'],
],
] );
@@ -108,6 +107,6 @@ class ApiEchoArticleReminder extends ApiBase {
}
public function getHelpUrls() {
- return 'https://www.mediawiki.org/wiki/Echo_(Notifications)/API';
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/Echo_(Notifications)/API';
}
}
diff --git a/Echo/includes/api/ApiEchoMarkRead.php b/Echo/includes/api/ApiEchoMarkRead.php
index 273d2b68..93021715 100644
--- a/Echo/includes/api/ApiEchoMarkRead.php
+++ b/Echo/includes/api/ApiEchoMarkRead.php
@@ -123,6 +123,6 @@ class ApiEchoMarkRead extends ApiBase {
}
public function getHelpUrls() {
- return 'https://www.mediawiki.org/wiki/Echo_(Notifications)/API';
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/Echo_(Notifications)/API';
}
}
diff --git a/Echo/includes/api/ApiEchoMarkSeen.php b/Echo/includes/api/ApiEchoMarkSeen.php
index 46069166..50eeccd9 100644
--- a/Echo/includes/api/ApiEchoMarkSeen.php
+++ b/Echo/includes/api/ApiEchoMarkSeen.php
@@ -1,5 +1,7 @@
<?php
+// This is a GET module, not a POST module, for multi-DC support. See T222851.
+// Note that this module doesn't write to the database, only to the seentime cache.
class ApiEchoMarkSeen extends ApiBase {
public function execute() {
@@ -20,7 +22,10 @@ class ApiEchoMarkSeen extends ApiBase {
$outputTimestamp = wfTimestamp( TS_ISO_8601, $timestamp );
} else {
// MW
- $this->addDeprecation( 'apiwarn-echo-deprecation-timestampformat', 'action=echomarkseen&timestampFormat=MW' );
+ $this->addDeprecation(
+ 'apiwarn-echo-deprecation-timestampformat',
+ 'action=echomarkseen&timestampFormat=MW'
+ );
$outputTimestamp = $timestamp;
}
@@ -33,9 +38,6 @@ class ApiEchoMarkSeen extends ApiBase {
public function getAllowedParams() {
return [
- 'token' => [
- ApiBase::PARAM_REQUIRED => true,
- ],
'type' => [
ApiBase::PARAM_REQUIRED => true,
ApiBase::PARAM_TYPE => [ 'alert', 'message', 'all' ],
@@ -48,22 +50,6 @@ class ApiEchoMarkSeen extends ApiBase {
];
}
- public function needsToken() {
- return 'csrf';
- }
-
- public function getTokenSalt() {
- return '';
- }
-
- public function mustBePosted() {
- return true;
- }
-
- public function isWriteMode() {
- return true;
- }
-
/**
* @see ApiBase::getExamplesMessages()
* @return string[]
@@ -75,6 +61,6 @@ class ApiEchoMarkSeen extends ApiBase {
}
public function getHelpUrls() {
- return 'https://www.mediawiki.org/wiki/Echo_(Notifications)/API';
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/Echo_(Notifications)/API';
}
}
diff --git a/Echo/includes/api/ApiEchoMute.php b/Echo/includes/api/ApiEchoMute.php
new file mode 100644
index 00000000..e050e3bd
--- /dev/null
+++ b/Echo/includes/api/ApiEchoMute.php
@@ -0,0 +1,130 @@
+<?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'] );
+ $targetsToMute = $params['mute'] ?? [];
+ $targetsToUnmute = $params['unmute'] ?? [];
+
+ $changed = false;
+ $addIds = $this->lookupIds( $targetsToMute, $mutelistInfo['type'] );
+ foreach ( $addIds as $id ) {
+ if ( !in_array( $id, $ids ) ) {
+ $ids[] = $id;
+ $changed = true;
+ }
+ }
+ $removeIds = $this->lookupIds( $targetsToUnmute, $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;
+ }
+
+}
diff --git a/Echo/includes/api/ApiEchoNotifications.php b/Echo/includes/api/ApiEchoNotifications.php
index e549a702..3d4736c4 100644
--- a/Echo/includes/api/ApiEchoNotifications.php
+++ b/Echo/includes/api/ApiEchoNotifications.php
@@ -74,7 +74,7 @@ class ApiEchoNotifications extends ApiQueryBase {
$prop = $params['prop'];
$titles = null;
if ( $params['titles'] ) {
- $titles = array_values( array_filter( array_map( 'Title::newFromText', $params['titles'] ) ) );
+ $titles = array_values( array_filter( array_map( [ Title::class, 'newFromText' ], $params['titles'] ) ) );
if ( in_array( '[]', $params['titles'] ) ) {
$titles[] = null;
}
@@ -110,7 +110,9 @@ class ApiEchoNotifications extends ApiQueryBase {
// if exactly 1 section is specified, we consider only that section, otherwise
// we pass ALL to consider all foreign notifications
- $section = count( $params['sections'] ) === 1 ? reset( $params['sections'] ) : EchoAttributeManager::ALL;
+ $section = count( $params['sections'] ) === 1
+ ? reset( $params['sections'] )
+ : EchoAttributeManager::ALL;
if ( $this->crossWikiSummary ) {
$foreignNotification = $this->makeForeignNotification( $user, $params['format'], $section );
if ( $foreignNotification ) {
@@ -141,7 +143,7 @@ class ApiEchoNotifications extends ApiQueryBase {
* Internal method for getting the property 'list' data for individual section
* @param User $user
* @param string $section 'alert' or 'message'
- * @param string $filter 'all', 'read' or 'unread'
+ * @param string[] $filter 'all', 'read' or 'unread'
* @param int $limit
* @param string $continue
* @param string $format
@@ -184,7 +186,7 @@ class ApiEchoNotifications extends ApiQueryBase {
* of a set of sections or a single section
* @param User $user
* @param string[] $eventTypes
- * @param string $filter 'all', 'read' or 'unread'
+ * @param string[] $filter 'all', 'read' or 'unread'
* @param int $limit
* @param string $continue
* @param string $format
@@ -236,7 +238,7 @@ class ApiEchoNotifications extends ApiQueryBase {
/** @var EchoNotification $first */
$first = reset( $notifs );
$continueId = intval( trim( strrchr( $continue, '|' ), '|' ) );
- if ( $first->getEvent()->getID() !== $continueId ) {
+ if ( $first->getEvent()->getId() !== $continueId ) {
// notification doesn't match continue id, it must've been
// about read notifications: discard all unread ones
$notifs = [];
@@ -278,6 +280,7 @@ class ApiEchoNotifications extends ApiQueryBase {
/** @var EchoNotification $overfetchedItem */
$overfetchedItem = count( $notifs ) > $limit ? array_pop( $notifs ) : null;
+ $bundler = null;
if ( $bundle ) {
$bundler = new Bundler();
$notifs = $bundler->bundle( $notifs );
@@ -289,7 +292,7 @@ class ApiEchoNotifications extends ApiQueryBase {
$output = EchoDataOutputFormatter::formatOutput( $notif, $format, $user, $this->getLanguage() );
if ( $output !== false ) {
$result['list'][] = $output;
- } elseif ( $bundle && $notif->getBundledNotifications() ) {
+ } elseif ( $bundler && $notif->getBundledNotifications() ) {
// when the bundle_base gets filtered out, bundled notifications
// have to be re-bundled and formatted
$notifs = array_merge( $bundler->bundle( $notif->getBundledNotifications() ), $notifs );
@@ -371,61 +374,12 @@ class ApiEchoNotifications extends ApiQueryBase {
$format,
$section = EchoAttributeManager::ALL
) {
- global $wgEchoSectionTransition, $wgEchoBundleTransition;
- if (
- ( $wgEchoSectionTransition && $section !== EchoAttributeManager::ALL ) ||
- $wgEchoBundleTransition
- ) {
- // In section transition mode we trust that echo_unread_wikis is accurate for the total of alerts+messages,
- // but not for each section individually (i.e. we don't trust that notifications won't be misclassified).
- // We get all wikis that have any notifications at all according to the euw table,
- // and query them to find out what's really there.
- // In bundle transition mode, we trust that notifications are classified correctly, but we don't
- // trust the counts in the table.
- $potentialWikis = $this->getForeignNotifications()->getWikis(
- $wgEchoSectionTransition ? EchoAttributeManager::ALL : $section );
- if ( !$potentialWikis ) {
- return false;
- }
- $foreignResults = $this->getFromForeign( $potentialWikis,
- [ $this->getModulePrefix() . 'filter' => '!read' ] );
-
- $countsByWiki = [];
- $timestampsByWiki = [];
- foreach ( $foreignResults as $wiki => $result ) {
- if ( isset( $result['query']['notifications']['list'] ) ) {
- $notifs = $result['query']['notifications']['list'];
- $countsByWiki[$wiki] = intval( $result['query']['notifications']['count'] );
- } elseif ( isset( $result['query']['notifications'][$section]['list'] ) ) {
- $notifs = $result['query']['notifications'][$section]['list'];
- $countsByWiki[$wiki] = intval( $result['query']['notifications'][$section]['count'] );
- } else {
- $notifs = false;
- $countsByWiki[$wiki] = 0;
- }
- if ( $notifs ) {
- $timestamps = array_filter( array_map( function ( $n ) {
- return $n['timestamp']['mw'];
- }, $notifs ) );
- $timestampsByWiki[$wiki] = $timestamps ? max( $timestamps ) : 0;
- }
- }
-
- $wikis = array_keys( $timestampsByWiki );
- $count = array_sum( $countsByWiki );
- $maxTimestamp = new MWTimestamp( $timestampsByWiki ? max( $timestampsByWiki ) : 0 );
- $timestampsByWiki = array_map( function ( $ts ) {
- return new MWTimestamp( $ts );
- }, $timestampsByWiki );
- } else {
- // In non-transition mode, or when querying all sections, we can trust the euw table
- $wikis = $this->getForeignNotifications()->getWikis( $section );
- $count = $this->getForeignNotifications()->getCount( $section );
- $maxTimestamp = $this->getForeignNotifications()->getTimestamp( $section );
- $timestampsByWiki = [];
- foreach ( $wikis as $wiki ) {
- $timestampsByWiki[$wiki] = $this->getForeignNotifications()->getWikiTimestamp( $wiki, $section );
- }
+ $wikis = $this->getForeignNotifications()->getWikis( $section );
+ $count = $this->getForeignNotifications()->getCount( $section );
+ $maxTimestamp = $this->getForeignNotifications()->getTimestamp( $section );
+ $timestampsByWiki = [];
+ foreach ( $wikis as $wiki ) {
+ $timestampsByWiki[$wiki] = $this->getForeignNotifications()->getWikiTimestamp( $wiki, $section );
}
if ( $count === 0 || $wikis === [] ) {
@@ -434,18 +388,17 @@ class ApiEchoNotifications extends ApiQueryBase {
// Sort wikis by timestamp, in descending order (newest first)
usort( $wikis, function ( $a, $b ) use ( $section, $timestampsByWiki ) {
- return $timestampsByWiki[$b]->getTimestamp( TS_UNIX ) - $timestampsByWiki[$a]->getTimestamp( TS_UNIX );
+ return (int)$timestampsByWiki[$b]->getTimestamp( TS_UNIX )
+ - (int)$timestampsByWiki[$a]->getTimestamp( TS_UNIX );
} );
- $row = new StdClass;
+ $row = new stdClass;
$row->event_id = -1;
$row->event_type = 'foreign';
$row->event_variant = null;
$row->event_agent_id = $user->getId();
$row->event_agent_ip = null;
$row->event_page_id = null;
- $row->event_page_namespace = null;
- $row->event_page_title = null;
$row->event_extra = serialize( [
'section' => $section ?: 'all',
'wikis' => $wikis,
@@ -456,9 +409,7 @@ class ApiEchoNotifications extends ApiQueryBase {
$row->notification_user = $user->getId();
$row->notification_timestamp = $maxTimestamp;
$row->notification_read_timestamp = null;
- $row->notification_bundle_base = 1;
$row->notification_bundle_hash = md5( 'bogus' );
- $row->notification_bundle_display_hash = md5( 'also-bogus' );
// Format output like any other notification
$notif = EchoNotification::newFromRow( $row );
@@ -485,9 +436,9 @@ class ApiEchoNotifications extends ApiQueryBase {
}
/**
- * @param array $results
+ * @param array[] $results
* @param array $params
- * @return mixed
+ * @return array
*/
protected function mergeResults( array $results, array $params ) {
$master = array_shift( $results );
@@ -511,7 +462,7 @@ class ApiEchoNotifications extends ApiQueryBase {
/**
* @param array $master
- * @param array $results
+ * @param array[] $results
* @param bool $groupBySection
* @return array
*/
@@ -546,7 +497,7 @@ class ApiEchoNotifications extends ApiQueryBase {
/**
* @param array $master
- * @param array $results
+ * @param array[] $results
* @param bool $groupBySection
* @return array
*/
@@ -673,6 +624,6 @@ class ApiEchoNotifications extends ApiQueryBase {
}
public function getHelpUrls() {
- return 'https://www.mediawiki.org/wiki/Echo_(Notifications)/API';
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/Echo_(Notifications)/API';
}
}
diff --git a/Echo/includes/api/ApiEchoUnreadNotificationPages.php b/Echo/includes/api/ApiEchoUnreadNotificationPages.php
index f085f3ca..95f9b5df 100644
--- a/Echo/includes/api/ApiEchoUnreadNotificationPages.php
+++ b/Echo/includes/api/ApiEchoUnreadNotificationPages.php
@@ -30,7 +30,7 @@ class ApiEchoUnreadNotificationPages extends ApiQueryBase {
$params = $this->extractRequestParams();
$result = [];
- if ( in_array( wfWikiId(), $this->getRequestedWikis() ) ) {
+ if ( in_array( wfWikiID(), $this->getRequestedWikis() ) ) {
$result[wfWikiID()] = $this->getFromLocal( $params['limit'], $params['grouppages'] );
}
@@ -51,6 +51,7 @@ class ApiEchoUnreadNotificationPages extends ApiQueryBase {
* @param int $limit
* @param bool $groupPages
* @return array
+ * @phan-return array{pages:array[],totalCount:int}
*/
protected function getFromLocal( $limit, $groupPages ) {
$attributeManager = EchoAttributeManager::newFromGlobalVars();
@@ -77,19 +78,24 @@ class ApiEchoUnreadNotificationPages extends ApiQueryBase {
);
if ( $rows === false ) {
- return [];
+ return [
+ 'pages' => [],
+ 'totalCount' => 0,
+ ];
}
$nullCount = 0;
$pageCounts = [];
foreach ( $rows as $row ) {
if ( $row->event_page_id !== null ) {
+ // @phan-suppress-next-line PhanTypeMismatchDimAssignment
$pageCounts[$row->event_page_id] = intval( $row->count );
} else {
$nullCount = intval( $row->count );
}
}
+ // @phan-suppress-next-line PhanTypeMismatchArgument
$titles = Title::newFromIDs( array_keys( $pageCounts ) );
$groupCounts = [];
@@ -101,7 +107,8 @@ class ApiEchoUnreadNotificationPages extends ApiQueryBase {
$pageName = $title->getPrefixedText();
}
- $count = $pageCounts[$title->getArticleId()];
+ // @phan-suppress-next-line PhanTypeMismatchDimFetch
+ $count = $pageCounts[$title->getArticleID()];
if ( isset( $groupCounts[$pageName] ) ) {
$groupCounts[$pageName] += $count;
} else {
@@ -162,7 +169,7 @@ class ApiEchoUnreadNotificationPages extends ApiQueryBase {
}
/**
- * @return array
+ * @return array[]
*/
protected function getUnreadNotificationPagesFromForeign() {
$result = [];
@@ -210,6 +217,6 @@ class ApiEchoUnreadNotificationPages extends ApiQueryBase {
}
public function getHelpUrls() {
- return 'https://www.mediawiki.org/wiki/Echo_(Notifications)/API';
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/Echo_(Notifications)/API';
}
}
diff --git a/Echo/includes/api/Push/ApiEchoPushSubscriptions.php b/Echo/includes/api/Push/ApiEchoPushSubscriptions.php
new file mode 100644
index 00000000..ac067294
--- /dev/null
+++ b/Echo/includes/api/Push/ApiEchoPushSubscriptions.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace EchoPush\Api;
+
+use ApiBase;
+use ApiModuleManager;
+use ApiUsageException;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\ParamValidator\ParamValidator;
+
+/**
+ * API parent module for administering push subscriptions.
+ * Each operation (command) is implemented as a submodule. This module just performs some basic
+ * checks and dispatches the execute() call.
+ */
+class ApiEchoPushSubscriptions extends ApiBase {
+
+ /** array Module name => module class */
+ private const SUBMODULES = [
+ 'create' => ApiEchoPushSubscriptionsCreate::class,
+ 'delete' => ApiEchoPushSubscriptionsDelete::class,
+ ];
+
+ /** @var ApiModuleManager */
+ private $moduleManager;
+
+ /** @inheritDoc */
+ public function execute(): void {
+ $this->checkLoginState();
+ $this->checkUserRightsAny( 'editmyprivateinfo' );
+ $command = $this->getParameter( 'command' );
+ $module = $this->moduleManager->getModule( $command, 'command' );
+ $module->execute();
+ $module->getResult()->addValue(
+ null,
+ $module->getModuleName(),
+ [ 'result' => 'Success' ]
+ );
+ }
+
+ /** @inheritDoc */
+ public function getModuleManager(): ApiModuleManager {
+ if ( !$this->moduleManager ) {
+ $submodules = array_map( function ( $class ) {
+ return [
+ 'class' => $class,
+ 'factory' => "$class::factory",
+ ];
+ }, self::SUBMODULES );
+ $this->moduleManager = new ApiModuleManager(
+ $this,
+ MediaWikiServices::getInstance()->getObjectFactory()
+ );
+ $this->moduleManager->addModules( $submodules, 'command' );
+ }
+ return $this->moduleManager;
+ }
+
+ /** @inheritDoc */
+ protected function getAllowedParams(): array {
+ return [
+ 'command' => [
+ ParamValidator::PARAM_TYPE => 'submodule',
+ ParamValidator::PARAM_REQUIRED => true,
+ ],
+ ];
+ }
+
+ /**
+ * Bail out with an API error if the user is not logged in.
+ * @throws ApiUsageException
+ */
+ private function checkLoginState(): void {
+ if ( $this->getUser()->isAnon() ) {
+ $this->dieWithError(
+ [ 'apierror-mustbeloggedin', $this->msg( 'action-editmyprivateinfo' ) ],
+ 'notloggedin'
+ );
+ }
+ }
+
+ /** @inheritDoc */
+ public function getHelpUrls(): string {
+ return 'https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:Echo#API';
+ }
+
+ /** @inheritDoc */
+ public function isWriteMode(): bool {
+ return true;
+ }
+
+ /** @inheritDoc */
+ public function needsToken(): string {
+ return 'csrf';
+ }
+
+ /** @inheritDoc */
+ public function isInternal(): bool {
+ // experimental!
+ return true;
+ }
+
+}
diff --git a/Echo/includes/api/Push/ApiEchoPushSubscriptionsCreate.php b/Echo/includes/api/Push/ApiEchoPushSubscriptionsCreate.php
new file mode 100644
index 00000000..db8009ec
--- /dev/null
+++ b/Echo/includes/api/Push/ApiEchoPushSubscriptionsCreate.php
@@ -0,0 +1,115 @@
+<?php
+
+namespace EchoPush\Api;
+
+use ApiBase;
+use ApiMain;
+use EchoPush\SubscriptionManager;
+use EchoServices;
+use Wikimedia\ParamValidator\ParamValidator;
+
+class ApiEchoPushSubscriptionsCreate extends ApiBase {
+
+ /**
+ * Supported push notification providers:
+ * (1) fcm: Firebase Cloud Messaging
+ * (2) apns: Apple Push Notification Service
+ */
+ private const PROVIDERS = [ 'fcm', 'apns' ];
+
+ /** @var ApiBase */
+ private $parent;
+
+ /** @var SubscriptionManager */
+ private $subscriptionManager;
+
+ /**
+ * Static entry point for initializing the module
+ * @param ApiBase $parent Parent module
+ * @param string $name Module name
+ * @return ApiEchoPushSubscriptionsCreate
+ */
+ public static function factory( ApiBase $parent, string $name ):
+ ApiEchoPushSubscriptionsCreate {
+ $subscriptionManger = EchoServices::getInstance()->getPushSubscriptionManager();
+ $module = new self( $parent->getMain(), $name, $subscriptionManger );
+ $module->parent = $parent;
+ return $module;
+ }
+
+ /**
+ * @param ApiMain $mainModule
+ * @param string $moduleName
+ * @param SubscriptionManager $subscriptionManager
+ */
+ public function __construct(
+ ApiMain $mainModule,
+ string $moduleName,
+ SubscriptionManager $subscriptionManager
+ ) {
+ parent::__construct( $mainModule, $moduleName );
+ $this->subscriptionManager = $subscriptionManager;
+ }
+
+ /**
+ * Entry point for executing the module.
+ * @inheritDoc
+ */
+ public function execute(): void {
+ $provider = $this->getParameter( 'provider' );
+ $token = $this->getParameter( 'providertoken' );
+ $success = $this->subscriptionManager->create( $this->getUser(), $provider, $token );
+ if ( !$success ) {
+ $this->dieWithError( 'apierror-echo-push-token-exists' );
+ }
+ }
+
+ /**
+ * Get the parent module.
+ * @return ApiBase
+ */
+ public function getParent(): ApiBase {
+ return $this->parent;
+ }
+
+ /** @inheritDoc */
+ protected function getAllowedParams(): array {
+ return [
+ 'provider' => [
+ ParamValidator::PARAM_TYPE => self::PROVIDERS,
+ ParamValidator::PARAM_REQUIRED => true,
+ ],
+ 'providertoken' => [
+ ParamValidator::PARAM_TYPE => 'string',
+ ParamValidator::PARAM_REQUIRED => true,
+ ],
+ ];
+ }
+
+ /** @inheritDoc */
+ protected function getExamplesMessages(): array {
+ return [
+ "action=echopushsubscriptions&command=create&provider=fcm&providertoken=ABC123" =>
+ "apihelp-echopushsubscriptions+create-example"
+ ];
+ }
+
+ // The parent module already enforces these but they make documentation nicer.
+
+ /** @inheritDoc */
+ public function isWriteMode(): bool {
+ return true;
+ }
+
+ /** @inheritDoc */
+ public function mustBePosted(): bool {
+ return true;
+ }
+
+ /** @inheritDoc */
+ public function isInternal(): bool {
+ // experimental!
+ return true;
+ }
+
+}
diff --git a/Echo/includes/api/Push/ApiEchoPushSubscriptionsDelete.php b/Echo/includes/api/Push/ApiEchoPushSubscriptionsDelete.php
new file mode 100644
index 00000000..e6331d23
--- /dev/null
+++ b/Echo/includes/api/Push/ApiEchoPushSubscriptionsDelete.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace EchoPush\Api;
+
+use ApiBase;
+use ApiMain;
+use EchoPush\SubscriptionManager;
+use EchoServices;
+use Wikimedia\ParamValidator\ParamValidator;
+
+class ApiEchoPushSubscriptionsDelete extends ApiBase {
+
+ /** @var ApiBase */
+ private $parent;
+
+ /** @var SubscriptionManager */
+ private $subscriptionManager;
+
+ /**
+ * Static entry point for initializing the module
+ * @param ApiBase $parent Parent module
+ * @param string $name Module name
+ * @return ApiEchoPushSubscriptionsDelete
+ */
+ public static function factory( ApiBase $parent, string $name ):
+ ApiEchoPushSubscriptionsDelete {
+ $subscriptionManager = EchoServices::getInstance()->getPushSubscriptionManager();
+ $module = new self( $parent->getMain(), $name, $subscriptionManager );
+ $module->parent = $parent;
+ return $module;
+ }
+
+ /**
+ * @param ApiMain $mainModule
+ * @param string $moduleName
+ * @param SubscriptionManager $subscriptionManager
+ */
+ public function __construct(
+ ApiMain $mainModule,
+ string $moduleName,
+ SubscriptionManager $subscriptionManager
+ ) {
+ parent::__construct( $mainModule, $moduleName );
+ $this->subscriptionManager = $subscriptionManager;
+ }
+
+ /**
+ * Entry point for executing the module.
+ * @inheritDoc
+ */
+ public function execute(): void {
+ $token = $this->getParameter( 'providertoken' );
+ $numRowsDeleted = $this->subscriptionManager->delete( $this->getUser(), $token );
+ if ( $numRowsDeleted == 0 ) {
+ $this->dieWithError( 'apierror-echo-push-token-not-found' );
+ }
+ }
+
+ /**
+ * Get the parent module.
+ * @return ApiBase
+ */
+ public function getParent(): ApiBase {
+ return $this->parent;
+ }
+
+ /** @inheritDoc */
+ protected function getAllowedParams(): array {
+ return [
+ 'providertoken' => [
+ ParamValidator::PARAM_TYPE => 'string',
+ ParamValidator::PARAM_REQUIRED => true,
+ ],
+ ];
+ }
+
+ /** @inheritDoc */
+ protected function getExamplesMessages(): array {
+ return [
+ "action=echopushsubscriptions&command=delete&providertoken=ABC123" =>
+ "apihelp-echopushsubscriptions+delete-example"
+ ];
+ }
+
+ // The parent module already enforces these but they make documentation nicer.
+
+ /** @inheritDoc */
+ public function isWriteMode(): bool {
+ return true;
+ }
+
+ /** @inheritDoc */
+ public function mustBePosted(): bool {
+ return true;
+ }
+
+ /** @inheritDoc */
+ public function isInternal(): bool {
+ // experimental!
+ return true;
+ }
+
+}
diff --git a/Echo/includes/cache/LocalCache.php b/Echo/includes/cache/LocalCache.php
index 069d0f44..cebe8621 100644
--- a/Echo/includes/cache/LocalCache.php
+++ b/Echo/includes/cache/LocalCache.php
@@ -21,7 +21,7 @@ abstract class EchoLocalCache {
/**
* Lookup ids that have not been resolved for a target
- * @var int[]
+ * @var bool[]
*/
private $lookups = [];
@@ -50,7 +50,7 @@ abstract class EchoLocalCache {
public function add( $key ) {
if (
count( $this->lookups ) < self::TARGET_MAX_NUM
- && !$this->targets->get( $key )
+ && !$this->targets->get( (string)$key )
) {
$this->lookups[$key] = true;
}
@@ -63,7 +63,7 @@ abstract class EchoLocalCache {
* @return mixed|null
*/
public function get( $key ) {
- $target = $this->targets->get( $key );
+ $target = $this->targets->get( (string)$key );
if ( $target ) {
return $target;
}
@@ -75,7 +75,7 @@ abstract class EchoLocalCache {
$this->targets->set( $id, $val );
}
$this->lookups = [];
- $target = $this->targets->get( $key );
+ $target = $this->targets->get( (string)$key );
if ( $target ) {
return $target;
}
diff --git a/Echo/includes/cache/RevisionLocalCache.php b/Echo/includes/cache/RevisionLocalCache.php
index 18183322..4c8c31a6 100644
--- a/Echo/includes/cache/RevisionLocalCache.php
+++ b/Echo/includes/cache/RevisionLocalCache.php
@@ -1,7 +1,9 @@
<?php
+use MediaWiki\MediaWikiServices;
+
/**
- * Cache class that maps revision id to Revision object
+ * Cache class that maps revision id to RevisionStore object
*/
class EchoRevisionLocalCache extends EchoLocalCache {
@@ -25,8 +27,9 @@ class EchoRevisionLocalCache extends EchoLocalCache {
* @inheritDoc
*/
protected function resolve( array $lookups ) {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
$dbr = wfGetDB( DB_REPLICA );
- $revQuery = Revision::getQueryInfo( [ 'page', 'user' ] );
+ $revQuery = $store->getQueryInfo( [ 'page', 'user' ] );
$res = $dbr->select(
$revQuery['tables'],
$revQuery['fields'],
@@ -36,7 +39,7 @@ class EchoRevisionLocalCache extends EchoLocalCache {
$revQuery['joins']
);
foreach ( $res as $row ) {
- yield $row->rev_id => new Revision( $row );
+ yield $row->rev_id => $store->newRevisionFromRow( $row );
}
}
}
diff --git a/Echo/includes/cache/TitleLocalCache.php b/Echo/includes/cache/TitleLocalCache.php
index d896b012..b756b0f7 100644
--- a/Echo/includes/cache/TitleLocalCache.php
+++ b/Echo/includes/cache/TitleLocalCache.php
@@ -28,7 +28,7 @@ class EchoTitleLocalCache extends EchoLocalCache {
if ( $lookups ) {
$titles = Title::newFromIDs( $lookups );
foreach ( $titles as $title ) {
- yield $title->getArticleId() => $title;
+ yield $title->getArticleID() => $title;
}
}
}
diff --git a/Echo/includes/controller/ModerationController.php b/Echo/includes/controller/ModerationController.php
index b22090a8..1c2c6884 100644
--- a/Echo/includes/controller/ModerationController.php
+++ b/Echo/includes/controller/ModerationController.php
@@ -14,7 +14,7 @@ class EchoModerationController {
* @param bool $moderate Whether to moderate or unmoderate the events
* @throws MWException
*/
- public static function moderate( $eventIds, $moderate ) {
+ public static function moderate( array $eventIds, $moderate ) {
if ( !$eventIds ) {
return;
}
@@ -25,12 +25,14 @@ class EchoModerationController {
$affectedUserIds = $notificationMapper->fetchUsersWithNotificationsForEvents( $eventIds );
$eventMapper->toggleDeleted( $eventIds, $moderate );
- DeferredUpdates::addCallableUpdate( function () use ( $affectedUserIds ) {
+ $fname = __METHOD__;
+
+ DeferredUpdates::addCallableUpdate( function () use ( $affectedUserIds, $fname ) {
// This update runs after the main transaction round commits.
// Wait for the event deletions to be propagated to replica DBs
$lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
$lbFactory->waitForReplication( [ 'timeout' => 5 ] );
- $lbFactory->flushReplicaSnapshots( 'EchoModerationController::moderate' );
+ $lbFactory->flushReplicaSnapshots( $fname );
// Recompute the notification count for the
// users whose notifications have been moderated.
foreach ( $affectedUserIds as $userId ) {
diff --git a/Echo/includes/controller/NotificationController.php b/Echo/includes/controller/NotificationController.php
index e6c47a2b..b22773d1 100644
--- a/Echo/includes/controller/NotificationController.php
+++ b/Echo/includes/controller/NotificationController.php
@@ -1,4 +1,5 @@
<?php
+
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
use MediaWiki\Revision\RevisionStore;
@@ -13,28 +14,42 @@ class EchoNotificationController {
*
* @var int $maxRecipientCacheSize
*/
- static protected $maxRecipientCacheSize = 200;
+ protected static $maxRecipientCacheSize = 200;
+
+ /**
+ * Max number of users for which we in-process cache titles.
+ *
+ * @var int
+ */
+ protected static $maxUsersTitleCacheSize = 200;
/**
* Echo event agent per user blacklist
*
* @var MapCacheLRU
*/
- static protected $blacklistByUser;
+ protected static $blacklistByUser;
+
+ /**
+ * Echo event agent per page linked event title mute list.
+ *
+ * @var MapCacheLRU
+ */
+ protected static $mutedPageLinkedTitlesCache;
/**
* Echo event agent per wiki blacklist
*
* @var EchoContainmentList|null
*/
- static protected $wikiBlacklist;
+ protected static $wikiBlacklist;
/**
* Echo event agent per user whitelist, this overwrites $blacklistByUser
*
* @var MapCacheLRU
*/
- static protected $whitelistByUser;
+ protected static $whitelistByUser;
/**
* Returns the count passed in, or MWEchoNotifUser::MAX_BADGE_COUNT + 1,
@@ -80,8 +95,7 @@ class EchoNotificationController {
// defer job insertion till end of request when all primary db transactions
// have been committed
DeferredUpdates::addCallableUpdate( function () use ( $event ) {
- // can't use self::, php 5.3 doesn't inherit class scope
- EchoNotificationController::enqueueEvent( $event );
+ self::enqueueEvent( $event );
} );
return;
@@ -188,30 +202,13 @@ class EchoNotificationController {
* this event type
*/
public static function getEventNotifyTypes( $eventType ) {
- global $wgDefaultNotifyTypeAvailability,
- $wgEchoNotifications;
-
$attributeManager = EchoAttributeManager::newFromGlobalVars();
$category = $attributeManager->getNotificationCategory( $eventType );
- // If the category is displayed in preferences, we should go by that, rather
- // than overrides that are inconsistent with what the user saw in preferences.
- $isTypeSpecificConsidered = !$attributeManager->isCategoryDisplayedInPreferences(
- $category
- );
-
- $notifyTypes = $wgDefaultNotifyTypeAvailability;
-
- if ( $isTypeSpecificConsidered && isset( $wgEchoNotifications[$eventType]['notify-type-availability'] ) ) {
- $notifyTypes = array_merge(
- $notifyTypes,
- $wgEchoNotifications[$eventType]['notify-type-availability']
- );
- }
-
- // Category settings for availability are considered in EchoNotifier
- return array_keys( array_filter( $notifyTypes ) );
+ return array_keys( array_filter(
+ $attributeManager->getNotifyTypeAvailabilityForCategory( $category )
+ ) );
}
/**
@@ -240,8 +237,6 @@ class EchoNotificationController {
public static function isBlacklistedByUser( EchoEvent $event, User $user ) {
global $wgEchoAgentBlacklist, $wgEchoPerUserBlacklist;
- $clusterCache = ObjectCache::getLocalClusterInstance();
-
if ( !$event->getAgent() ) {
return false;
}
@@ -252,7 +247,7 @@ class EchoNotificationController {
}
// Ensure we have a blacklist for the user
- if ( !self::$blacklistByUser->has( $user->getId() ) ) {
+ if ( !self::$blacklistByUser->has( (string)$user->getId() ) ) {
$blacklist = new EchoContainmentSet( $user );
// Add the config setting
@@ -270,24 +265,50 @@ class EchoNotificationController {
}
// Add user's blacklist to dictionary if user wasn't already there
- self::$blacklistByUser->set( $user->getId(), $blacklist );
+ self::$blacklistByUser->set( (string)$user->getId(), $blacklist );
} else {
// Just get the user's blacklist if it's already there
- $blacklist = self::$blacklistByUser->get( $user->getId() );
+ $blacklist = self::$blacklistByUser->get( (string)$user->getId() );
+ }
+ return $blacklist->contains( $event->getAgent()->getName() ) ||
+ ( $wgEchoPerUserBlacklist &&
+ $event->getType() === 'page-linked' &&
+ self::isPageLinkedTitleMutedByUser( $event->getTitle(), $user ) );
+ }
+
+ /**
+ * Check if a title is in the user's page-linked event blacklist.
+ *
+ * @param Title $title
+ * @param User $user
+ * @return bool
+ */
+ 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(
+ 'echo-notifications-page-linked-title-muted-list'
+ );
+ self::$mutedPageLinkedTitlesCache->set( (string)$user->getId(), $pageLinkedTitleMutedList );
+ } else {
+ $pageLinkedTitleMutedList = self::$mutedPageLinkedTitlesCache->get( (string)$user->getId() );
}
- return $blacklist->contains( $event->getAgent()->getName() );
+ return $pageLinkedTitleMutedList->contains( (string)$title->getArticleID() );
}
/**
* @return EchoContainmentList|null
*/
protected static function getWikiBlacklist() {
- $clusterCache = ObjectCache::getLocalClusterInstance();
global $wgEchoOnWikiBlacklist;
if ( !$wgEchoOnWikiBlacklist ) {
return null;
}
if ( self::$wikiBlacklist === null ) {
+ $clusterCache = MediaWikiServices::getInstance()->getMainWANObjectCache();
self::$wikiBlacklist = new EchoCachedList(
$clusterCache,
$clusterCache->makeKey( "echo_on_wiki_blacklist" ),
@@ -306,14 +327,14 @@ class EchoNotificationController {
* @return bool True when the event agent is in the user whitelist
*/
public static function isWhitelistedByUser( EchoEvent $event, User $user ) {
- $clusterCache = ObjectCache::getLocalClusterInstance();
+ $clusterCache = MediaWikiServices::getInstance()->getMainWANObjectCache();
global $wgEchoPerUserWhitelistFormat;
if ( $wgEchoPerUserWhitelistFormat === null || !$event->getAgent() ) {
return false;
}
- $userId = $user->getID();
+ $userId = $user->getId();
if ( $userId === 0 ) {
return false; // anonymous user
}
@@ -324,9 +345,9 @@ class EchoNotificationController {
}
// Ensure we have a whitelist for the user
- if ( !self::$whitelistByUser->has( $userId ) ) {
+ if ( !self::$whitelistByUser->has( (string)$userId ) ) {
$whitelist = new EchoContainmentSet( $user );
- self::$whitelistByUser->set( $userId, $whitelist );
+ self::$whitelistByUser->set( (string)$userId, $whitelist );
$whitelist->addOnWiki(
NS_USER,
sprintf( $wgEchoPerUserWhitelistFormat, $user->getName() ),
@@ -335,7 +356,7 @@ class EchoNotificationController {
);
} else {
// Just get the user's whitelist
- $whitelist = self::$whitelistByUser->get( $userId );
+ $whitelist = self::$whitelistByUser->get( (string)$userId );
}
return $whitelist->contains( $event->getAgent()->getName() );
}
@@ -411,6 +432,7 @@ class EchoNotificationController {
// @deprecated
$users = [];
Hooks::run( 'EchoGetDefaultNotifiedUsers', [ $event, &$users ] );
+ // @phan-suppress-next-line PhanImpossibleCondition May be set by hook
if ( $users ) {
$notify->add( $users );
}
@@ -449,9 +471,8 @@ class EchoNotificationController {
return true;
} );
- // Don't notify the person who initiated the event unless the event extra says to do so
- $extra = $event->getExtra();
- if ( ( !isset( $extra['notifyAgent'] ) || !$extra['notifyAgent'] ) && $event->getAgent() ) {
+ // Don't notify the person who initiated the event unless the event allows it
+ if ( !$event->canNotifyAgent() && $event->getAgent() ) {
$agentId = $event->getAgent()->getId();
$notify->addFilter( function ( $user ) use ( $agentId ) {
return $user->getId() != $agentId;
diff --git a/Echo/includes/formatters/EchoEventDigestFormatter.php b/Echo/includes/formatters/EchoEventDigestFormatter.php
index c4a9d436..85c9e927 100644
--- a/Echo/includes/formatters/EchoEventDigestFormatter.php
+++ b/Echo/includes/formatters/EchoEventDigestFormatter.php
@@ -7,6 +7,13 @@
* arguments passed in the constructor (user and language)
*/
abstract class EchoEventDigestFormatter {
+
+ /** @var User */
+ protected $user;
+
+ /** @var Language */
+ protected $language;
+
public function __construct( User $user, Language $language ) {
$this->user = $user;
$this->language = $language;
@@ -16,13 +23,14 @@ abstract class EchoEventDigestFormatter {
* Equivalent to IContextSource::msg for the current
* language
*
+ * @param string ...$args
* @return Message
*/
- protected function msg( /* ,,, */ ) {
+ protected function msg( ...$args ) {
/**
* @var Message $msg
*/
- $msg = wfMessage( ...func_get_args() );
+ $msg = wfMessage( ...$args );
$msg->inLanguage( $this->language );
return $msg;
diff --git a/Echo/includes/formatters/EchoEventFormatter.php b/Echo/includes/formatters/EchoEventFormatter.php
index f66b3ce9..0e81b3c4 100644
--- a/Echo/includes/formatters/EchoEventFormatter.php
+++ b/Echo/includes/formatters/EchoEventFormatter.php
@@ -14,6 +14,13 @@ use MediaWiki\Logger\LoggerFactory;
* arguments passed in the constructor (user and language)
*/
abstract class EchoEventFormatter {
+
+ /** @var User */
+ protected $user;
+
+ /** @var Language */
+ protected $language;
+
public function __construct( User $user, Language $language ) {
$this->user = $user;
$this->language = $language;
@@ -23,13 +30,14 @@ abstract class EchoEventFormatter {
* Equivalent to IContextSource::msg for the current
* language
*
+ * @param string ...$args
* @return Message
*/
- protected function msg( /* ,,, */ ) {
+ protected function msg( ...$args ) {
/**
* @var Message $msg
*/
- $msg = wfMessage( ...func_get_args() );
+ $msg = wfMessage( ...$args );
$msg->inLanguage( $this->language );
return $msg;
diff --git a/Echo/includes/formatters/EchoForeignPresentationModel.php b/Echo/includes/formatters/EchoForeignPresentationModel.php
index 0d040dfc..42e56f81 100644
--- a/Echo/includes/formatters/EchoForeignPresentationModel.php
+++ b/Echo/includes/formatters/EchoForeignPresentationModel.php
@@ -15,7 +15,8 @@ class EchoForeignPresentationModel extends EchoEventPresentationModel {
// notification-header-foreign-alert
// notification-header-foreign-notice
- return "notification-header-{$this->type}-{$section}";
+ // notification-header-foreign-all
+ return "notification-header-foreign-{$section}";
}
public function getHeaderMessage() {
@@ -33,7 +34,7 @@ class EchoForeignPresentationModel extends EchoEventPresentationModel {
public function getBodyMessage() {
$data = $this->event->getExtra();
- $msg = wfMessage( "notification-body-{$this->type}" );
+ $msg = wfMessage( 'notification-body-foreign' );
$msg->params( $this->language->listToText( $this->getWikiNames( $data['wikis'] ) ) );
return $msg;
}
diff --git a/Echo/includes/formatters/EchoHtmlDigestEmailFormatter.php b/Echo/includes/formatters/EchoHtmlDigestEmailFormatter.php
index 77ffe9c8..fe59aaa3 100644
--- a/Echo/includes/formatters/EchoHtmlDigestEmailFormatter.php
+++ b/Echo/includes/formatters/EchoHtmlDigestEmailFormatter.php
@@ -146,7 +146,7 @@ EOF;
* @param EchoEventPresentationModel[] $models
* @return array [ 'category name' => EchoEventPresentationModel[] ]
*/
- private function groupByCategory( $models ) {
+ private function groupByCategory( array $models ) {
$eventsByCategory = [];
foreach ( $models as $model ) {
$eventsByCategory[$model->getCategory()][] = $model;
diff --git a/Echo/includes/formatters/EchoIcon.php b/Echo/includes/formatters/EchoIcon.php
index 5d1b186d..b7ccbde4 100644
--- a/Echo/includes/formatters/EchoIcon.php
+++ b/Echo/includes/formatters/EchoIcon.php
@@ -59,9 +59,7 @@ class EchoIcon {
throw new InvalidArgumentException( "The $icon icon is not registered" );
}
- $url = isset( $wgEchoNotificationIcons[ $icon ][ 'url' ] ) ?
- $wgEchoNotificationIcons[ $icon ][ 'url' ] :
- null;
+ $url = $wgEchoNotificationIcons[ $icon ][ 'url' ] ?? null;
// If the defined URL is explicitly false, use placeholder
if ( $url === false ) {
diff --git a/Echo/includes/formatters/EchoModelFormatter.php b/Echo/includes/formatters/EchoModelFormatter.php
index 4d4050b4..cff2abe5 100644
--- a/Echo/includes/formatters/EchoModelFormatter.php
+++ b/Echo/includes/formatters/EchoModelFormatter.php
@@ -8,6 +8,7 @@ class EchoModelFormatter extends EchoEventFormatter {
/**
* @param EchoEventPresentationModel $model
* @return array
+ * @suppress SecurityCheck-DoubleEscaped
*/
protected function formatModel( EchoEventPresentationModel $model ) {
$data = $model->jsonSerialize();
@@ -17,9 +18,12 @@ class EchoModelFormatter extends EchoEventFormatter {
$data['links']['primary']['url'] = wfExpandUrl( $data['links']['primary']['url'] );
}
+ // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
foreach ( $data['links']['secondary'] as &$link ) {
+ // @phan-suppress-next-line PhanTypeMismatchDimAssignment
$link['url'] = wfExpandUrl( $link['url'] );
}
+ unset( $link );
$bundledIds = $model->getBundledIds();
if ( $bundledIds ) {
diff --git a/Echo/includes/formatters/EchoPlainTextEmailFormatter.php b/Echo/includes/formatters/EchoPlainTextEmailFormatter.php
index 34fdc7d1..33825136 100644
--- a/Echo/includes/formatters/EchoPlainTextEmailFormatter.php
+++ b/Echo/includes/formatters/EchoPlainTextEmailFormatter.php
@@ -44,7 +44,7 @@ class EchoPlainTextEmailFormatter extends EchoEventFormatter {
->getFullURL( '', false, PROTO_CANONICAL );
$text = "--\n\n$footerMsg\n$prefsUrl";
- if ( strlen( $wgEchoEmailFooterAddress ) ) {
+ if ( $wgEchoEmailFooterAddress !== '' ) {
$text .= "\n\n$wgEchoEmailFooterAddress";
}
diff --git a/Echo/includes/formatters/EditUserTalkPresentationModel.php b/Echo/includes/formatters/EditUserTalkPresentationModel.php
index b950bd46..3e6974d1 100644
--- a/Echo/includes/formatters/EditUserTalkPresentationModel.php
+++ b/Echo/includes/formatters/EditUserTalkPresentationModel.php
@@ -1,7 +1,19 @@
<?php
class EchoEditUserTalkPresentationModel extends EchoEventPresentationModel {
- use EchoPresentationModelSectionTrait;
+
+ /**
+ * @var EchoPresentationModelSection
+ */
+ private $section;
+
+ /**
+ * @inheritDoc
+ */
+ protected function __construct( EchoEvent $event, Language $language, User $user, $distributionType ) {
+ parent::__construct( $event, $language, $user, $distributionType );
+ $this->section = new EchoPresentationModelSection( $event, $user, $language );
+ }
public function canRender() {
return (bool)$this->event->getTitle();
@@ -14,7 +26,7 @@ class EchoEditUserTalkPresentationModel extends EchoEventPresentationModel {
public function getPrimaryLink() {
return [
// Need FullURL so the section is included
- 'url' => $this->getTitleWithSection()->getFullURL(),
+ 'url' => $this->section->getTitleWithSection()->getFullURL(),
'label' => $this->msg( 'notification-link-text-view-message' )->text()
];
}
@@ -37,17 +49,17 @@ class EchoEditUserTalkPresentationModel extends EchoEventPresentationModel {
public function getHeaderMessage() {
if ( $this->isBundled() ) {
- $msg = $this->msg( "notification-bundle-header-{$this->type}-v2" );
+ $msg = $this->msg( 'notification-bundle-header-edit-user-talk-v2' );
$count = $this->getNotificationCountForOutput();
// Repeat is B/C until unused parameter is removed from translations
$msg->numParams( $count, $count );
$msg->params( $this->getViewingUserForGender() );
return $msg;
- } elseif ( $this->hasSection() ) {
- $msg = $this->getMessageWithAgent( "notification-header-{$this->type}-with-section" );
+ } elseif ( $this->section->exists() ) {
+ $msg = $this->getMessageWithAgent( 'notification-header-edit-user-talk-with-section' );
$msg->params( $this->getViewingUserForGender() );
- $msg->plaintextParams( $this->getTruncatedSectionTitle() );
+ $msg->plaintextParams( $this->section->getTruncatedSectionTitle() );
return $msg;
} else {
$msg = parent::getHeaderMessage();
@@ -57,23 +69,23 @@ class EchoEditUserTalkPresentationModel extends EchoEventPresentationModel {
}
public function getCompactHeaderMessage() {
- $hasSection = $this->hasSection();
+ $hasSection = $this->section->exists();
$key = $hasSection
- ? "notification-compact-header-{$this->type}-with-section"
- : "notification-compact-header-{$this->type}";
+ ? 'notification-compact-header-edit-user-talk-with-section'
+ : 'notification-compact-header-edit-user-talk';
$msg = $this->getMessageWithAgent( $key );
$msg->params( $this->getViewingUserForGender() );
if ( $hasSection ) {
- $msg->params( $this->getTruncatedSectionTitle() );
+ $msg->params( $this->section->getTruncatedSectionTitle() );
}
return $msg;
}
public function getBodyMessage() {
$sectionText = $this->event->getExtraParam( 'section-text' );
- if ( !$this->isBundled() && $this->hasSection() && $sectionText !== null ) {
+ if ( !$this->isBundled() && $this->section->exists() && is_string( $sectionText ) ) {
$msg = $this->msg( 'notification-body-edit-user-talk-with-section' );
- // section-text is safe to use here, because hasSection() returns false if the revision is deleted
+ // section-text is safe to use here, because section->exists() returns false if the revision is deleted
$msg->plaintextParams( $sectionText );
return $msg;
} else {
diff --git a/Echo/includes/formatters/EventPresentationModel.php b/Echo/includes/formatters/EventPresentationModel.php
index 02c6e6e8..59a9bfef 100644
--- a/Echo/includes/formatters/EventPresentationModel.php
+++ b/Echo/includes/formatters/EventPresentationModel.php
@@ -1,5 +1,6 @@
<?php
+use MediaWiki\Revision\RevisionRecord;
use Wikimedia\Timestamp\TimestampException;
/**
@@ -143,16 +144,26 @@ abstract class EchoEventPresentationModel implements JsonSerializable {
}
/**
+ * Get the distribution type
+ *
+ * @return string 'web' or 'email'
+ */
+ final public function getDistributionType() {
+ return $this->distributionType;
+ }
+
+ /**
* Equivalent to IContextSource::msg for the current
* language
*
+ * @param string ...$args
* @return Message
*/
- protected function msg( /* ,,, */ ) {
+ protected function msg( ...$args ) {
/**
* @var Message $msg
*/
- $msg = wfMessage( ...func_get_args() );
+ $msg = wfMessage( ...$args );
$msg->inLanguage( $this->language );
// Notifications are considered UI (and should be in UI language, not
@@ -257,7 +268,7 @@ abstract class EchoEventPresentationModel implements JsonSerializable {
/**
* Helper for EchoEvent::userCan
*
- * @param int $type Revision::DELETED_* constant
+ * @param int $type RevisionRecord::DELETED_* constant
* @return bool
*/
final protected function userCan( $type ) {
@@ -282,7 +293,7 @@ abstract class EchoEventPresentationModel implements JsonSerializable {
return false;
}
- if ( $this->userCan( Revision::DELETED_USER ) ) {
+ if ( $this->userCan( RevisionRecord::DELETED_USER ) ) {
// Not deleted
return [
$this->getTruncatedUsername( $agent ),
@@ -409,6 +420,7 @@ abstract class EchoEventPresentationModel implements JsonSerializable {
*/
public function getSubjectMessage() {
$msg = $this->getMessageWithAgent( $this->getSubjectMessageKey() );
+ $msg->params( $this->getViewingUserForGender() );
if ( $msg->isDisabled() ) {
// Back-compat for models that haven't been updated yet
$msg = $this->getHeaderMessage();
@@ -467,7 +479,7 @@ abstract class EchoEventPresentationModel implements JsonSerializable {
* Array of secondary link details, including possibly-relative URLs, label,
* description & icon name.
*
- * @return array Array of links in the format of:
+ * @return (null|array)[] Array of links in the format of:
* [['url' => (string) url,
* 'label' => (string) link text (non-escaped),
* 'description' => (string) descriptive text (optional, non-escaped),
@@ -564,7 +576,7 @@ abstract class EchoEventPresentationModel implements JsonSerializable {
return null;
}
- if ( !$this->userCan( Revision::DELETED_USER ) ) {
+ if ( !$this->userCan( RevisionRecord::DELETED_USER ) ) {
return null;
}
@@ -620,7 +632,7 @@ abstract class EchoEventPresentationModel implements JsonSerializable {
* Get a dynamic action link
*
* @param Title $title Title relating to this action
- * @param bool $icon Optional. Symbolic name of the OOUI icon to use
+ * @param string|false $icon Optional. Symbolic name of the OOUI icon to use
* @param string $label link text (non-escaped)
* @param string|null $description descriptive text (optional, non-escaped)
* @param array $data Action data
@@ -714,7 +726,7 @@ abstract class EchoEventPresentationModel implements JsonSerializable {
$this->getTruncatedTitleText( $title ),
$title->getFullURL( [ 'action' => $availableAction ] ),
$this->getUser()->getName()
- ),
+ )->escaped(),
null,
$data,
[ 'action' => $availableAction ]
diff --git a/Echo/includes/formatters/MentionInSummaryPresentationModel.php b/Echo/includes/formatters/MentionInSummaryPresentationModel.php
index bda17118..74f17603 100644
--- a/Echo/includes/formatters/MentionInSummaryPresentationModel.php
+++ b/Echo/includes/formatters/MentionInSummaryPresentationModel.php
@@ -1,5 +1,7 @@
<?php
+use MediaWiki\Revision\RevisionRecord;
+
class EchoMentionInSummaryPresentationModel extends EchoEventPresentationModel {
public function getIconType() {
@@ -13,15 +15,15 @@ class EchoMentionInSummaryPresentationModel extends EchoEventPresentationModel {
public function getHeaderMessage() {
$msg = $this->getMessageWithAgent( 'notification-header-mention-summary' );
$msg->params( $this->getViewingUserForGender() );
- $msg->params( $this->getTruncatedTitleText( $this->event->getTitle() ) );
+ $msg->params( $this->getTruncatedTitleText( $this->event->getTitle(), true ) );
return $msg;
}
public function getBodyMessage() {
- if ( $this->userCan( Revision::DELETED_COMMENT ) ) {
- $revision = $this->event->getRevision();
- $summary = $revision->getComment();
+ $revision = $this->event->getRevision();
+ if ( $revision && $revision->getComment() && $this->userCan( RevisionRecord::DELETED_COMMENT ) ) {
+ $summary = $revision->getComment()->text;
$summary = Linker::formatComment( $summary );
$summary = Sanitizer::stripAllTags( $summary );
diff --git a/Echo/includes/formatters/MentionPresentationModel.php b/Echo/includes/formatters/MentionPresentationModel.php
index 69e3cc23..c407b350 100644
--- a/Echo/includes/formatters/MentionPresentationModel.php
+++ b/Echo/includes/formatters/MentionPresentationModel.php
@@ -1,7 +1,21 @@
<?php
+use MediaWiki\Revision\RevisionRecord;
+
class EchoMentionPresentationModel extends EchoEventPresentationModel {
- use EchoPresentationModelSectionTrait;
+
+ /**
+ * @var EchoPresentationModelSection
+ */
+ private $section;
+
+ /**
+ * @inheritDoc
+ */
+ protected function __construct( EchoEvent $event, Language $language, User $user, $distributionType ) {
+ parent::__construct( $event, $language, $user, $distributionType );
+ $this->section = new EchoPresentationModelSection( $event, $user, $language );
+ }
public function getIconType() {
return 'mention';
@@ -12,20 +26,21 @@ class EchoMentionPresentationModel extends EchoEventPresentationModel {
}
protected function getHeaderMessageKey() {
+ $hasSection = $this->section->exists();
if ( $this->onArticleTalkpage() ) {
- return $this->hasSection() ?
+ return $hasSection ?
'notification-header-mention-article-talkpage' :
'notification-header-mention-article-talkpage-nosection';
} elseif ( $this->onAgentTalkpage() ) {
- return $this->hasSection() ?
+ return $hasSection ?
'notification-header-mention-agent-talkpage' :
'notification-header-mention-agent-talkpage-nosection';
} elseif ( $this->onUserTalkpage() ) {
- return $this->hasSection() ?
+ return $hasSection ?
'notification-header-mention-user-talkpage-v2' :
'notification-header-mention-user-talkpage-nosection';
} else {
- return $this->hasSection() ?
+ return $hasSection ?
'notification-header-mention-other' :
'notification-header-mention-other-nosection';
}
@@ -49,8 +64,8 @@ class EchoMentionPresentationModel extends EchoEventPresentationModel {
$msg->params( $this->getTruncatedTitleText( $this->event->getTitle(), true ) );
}
- if ( $this->hasSection() ) {
- $msg->plaintextParams( $this->getTruncatedSectionTitle() );
+ if ( $this->section->exists() ) {
+ $msg->plaintextParams( $this->section->getTruncatedSectionTitle() );
}
return $msg;
@@ -58,7 +73,7 @@ class EchoMentionPresentationModel extends EchoEventPresentationModel {
public function getBodyMessage() {
$content = $this->event->getExtraParam( 'content' );
- if ( $content && $this->userCan( Revision::DELETED_TEXT ) ) {
+ if ( $content && $this->userCan( RevisionRecord::DELETED_TEXT ) ) {
$msg = $this->msg( 'notification-body-mention' );
$msg->plaintextParams(
EchoDiscussionParser::getTextSnippet(
@@ -77,7 +92,7 @@ class EchoMentionPresentationModel extends EchoEventPresentationModel {
public function getPrimaryLink() {
return [
// Need FullURL so the section is included
- 'url' => $this->getTitleWithSection()->getFullURL(),
+ 'url' => $this->section->getTitleWithSection()->getFullURL(),
'label' => $this->msg( 'notification-link-text-view-mention' )->text()
];
}
@@ -109,18 +124,8 @@ class EchoMentionPresentationModel extends EchoEventPresentationModel {
}
private function onUserTalkpage() {
- return $this->event->getTitle()->getNamespace() === NS_USER_TALK &&
- $this->event->getTitle()->isTalkPage() &&
- !$this->event->getTitle()->isSubpage();
- }
-
- private function isTalk() {
- return $this->event->getTitle()->isTalkPage();
- }
-
- private function isArticle() {
- $ns = $this->event->getTitle()->getNamespace();
- return $ns === NS_MAIN || $ns === NS_TALK;
+ $title = $this->event->getTitle();
+ return $title->getNamespace() === NS_USER_TALK && !$title->isSubpage();
}
protected function getSubjectMessageKey() {
diff --git a/Echo/includes/formatters/MentionStatusPresentationModel.php b/Echo/includes/formatters/MentionStatusPresentationModel.php
index 974c23d2..083d2c84 100644
--- a/Echo/includes/formatters/MentionStatusPresentationModel.php
+++ b/Echo/includes/formatters/MentionStatusPresentationModel.php
@@ -8,7 +8,19 @@
* @license MIT
*/
class EchoMentionStatusPresentationModel extends EchoEventPresentationModel {
- use EchoPresentationModelSectionTrait;
+
+ /**
+ * @var EchoPresentationModelSection
+ */
+ private $section;
+
+ /**
+ * @inheritDoc
+ */
+ protected function __construct( EchoEvent $event, Language $language, User $user, $distributionType ) {
+ parent::__construct( $event, $language, $user, $distributionType );
+ $this->section = new EchoPresentationModelSection( $event, $user, $language );
+ }
public function getIconType() {
if ( $this->isMixedBundle() ) {
@@ -82,7 +94,7 @@ class EchoMentionStatusPresentationModel extends EchoEventPresentationModel {
public function getPrimaryLink() {
return [
// Need FullURL so the section is included
- 'url' => $this->getTitleWithSection()->getFullURL(),
+ 'url' => $this->section->getTitleWithSection()->getFullURL(),
'label' => $this->msg( 'notification-link-text-view-mention-failure' )
->numParams( $this->getBundleCount() )
->text()
@@ -95,7 +107,7 @@ class EchoMentionStatusPresentationModel extends EchoEventPresentationModel {
}
$talkPageLink = $this->getPageLink(
- $this->getTitleWithSection(),
+ $this->section->getTitleWithSection(),
'',
true
);
diff --git a/Echo/includes/formatters/PageLinkedPresentationModel.php b/Echo/includes/formatters/PageLinkedPresentationModel.php
index 3941484b..3e34c6d2 100644
--- a/Echo/includes/formatters/PageLinkedPresentationModel.php
+++ b/Echo/includes/formatters/PageLinkedPresentationModel.php
@@ -1,5 +1,7 @@
<?php
+use MediaWiki\MediaWikiServices;
+
class EchoPageLinkedPresentationModel extends EchoEventPresentationModel {
private $pageFrom;
@@ -34,7 +36,8 @@ class EchoPageLinkedPresentationModel extends EchoEventPresentationModel {
public function getSecondaryLinks() {
$whatLinksHereLink = [
- 'url' => SpecialPage::getTitleFor( 'Whatlinkshere', $this->event->getTitle()->getPrefixedText() )->getFullURL(),
+ 'url' => SpecialPage::getTitleFor( 'Whatlinkshere', $this->event->getTitle()->getPrefixedText() )
+ ->getFullURL(),
'label' => $this->msg( 'notification-link-text-what-links-here' )->text(),
'description' => '',
'icon' => 'linked',
@@ -46,21 +49,75 @@ class EchoPageLinkedPresentationModel extends EchoEventPresentationModel {
if ( $revid !== null ) {
$diffLink = [
'url' => $this->getPageFrom()->getFullURL( [ 'diff' => $revid, 'oldid' => 'prev' ] ),
- 'label' => $this->msg( 'notification-link-text-view-changes', $this->getViewingUserForGender() )->text(),
+ 'label' => $this->msg( 'notification-link-text-view-changes', $this->getViewingUserForGender() )
+ ->text(),
'description' => '',
'icon' => 'changes',
'prioritized' => true
];
}
- 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()
+ )->text(),
+ null,
+ $data,
+ []
+ );
}
protected function getHeaderMessageKey() {
if ( $this->getBundleCount( true, [ $this, 'getLinkedPageId' ] ) > 1 ) {
- return "notification-bundle-header-{$this->type}";
+ return 'notification-bundle-header-page-linked';
}
- return "notification-header-{$this->type}";
+ return 'notification-header-page-linked';
}
public function getHeaderMessage() {
@@ -95,7 +152,7 @@ class EchoPageLinkedPresentationModel extends EchoEventPresentationModel {
if ( isset( $extra['link-from-namespace'] ) && isset( $extra['link-from-title'] ) ) {
$title = Title::makeTitleSafe( $extra['link-from-namespace'], $extra['link-from-title'] );
if ( $title ) {
- return $title->getArticleId();
+ return $title->getArticleID();
}
}
return 0;
@@ -103,7 +160,7 @@ class EchoPageLinkedPresentationModel extends EchoEventPresentationModel {
private function getPageFrom() {
if ( !$this->pageFrom ) {
- $this->pageFrom = Title::newFromId( $this->getLinkedPageId( $this->event ) );
+ $this->pageFrom = Title::newFromID( $this->getLinkedPageId( $this->event ) );
}
return $this->pageFrom;
}
diff --git a/Echo/includes/formatters/PresentationModelSectionTrait.php b/Echo/includes/formatters/PresentationModelSection.php
index 00f9e318..0a93bc53 100644
--- a/Echo/includes/formatters/PresentationModelSectionTrait.php
+++ b/Echo/includes/formatters/PresentationModelSection.php
@@ -1,16 +1,53 @@
<?php
+
+use MediaWiki\Revision\RevisionRecord;
+
/**
- * Trait that adds section title handling to an EchoEventPresentationModel subclass.
+ * Component that represents a section of a page to be used from EchoEventPresentationModel subclass.
*/
-trait EchoPresentationModelSectionTrait {
+class EchoPresentationModelSection {
+
+ /**
+ * @var string|false|null
+ */
private $rawSectionTitle = null;
+
+ /**
+ * @var string|false|null
+ */
private $parsedSectionTitle = null;
/**
+ * @var EchoEvent
+ */
+ private $event;
+
+ /**
+ * @var User
+ */
+ private $user;
+
+ /**
+ * @var Language
+ */
+ private $language;
+
+ /**
+ * @param EchoEvent $event
+ * @param User $user
+ * @param Language $language
+ */
+ public function __construct( EchoEvent $event, User $user, Language $language ) {
+ $this->event = $event;
+ $this->user = $user;
+ $this->language = $language;
+ }
+
+ /**
* Get the raw (unparsed) section title
- * @return string Section title
+ * @return string|false Section title
*/
- protected function getRawSectionTitle() {
+ private function getRawSectionTitle() {
if ( $this->rawSectionTitle !== null ) {
return $this->rawSectionTitle;
}
@@ -20,7 +57,7 @@ trait EchoPresentationModelSectionTrait {
return false;
}
// Check permissions
- if ( !$this->userCan( Revision::DELETED_TEXT ) ) {
+ if ( !$this->event->userCan( RevisionRecord::DELETED_TEXT, $this->user ) ) {
$this->rawSectionTitle = false;
return false;
}
@@ -31,9 +68,9 @@ trait EchoPresentationModelSectionTrait {
/**
* Get the section title parsed to plain text
- * @return string Section title (plain text)
+ * @return string|false Section title (plain text)
*/
- protected function getParsedSectionTitle() {
+ private function getParsedSectionTitle() {
if ( $this->parsedSectionTitle !== null ) {
return $this->parsedSectionTitle;
}
@@ -59,7 +96,7 @@ trait EchoPresentationModelSectionTrait {
* be viewed in that case.
* @return bool Whether there is a section
*/
- protected function hasSection() {
+ public function exists() {
return (bool)$this->getRawSectionTitle();
}
@@ -67,24 +104,28 @@ trait EchoPresentationModelSectionTrait {
* Get a Title pointing to the section, if available.
* @return Title
*/
- protected function getTitleWithSection() {
+ public function getTitleWithSection() {
$title = $this->event->getTitle();
+ if ( $title === null ) {
+ throw new MWException( 'Event #' . $this->event->getId() . ' with no title' );
+ }
$section = $this->getParsedSectionTitle();
- $fragment = substr( Parser::guessSectionNameFromStrippedText( $section ), 1 );
if ( $section ) {
- $title = Title::makeTitle(
- $title->getNamespace(),
- $title->getDBkey(),
- $fragment
- );
+ $fragment = substr( Parser::guessSectionNameFromStrippedText( $section ), 1 );
+ $title = $title->createFragmentTarget( $fragment );
}
return $title;
}
- protected function getTruncatedSectionTitle() {
+ /**
+ * Get truncated section title, according to user's language.
+ * You should only call this if EchoPresentationModelSection::exists returns true.
+ * @return string
+ */
+ public function getTruncatedSectionTitle() {
return $this->language->embedBidi( $this->language->truncateForVisual(
$this->getParsedSectionTitle(),
- self::SECTION_TITLE_RECOMMENDED_LENGTH,
+ EchoEventPresentationModel::SECTION_TITLE_RECOMMENDED_LENGTH,
'...',
false
) );
diff --git a/Echo/includes/formatters/RevertedPresentationModel.php b/Echo/includes/formatters/RevertedPresentationModel.php
index 04d63b93..46ce1db0 100644
--- a/Echo/includes/formatters/RevertedPresentationModel.php
+++ b/Echo/includes/formatters/RevertedPresentationModel.php
@@ -1,5 +1,7 @@
<?php
+use MediaWiki\Revision\RevisionRecord;
+
class EchoRevertedPresentationModel extends EchoEventPresentationModel {
public function getIconType() {
@@ -19,8 +21,11 @@ class EchoRevertedPresentationModel extends EchoEventPresentationModel {
public function getBodyMessage() {
$summary = $this->event->getExtraParam( 'summary' );
- if ( !$this->isAutomaticSummary( $summary ) && $this->userCan( Revision::DELETED_COMMENT ) ) {
- $msg = $this->msg( "notification-body-{$this->type}" );
+ if (
+ !$this->isAutomaticSummary( $summary ) &&
+ $this->userCan( RevisionRecord::DELETED_COMMENT )
+ ) {
+ $msg = $this->msg( 'notification-body-reverted' );
$msg->plaintextParams( $this->formatSummary( $summary ) );
return $msg;
} else {
@@ -49,7 +54,7 @@ class EchoRevertedPresentationModel extends EchoEventPresentationModel {
$title = $this->event->getTitle();
if ( $title->canHaveTalkPage() ) {
$links[] = $this->getPageLink(
- $title->getTalkPage(), null, true
+ $title->getTalkPage(), '', true
);
}
@@ -82,4 +87,8 @@ class EchoRevertedPresentationModel extends EchoEventPresentationModel {
protected function getSubjectMessageKey() {
return 'notification-reverted-email-subject2';
}
+
+ public function getSubjectMessage() {
+ return parent::getSubjectMessage()->params( $this->getNumberOfEdits() );
+ }
}
diff --git a/Echo/includes/formatters/SpecialNotificationsFormatter.php b/Echo/includes/formatters/SpecialNotificationsFormatter.php
index ca57ba90..c5b4caf0 100644
--- a/Echo/includes/formatters/SpecialNotificationsFormatter.php
+++ b/Echo/includes/formatters/SpecialNotificationsFormatter.php
@@ -90,7 +90,10 @@ class SpecialNotificationsFormatter extends EchoEventFormatter {
$html .= Xml::tags(
'div',
[ 'class' => 'mw-echo-notification-footer' ],
- implode( Html::element( 'span', [ 'class' => 'mw-echo-notification-footer-element' ], $pipe ), $footerItems )
+ implode(
+ Html::element( 'span', [ 'class' => 'mw-echo-notification-footer-element' ], $pipe ),
+ $footerItems
+ )
) . "\n";
// Wrap everything in mw-echo-content class
diff --git a/Echo/includes/formatters/UserRightsPresentationModel.php b/Echo/includes/formatters/UserRightsPresentationModel.php
index ba29a3e4..62213e67 100644
--- a/Echo/includes/formatters/UserRightsPresentationModel.php
+++ b/Echo/includes/formatters/UserRightsPresentationModel.php
@@ -65,7 +65,7 @@ class EchoUserRightsPresentationModel extends EchoEventPresentationModel {
return false;
}
- private function getLocalizedGroupNames( $names ) {
+ private function getLocalizedGroupNames( array $names ) {
return array_map( function ( $name ) {
$msg = $this->msg( 'group-' . $name );
return $msg->isBlank() ? $name : $msg->text();
diff --git a/Echo/includes/formatters/WatchlistChangePresentationModel.php b/Echo/includes/formatters/WatchlistChangePresentationModel.php
new file mode 100644
index 00000000..cf09e187
--- /dev/null
+++ b/Echo/includes/formatters/WatchlistChangePresentationModel.php
@@ -0,0 +1,103 @@
+<?php
+
+class EchoWatchlistChangePresentationModel extends EchoEventPresentationModel {
+
+ public function getIconType() {
+ // @todo create an icon to use here
+ return 'placeholder';
+ }
+
+ public function getHeaderMessage() {
+ if ( $this->isMultiTypeBundle() ) {
+ $status = "changed";
+ } else {
+ $status = $this->event->getExtraParam( 'status' );
+ }
+ if ( $this->isMultiUserBundle() ) {
+ // Messages: notification-header-watchlist-multiuser-changed,
+ // notification-header-watchlist-multiuser-created
+ // notification-header-watchlist-multiuser-deleted
+ // notification-header-watchlist-multiuser-moved
+ // notification-header-watchlist-multiuser-restored
+ $msg = $this->msg( "notification-header-watchlist-multiuser-" . $status );
+ } else {
+ // Messages: notification-header-watchlist-changed,
+ // notification-header-watchlist-created
+ // notification-header-watchlist-deleted
+ // notification-header-watchlist-moved
+ // notification-header-watchlist-restored
+ $msg = $this->getMessageWithAgent( "notification-header-watchlist-" . $status );
+ }
+ $msg->params( $this->getTruncatedTitleText( $this->event->getTitle() ) );
+ $msg->params( $this->getViewingUserForGender() );
+ $msg->numParams( $this->getBundleCount() );
+ return $msg;
+ }
+
+ public function getPrimaryLink() {
+ if ( $this->isBundled() ) {
+ return [
+ 'url' => $this->event->getTitle()->getLocalUrl(),
+ 'label' => $this->msg( 'notification-link-text-view-page' )->text()
+ ];
+ }
+ return [
+ 'url' => $this->getViewChangesUrl(),
+ 'label' => $this->msg( 'notification-link-text-view-changes', $this->getViewingUserForGender() )
+ ->text(),
+ ];
+ }
+
+ public function getSecondaryLinks() {
+ if ( $this->isBundled() ) {
+ if ( $this->isMultiUserBundle() ) {
+ return [];
+ } else {
+ return [ $this->getAgentLink() ];
+ }
+ } else {
+ $viewChangesLink = [
+ 'url' => $this->getViewChangesUrl(),
+ 'label' => $this->msg( 'notification-link-text-view-changes', $this->getViewingUserForGender() )
+ ->text(),
+ 'description' => '',
+ 'icon' => 'changes',
+ 'prioritized' => true,
+ ];
+ return [ $this->getAgentLink(), $viewChangesLink ];
+ }
+ }
+
+ private function isMultiUserBundle() {
+ foreach ( $this->getBundledEvents() as $bundled ) {
+ if ( !$bundled->getAgent()->equals( $this->event->getAgent() ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private function isMultiTypeBundle() {
+ foreach ( $this->getBundledEvents() as $bundled ) {
+ if ( $bundled->getExtraParam( 'status' ) !== $this->event->getExtraParam( 'status' ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private function getViewChangesUrl() {
+ $revid = $this->event->getExtraParam( 'revid' );
+ if ( $revid === 0 ) {
+ $url = SpecialPage::getTitleFor( 'Log' )->getLocalUrl( [
+ 'logid' => $this->event->getExtraParam( 'logid' )
+ ] );
+ } else {
+ $url = $this->event->getTitle()->getLocalURL( [
+ 'oldid' => 'prev',
+ 'diff' => $revid
+ ] );
+ }
+ return $url;
+ }
+}
diff --git a/Echo/includes/gateway/UserNotificationGateway.php b/Echo/includes/gateway/UserNotificationGateway.php
index 7ee23704..d8087715 100644
--- a/Echo/includes/gateway/UserNotificationGateway.php
+++ b/Echo/includes/gateway/UserNotificationGateway.php
@@ -30,14 +30,20 @@ class EchoUserNotificationGateway {
* @var string
*/
protected static $notificationTable = 'echo_notification';
+ /**
+ * @var Config
+ */
+ private $config;
/**
* @param User $user
* @param MWEchoDbFactory $dbFactory
+ * @param Config $config
*/
- public function __construct( User $user, MWEchoDbFactory $dbFactory ) {
+ public function __construct( User $user, MWEchoDbFactory $dbFactory, Config $config ) {
$this->user = $user;
$this->dbFactory = $dbFactory;
+ $this->config = $config;
}
public function getDB( $dbSource ) {
@@ -51,7 +57,6 @@ class EchoUserNotificationGateway {
* failure, or when there was nothing to update
*/
public function markRead( array $eventIDs ) {
- global $wgUpdateRowsPerQuery;
if ( !$eventIDs ) {
return false;
}
@@ -62,7 +67,9 @@ class EchoUserNotificationGateway {
}
$success = true;
- foreach ( array_chunk( $eventIDs, $wgUpdateRowsPerQuery ) as $batch ) {
+ foreach (
+ array_chunk( $eventIDs, $this->config->get( 'UpdateRowsPerQuery' ) ) as $batch
+ ) {
$success = $dbw->update(
self::$notificationTable,
[ 'notification_read_timestamp' => $dbw->timestamp( wfTimestampNow() ) ],
@@ -85,7 +92,6 @@ class EchoUserNotificationGateway {
* failure, or when there was nothing to update
*/
public function markUnRead( array $eventIDs ) {
- global $wgUpdateRowsPerQuery;
if ( !$eventIDs ) {
return false;
}
@@ -96,7 +102,9 @@ class EchoUserNotificationGateway {
}
$success = true;
- foreach ( array_chunk( $eventIDs, $wgUpdateRowsPerQuery ) as $batch ) {
+ foreach (
+ array_chunk( $eventIDs, $this->config->get( 'UpdateRowsPerQuery' ) ) as $batch
+ ) {
$success = $dbw->update(
self::$notificationTable,
[ 'notification_read_timestamp' => null ],
@@ -122,7 +130,7 @@ class EchoUserNotificationGateway {
return false;
}
- return $dbw->update(
+ $dbw->update(
self::$notificationTable,
[ 'notification_read_timestamp' => $dbw->timestamp( wfTimestampNow() ) ],
[
@@ -131,16 +139,22 @@ class EchoUserNotificationGateway {
],
__METHOD__
);
+
+ return true;
}
/**
* Get notification count for the types specified
- * @param int $dbSource use master or slave storage to pull count
+ * @param int $dbSource use master or replica storage to pull count
* @param array $eventTypesToLoad event types to retrieve
* @param int $cap Max count
* @return int
*/
- public function getCappedNotificationCount( $dbSource, array $eventTypesToLoad = [], $cap = MWEchoNotifUser::MAX_BADGE_COUNT ) {
+ public function getCappedNotificationCount(
+ $dbSource,
+ array $eventTypesToLoad = [],
+ $cap = MWEchoNotifUser::MAX_BADGE_COUNT
+ ) {
// double check
if ( !in_array( $dbSource, [ DB_REPLICA, DB_MASTER ] ) ) {
$dbSource = DB_REPLICA;
@@ -156,7 +170,7 @@ class EchoUserNotificationGateway {
self::$notificationTable,
self::$eventTable
],
- [ '1' ],
+ '1',
[
'notification_user' => $this->user->getId(),
'notification_read_timestamp' => null,
diff --git a/Echo/includes/iterator/NotRecursiveIterator.php b/Echo/includes/iterator/NotRecursiveIterator.php
index 2bc002bf..39b67ae1 100644
--- a/Echo/includes/iterator/NotRecursiveIterator.php
+++ b/Echo/includes/iterator/NotRecursiveIterator.php
@@ -13,6 +13,7 @@ class EchoNotRecursiveIterator extends EchoIteratorDecorator implements Recursiv
}
public function getChildren() {
+ // @phan-suppress-next-line PhanTypeMismatchReturn Never called
return null;
}
}
diff --git a/Echo/includes/jobs/NotificationDeleteJob.php b/Echo/includes/jobs/NotificationDeleteJob.php
index 5efd410f..d1ec0c0a 100644
--- a/Echo/includes/jobs/NotificationDeleteJob.php
+++ b/Echo/includes/jobs/NotificationDeleteJob.php
@@ -14,18 +14,11 @@
class EchoNotificationDeleteJob extends Job {
/**
- * UserIds to be processed
- * @var int[]
- */
- protected $userIds = [];
-
- /**
* @param Title $title
* @param array $params
*/
- public function __construct( $title, $params ) {
+ public function __construct( Title $title, array $params ) {
parent::__construct( __CLASS__, $title, $params );
- $this->userIds = $params['userIds'];
}
/**
@@ -34,10 +27,10 @@ class EchoNotificationDeleteJob extends Job {
*/
public function run() {
global $wgEchoMaxUpdateCount;
- if ( count( $this->userIds ) > 1 ) {
+ if ( count( $this->params['userIds'] ) > 1 ) {
// If there are multiple users, queue a single job for each one
$jobs = [];
- foreach ( $this->userIds as $userId ) {
+ foreach ( $this->params['userIds'] as $userId ) {
$jobs[] = new EchoNotificationDeleteJob( $this->title, [ 'userIds' => [ $userId ] ] );
}
JobQueueGroup::singleton()->push( $jobs );
@@ -46,10 +39,9 @@ class EchoNotificationDeleteJob extends Job {
}
$notifMapper = new EchoNotificationMapper();
- $targetMapper = new EchoTargetPageMapper();
// Back-compat for older jobs which used array( $userId => $userId );
- $userIds = array_values( $this->userIds );
+ $userIds = array_values( $this->params['userIds'] );
$userId = $userIds[0];
$user = User::newFromId( $userId );
$notif = $notifMapper->fetchByUserOffset( $user, $wgEchoMaxUpdateCount );
diff --git a/Echo/includes/jobs/NotificationJob.php b/Echo/includes/jobs/NotificationJob.php
index 00282c00..06fe226b 100644
--- a/Echo/includes/jobs/NotificationJob.php
+++ b/Echo/includes/jobs/NotificationJob.php
@@ -1,16 +1,14 @@
<?php
class EchoNotificationJob extends Job {
- private $eventId;
- function __construct( $title, $params ) {
+ public function __construct( Title $title, array $params ) {
parent::__construct( 'EchoNotificationJob', $title, $params );
- $this->eventId = $params['eventId'];
}
- function run() {
- MWEchoDbFactory::newFromDefault()->waitForSlaves();
- $event = EchoEvent::newFromID( $this->eventId );
+ public function run() {
+ $eventMapper = new EchoEventMapper();
+ $event = $eventMapper->fetchById( $this->params['eventId'], true );
EchoNotificationController::notify( $event, false );
return true;
diff --git a/Echo/includes/mapper/AbstractMapper.php b/Echo/includes/mapper/AbstractMapper.php
index d59bd8f1..ed6e140e 100644
--- a/Echo/includes/mapper/AbstractMapper.php
+++ b/Echo/includes/mapper/AbstractMapper.php
@@ -69,11 +69,8 @@ abstract class EchoAbstractMapper {
if ( !method_exists( $this, $method ) ) {
throw new MWException( $method . ' does not exist in ' . get_class( $this ) );
}
- if ( isset( $this->listeners[$method] ) ) {
- return $this->listeners[$method];
- } else {
- return [];
- }
+
+ return $this->listeners[$method] ?? [];
}
}
diff --git a/Echo/includes/mapper/EventMapper.php b/Echo/includes/mapper/EventMapper.php
index 3ac84dc9..d1e58919 100644
--- a/Echo/includes/mapper/EventMapper.php
+++ b/Echo/includes/mapper/EventMapper.php
@@ -19,20 +19,16 @@ class EchoEventMapper extends EchoAbstractMapper {
$row = $event->toDbArray();
- $res = $dbw->insert( 'echo_event', $row, __METHOD__ );
+ $dbw->insert( 'echo_event', $row, __METHOD__ );
- if ( $res ) {
- $id = $dbw->insertId();
-
- $listeners = $this->getMethodListeners( __FUNCTION__ );
- foreach ( $listeners as $listener ) {
- $dbw->onTransactionIdle( $listener );
- }
+ $id = $dbw->insertId();
- return $id;
- } else {
- return false;
+ $listeners = $this->getMethodListeners( __FUNCTION__ );
+ foreach ( $listeners as $listener ) {
+ $dbw->onTransactionCommitOrIdle( $listener, __METHOD__ );
}
+
+ return $id;
}
/**
@@ -46,7 +42,7 @@ class EchoEventMapper extends EchoAbstractMapper {
public function fetchById( $id, $fromMaster = false ) {
$db = $fromMaster ? $this->dbFactory->getEchoDb( DB_MASTER ) : $this->dbFactory->getEchoDb( DB_REPLICA );
- $row = $db->selectRow( 'echo_event', '*', [ 'event_id' => $id ], __METHOD__ );
+ $row = $db->selectRow( 'echo_event', EchoEvent::selectFields(), [ 'event_id' => $id ], __METHOD__ );
// If the row was not found, fall back on the master if it makes sense to do so
if ( !$row && !$fromMaster && $this->dbFactory->canRetryMaster() ) {
@@ -63,12 +59,12 @@ class EchoEventMapper extends EchoAbstractMapper {
* @param bool $deleted
* @return bool|IResultWrapper
*/
- public function toggleDeleted( $eventIds, $deleted ) {
+ public function toggleDeleted( array $eventIds, $deleted ) {
$dbw = $this->dbFactory->getEchoDb( DB_MASTER );
$selectDeleted = $deleted ? 0 : 1;
$setDeleted = $deleted ? 1 : 0;
- $res = $dbw->update(
+ $dbw->update(
'echo_event',
[
'event_deleted' => $setDeleted,
@@ -80,7 +76,7 @@ class EchoEventMapper extends EchoAbstractMapper {
__METHOD__
);
- return $res;
+ return true;
}
/**
@@ -91,14 +87,35 @@ class EchoEventMapper extends EchoAbstractMapper {
*/
public function fetchByPage( $pageId ) {
$events = [];
-
+ $seenEventIds = [];
$dbr = $this->dbFactory->getEchoDb( DB_REPLICA );
+
+ // From echo_event
+ $res = $dbr->select(
+ [ 'echo_event' ],
+ EchoEvent::selectFields(),
+ [ 'event_page_id' => $pageId ],
+ __METHOD__
+ );
+ if ( $res ) {
+ foreach ( $res as $row ) {
+ $event = EchoEvent::newFromRow( $row );
+ $events[] = $event;
+ $seenEventIds[] = $event->getId();
+ }
+ }
+
+ // From echo_target_page
+ $conds = [ 'etp_page' => $pageId ];
+ if ( $seenEventIds ) {
+ // Some events have both a title and target page(s).
+ // Skip the events that were already found in the echo_event table (the query above).
+ $conds[] = 'event_id NOT IN ( ' . $dbr->makeList( $seenEventIds ) . ' )';
+ }
$res = $dbr->select(
[ 'echo_event', 'echo_target_page' ],
- [ '*' ],
- [
- 'etp_page' => $pageId
- ],
+ EchoEvent::selectFields(),
+ $conds,
__METHOD__,
[ 'GROUP BY' => 'etp_event' ],
[ 'echo_target_page' => [ 'INNER JOIN', 'event_id=etp_event' ] ]
@@ -138,10 +155,11 @@ class EchoEventMapper extends EchoAbstractMapper {
*/
public function fetchUnreadByUserAndPage( User $user, $pageId ) {
$dbr = $this->dbFactory->getEchoDb( DB_REPLICA );
+ $fields = array_merge( EchoEvent::selectFields(), [ 'notification_timestamp' ] );
$res = $dbr->select(
[ 'echo_event', 'echo_notification', 'echo_target_page' ],
- '*',
+ $fields,
[
'event_deleted' => 0,
'notification_user' => $user->getId(),
@@ -149,7 +167,7 @@ class EchoEventMapper extends EchoAbstractMapper {
'etp_page' => $pageId,
],
__METHOD__,
- null,
+ [],
[
'echo_target_page' => [ 'INNER JOIN', 'etp_event=event_id' ],
'echo_notification' => [ 'INNER JOIN', [ 'notification_event=event_id' ] ],
@@ -164,4 +182,59 @@ class EchoEventMapper extends EchoAbstractMapper {
return $data;
}
+ /**
+ * Find out which of the given event IDs are orphaned, and delete them.
+ *
+ * An event is orphaned if it is not referred to by any rows in the echo_notification or
+ * echo_email_batch tables. If $ignoreUserId is set, rows for that user are not considered when
+ * determining orphanhood; if $ignoreUserTable is set, this only applies to that table.
+ * Use this when you've just recently deleted rows related to this user on the master, so that
+ * this function won't refuse to delete recently-orphaned events because it still sees the
+ * recently-deleted rows on the replica.
+ *
+ * @param array $eventIds Event IDs to check to see if they have become orphaned
+ * @param int|null $ignoreUserId Allow events to be deleted if the only referring rows
+ * have this user ID
+ * @param string|null $ignoreUserTable Restrict $ignoreUserId to this table only
+ * ('echo_notification' or 'echo_email_batch')
+ */
+ public function deleteOrphanedEvents( array $eventIds, $ignoreUserId = null, $ignoreUserTable = null ) {
+ $dbw = $this->dbFactory->getEchoDb( DB_MASTER );
+ $dbr = $this->dbFactory->getEchoDb( DB_REPLICA );
+
+ $notifJoinConds = [];
+ $emailJoinConds = [];
+ if ( $ignoreUserId !== null ) {
+ if ( $ignoreUserTable === null || $ignoreUserTable === 'echo_notification' ) {
+ $notifJoinConds[] = 'notification_user != ' . $dbr->addQuotes( $ignoreUserId );
+ }
+ if ( $ignoreUserTable === null || $ignoreUserTable === 'echo_email_batch' ) {
+ $emailJoinConds[] = 'eeb_user_id != ' . $dbr->addQuotes( $ignoreUserId );
+ }
+ }
+ $orphanedEventIds = $dbr->selectFieldValues(
+ [ 'echo_event', 'echo_notification', 'echo_email_batch' ],
+ 'event_id',
+ [
+ 'event_id' => $eventIds,
+ 'notification_timestamp' => null,
+ 'eeb_user_id' => null
+ ],
+ __METHOD__,
+ [],
+ [
+ 'echo_notification' => [ 'LEFT JOIN', array_merge( [
+ 'notification_event=event_id'
+ ], $notifJoinConds ) ],
+ 'echo_email_batch' => [ 'LEFT JOIN', array_merge( [
+ 'eeb_event_id=event_id'
+ ], $emailJoinConds ) ]
+ ]
+ );
+ if ( $orphanedEventIds ) {
+ $dbw->delete( 'echo_event', [ 'event_id' => $orphanedEventIds ], __METHOD__ );
+ $dbw->delete( 'echo_target_page', [ 'etp_event' => $orphanedEventIds ], __METHOD__ );
+ }
+ }
+
}
diff --git a/Echo/includes/mapper/NotificationMapper.php b/Echo/includes/mapper/NotificationMapper.php
index 3e9dc2f6..4e572a78 100644
--- a/Echo/includes/mapper/NotificationMapper.php
+++ b/Echo/includes/mapper/NotificationMapper.php
@@ -23,31 +23,11 @@ class EchoNotificationMapper extends EchoAbstractMapper {
$dbw,
__METHOD__,
function ( IDatabase $dbw, $fname ) use ( $row, $listeners ) {
- // Reset the bundle base if this notification has a display hash
- // the result of this operation is that all previous notifications
- // with the same display hash are set to non-base because new record
- // is becoming the bundle base
- if ( $row['notification_bundle_display_hash'] ) {
- $dbw->update(
- 'echo_notification',
- [ 'notification_bundle_base' => 0 ],
- [
- 'notification_user' => $row['notification_user'],
- 'notification_bundle_display_hash' =>
- $row['notification_bundle_display_hash'],
- 'notification_bundle_base' => 1
- ],
- $fname
- );
- }
-
$row['notification_timestamp'] =
$dbw->timestamp( $row['notification_timestamp'] );
- $res = $dbw->insert( 'echo_notification', $row, $fname );
- if ( $res ) {
- foreach ( $listeners as $listener ) {
- $dbw->onTransactionIdle( $listener );
- }
+ $dbw->insert( 'echo_notification', $row, $fname );
+ foreach ( $listeners as $listener ) {
+ $dbw->onTransactionCommitOrIdle( $listener, $fname );
}
}
) );
@@ -55,7 +35,7 @@ class EchoNotificationMapper extends EchoAbstractMapper {
/**
* Extract the offset used for notification list
- * @param string $continue String Used for offset
+ * @param string|null $continue String Used for offset
* @throws MWException
* @return int[]
*/
@@ -84,11 +64,11 @@ class EchoNotificationMapper extends EchoAbstractMapper {
* which is done via a deleteJob
* @param User $user
* @param int $limit
- * @param string $continue Used for offset
+ * @param string|null $continue Used for offset
* @param string[] $eventTypes
* @param Title[]|null $titles If set, only return notifications for these pages.
* To find notifications not associated with any page, add null as an element to this array.
- * @param int $dbSource Use master or slave database
+ * @param int $dbSource Use master or replica database
* @return EchoNotification[]
*/
public function fetchUnreadByUser(
@@ -99,7 +79,7 @@ class EchoNotificationMapper extends EchoAbstractMapper {
array $titles = null,
$dbSource = DB_REPLICA
) {
- $conds['notification_read_timestamp'] = null;
+ $conds = [ 'notification_read_timestamp' => null ];
if ( $titles ) {
$conds['event_page_id'] = $this->getIdsForTitles( $titles );
if ( !$conds['event_page_id'] ) {
@@ -117,11 +97,11 @@ class EchoNotificationMapper extends EchoAbstractMapper {
* which is done via a deleteJob
* @param User $user
* @param int $limit
- * @param string $continue Used for offset
+ * @param string|null $continue Used for offset
* @param string[] $eventTypes
* @param Title[]|null $titles If set, only return notifications for these pages.
* To find notifications not associated with any page, add null as an element to this array.
- * @param int $dbSource Use master or slave database
+ * @param int $dbSource Use master or replica database
* @return EchoNotification[]
*/
public function fetchReadByUser(
@@ -147,7 +127,7 @@ class EchoNotificationMapper extends EchoAbstractMapper {
*
* @param User $user the user to get notifications for
* @param int $limit The maximum number of notifications to return
- * @param string $continue Used for offset
+ * @param string|null $continue Used for offset
* @param array $eventTypes Event types to load
* @param array $excludeEventIds Event id's to exclude.
* @param Title[]|null $titles If set, only return notifications for these pages.
@@ -193,10 +173,10 @@ class EchoNotificationMapper extends EchoAbstractMapper {
/**
* @param User $user the user to get notifications for
* @param int $limit The maximum number of notifications to return
- * @param string $continue Used for offset
+ * @param string|null $continue Used for offset
* @param array $eventTypes Event types to load
* @param array $conds Additional query conditions.
- * @param int $dbSource Use master or slave database
+ * @param int $dbSource Use master or replica database
* @return EchoNotification[]
*/
protected function fetchByUserInternal(
@@ -220,7 +200,7 @@ class EchoNotificationMapper extends EchoAbstractMapper {
// the notification volume is in a reasonable amount for such case. The other option
// is to denormalize notification table with event_type and lookup index.
$conds = [
- 'notification_user' => $user->getID(),
+ 'notification_user' => $user->getId(),
'event_type' => $eventTypes,
'event_deleted' => 0,
] + $conds;
@@ -237,7 +217,7 @@ class EchoNotificationMapper extends EchoAbstractMapper {
$res = $dbr->select(
[ 'echo_notification', 'echo_event' ],
- '*',
+ EchoNotification::selectFields(),
$conds,
__METHOD__,
[
@@ -263,7 +243,7 @@ class EchoNotificationMapper extends EchoAbstractMapper {
$allNotifications[] = $notification;
}
} catch ( Exception $e ) {
- $id = isset( $row->event_id ) ? $row->event_id : 'unknown event';
+ $id = $row->event_id ?? 'unknown event';
wfDebugLog( 'Echo', __METHOD__ . ": Failed initializing event: $id" );
MWExceptionHandler::logException( $e );
}
@@ -278,47 +258,18 @@ class EchoNotificationMapper extends EchoAbstractMapper {
}
/**
- * Get the last notification in a set of bundle-able notifications by a bundle hash
- * @param User $user
- * @param string $bundleHash The hash used to identify a set of bundle-able notifications
- * @return EchoNotification|false
- */
- public function fetchNewestByUserBundleHash( User $user, $bundleHash ) {
- $dbr = $this->dbFactory->getEchoDb( DB_REPLICA );
-
- $row = $dbr->selectRow(
- [ 'echo_notification', 'echo_event' ],
- [ '*' ],
- [
- 'notification_user' => $user->getId(),
- 'notification_bundle_hash' => $bundleHash
- ],
- __METHOD__,
- [ 'ORDER BY' => 'notification_timestamp DESC', 'LIMIT' => 1 ],
- [
- 'echo_event' => [ 'LEFT JOIN', 'notification_event=event_id' ],
- ]
- );
- if ( $row ) {
- return EchoNotification::newFromRow( $row );
- } else {
- return false;
- }
- }
-
- /**
* Fetch EchoNotifications by user and event IDs.
*
* @param User $user
* @param int[] $eventIds
* @return EchoNotification[]|false
*/
- public function fetchByUserEvents( User $user, $eventIds ) {
+ public function fetchByUserEvents( User $user, array $eventIds ) {
$dbr = $this->dbFactory->getEchoDb( DB_REPLICA );
$result = $dbr->select(
[ 'echo_notification', 'echo_event' ],
- '*',
+ EchoNotification::selectFields(),
[
'notification_user' => $user->getId(),
'notification_event' => $eventIds
@@ -352,7 +303,7 @@ class EchoNotificationMapper extends EchoAbstractMapper {
$dbr = $this->dbFactory->getEchoDb( DB_REPLICA );
$row = $dbr->selectRow(
[ 'echo_notification', 'echo_event' ],
- [ '*' ],
+ EchoNotification::selectFields(),
[
'notification_user' => $user->getId(),
'event_deleted' => 0,
@@ -383,38 +334,47 @@ class EchoNotificationMapper extends EchoAbstractMapper {
*/
public function deleteByUserEventOffset( User $user, $eventId ) {
global $wgUpdateRowsPerQuery;
+ $eventMapper = new EchoEventMapper( $this->dbFactory );
$userId = $user->getId();
$dbw = $this->dbFactory->getEchoDb( DB_MASTER );
+ $dbr = $this->dbFactory->getEchoDb( DB_REPLICA );
$lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
$ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
- $domainId = $dbw->getDomainId();
+ $domainId = $dbw->getDomainID();
- $idsToDelete = $dbw->selectFieldValues(
+ $iterator = new BatchRowIterator(
+ $dbr,
'echo_notification',
'notification_event',
- [
- 'notification_user' => $userId,
- 'notification_event < ' . (int)$eventId
- ],
- __METHOD__
+ $wgUpdateRowsPerQuery
);
- if ( !$idsToDelete ) {
- return true;
- }
- $success = true;
- foreach ( array_chunk( $idsToDelete, $wgUpdateRowsPerQuery ) as $batch ) {
- $success = $dbw->delete(
+ $iterator->addConditions( [
+ 'notification_user' => $userId,
+ 'notification_event < ' . (int)$eventId
+ ] );
+
+ foreach ( $iterator as $batch ) {
+ $eventIds = [];
+ foreach ( $batch as $row ) {
+ $eventIds[] = $row->notification_event;
+ }
+ $dbw->delete(
'echo_notification',
[
'notification_user' => $userId,
- 'notification_event' => $batch,
+ 'notification_event' => $eventIds,
],
__METHOD__
- ) && $success;
+ );
+
+ // Find out which events are now orphaned, i.e. no longer referenced in echo_notifications
+ // (besides the rows we just deleted) or in echo_email_batch, and delete them
+ $eventMapper->deleteOrphanedEvents( $eventIds, $userId, 'echo_notification' );
+
$lbFactory->commitAndWaitForReplication(
__METHOD__, $ticket, [ 'domain' => $domainId ] );
}
- return $success;
+ return true;
}
/**
@@ -423,7 +383,7 @@ class EchoNotificationMapper extends EchoAbstractMapper {
* @param int[] $eventIds
* @return int[]|false
*/
- public function fetchUsersWithNotificationsForEvents( $eventIds ) {
+ public function fetchUsersWithNotificationsForEvents( array $eventIds ) {
$dbr = $this->dbFactory->getEchoDb( DB_REPLICA );
$res = $dbr->select(
diff --git a/Echo/includes/mapper/TargetPageMapper.php b/Echo/includes/mapper/TargetPageMapper.php
index 33dd7bc5..91699722 100644
--- a/Echo/includes/mapper/TargetPageMapper.php
+++ b/Echo/includes/mapper/TargetPageMapper.php
@@ -25,8 +25,8 @@ class EchoTargetPageMapper extends EchoAbstractMapper {
$row = $targetPage->toDbArray();
- $res = $dbw->insert( 'echo_target_page', $row, __METHOD__ );
+ $dbw->insert( 'echo_target_page', $row, __METHOD__ );
- return $res;
+ return true;
}
}
diff --git a/Echo/includes/model/Event.php b/Echo/includes/model/Event.php
index 79a9b14b..87dcffae 100644
--- a/Echo/includes/model/Event.php
+++ b/Echo/includes/model/Event.php
@@ -1,6 +1,8 @@
<?php
use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRecord;
/**
* Immutable class to represent an event.
@@ -8,41 +10,46 @@ use MediaWiki\Logger\LoggerFactory;
*/
class EchoEvent extends EchoAbstractEntity implements Bundleable {
+ /** @var string|null */
protected $type = null;
+ /** @var int|null|false */
protected $id = null;
+ /** @var string|null */
protected $variant = null;
/**
- * @var User
+ * @var User|null
*/
protected $agent = null;
/**
* Loaded dynamically on request
*
- * @var Title
+ * @var Title|null
*/
protected $title = null;
+ /** @var int|null */
protected $pageId = null;
/**
* Loaded dynamically on request
*
- * @var Revision
+ * @var RevisionRecord|null
*/
protected $revision = null;
+ /** @var array */
protected $extra = [];
/**
* Notification timestamp
- * @var string
+ * @var string|null
*/
protected $timestamp = null;
/**
* A hash used to bundle a set of events, events that can be
* grouped for a user has the same bundle hash
- * @var string
+ * @var string|null
*/
protected $bundleHash;
@@ -71,7 +78,7 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
}
## Save the id and timestamp
- function __sleep() {
+ public function __sleep() {
if ( !$this->id ) {
throw new MWException( "Unable to serialize an uninitialized EchoEvent" );
}
@@ -79,11 +86,11 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
return [ 'id', 'timestamp' ];
}
- function __wakeup() {
+ public function __wakeup() {
$this->loadFromID( $this->id );
}
- function __toString() {
+ public function __toString() {
return "EchoEvent(id={$this->id}; type={$this->type})";
}
@@ -150,7 +157,7 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
$obj->setTitle( $obj->title );
}
- if ( $obj->agent && ! $obj->agent instanceof User ) {
+ if ( $obj->agent && !$obj->agent instanceof User ) {
throw new InvalidArgumentException( "Invalid user parameter" );
}
@@ -195,7 +202,7 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
if ( $this->pageId ) {
$data['event_page_id'] = $this->pageId;
} elseif ( $this->title ) {
- $pageId = $this->title->getArticleId();
+ $pageId = $this->title->getArticleID();
// Don't need any special handling for title with no id
// as they are already stored in extra data array
if ( $pageId ) {
@@ -272,7 +279,7 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
* @return bool Whether loading was successful
*/
public function loadFromRow( $row ) {
- $this->id = $row->event_id;
+ $this->id = (int)$row->event_id;
$this->type = $row->event_type;
// If the object is loaded from __sleep(), timestamp should be already set
@@ -301,14 +308,15 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
$this->deleted = $row->event_deleted;
if ( $row->event_agent_id ) {
- $this->agent = User::newFromID( $row->event_agent_id );
+ $this->agent = User::newFromId( $row->event_agent_id );
} elseif ( $row->event_agent_ip ) {
+ // @phan-suppress-next-line PhanTypeMismatchArgument Not null here
$this->agent = User::newFromName( $row->event_agent_ip, false );
}
// Lazy load the title from getTitle() so that we can do a batch-load
if (
- isset( $this->extra['page_title'], $this->extra['page_namespace'] )
+ isset( $this->extra['page_title'] ) && isset( $this->extra['page_namespace'] )
&& !$row->event_page_id
) {
$this->title = Title::makeTitleSafe(
@@ -318,6 +326,7 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
}
if ( $row->event_page_id ) {
$titleCache = EchoTitleLocalCache::create();
+ // @phan-suppress-next-line PhanTypeMismatchArgument Not null here
$titleCache->add( $row->event_page_id );
}
if ( isset( $this->extra['revid'] ) && $this->extra['revid'] ) {
@@ -386,12 +395,12 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
/**
* Serialize the extra data for event
- * @return string
+ * @return string|null
*/
public function serializeExtra() {
if ( is_array( $this->extra ) || is_object( $this->extra ) ) {
$extra = serialize( $this->extra );
- } elseif ( is_null( $this->extra ) ) {
+ } elseif ( $this->extra === null ) {
$extra = null;
} else {
$extra = serialize( [ $this->extra ] );
@@ -401,58 +410,40 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
}
/**
- * Check if the event is dismissable for the given distribution type
- *
- * @param string $distribution notification distribution web/email
- * @return bool
- */
- public function isDismissable( $distribution ) {
- global $wgEchoNotificationCategories;
-
- $category = $this->getCategory();
- if ( isset( $wgEchoNotificationCategories[$category]['no-dismiss'] ) ) {
- $noDismiss = $wgEchoNotificationCategories[$category]['no-dismiss'];
- } else {
- $noDismiss = [];
- }
- if ( !in_array( $distribution, $noDismiss ) && !in_array( 'all', $noDismiss ) ) {
- return true;
- } else {
- return false;
- }
- }
-
- /**
* Determine if the current user is allowed to view a particular
* field of this revision, if it's marked as deleted. When no
* revision is attached always returns true.
*
- * @param int $field One of Revision::DELETED_TEXT,
- * Revision::DELETED_COMMENT,
- * Revision::DELETED_USER
+ * @param int $field One of RevisionRecord::DELETED_TEXT,
+ * RevisionRecord::DELETED_COMMENT,
+ * RevisionRecord::DELETED_USER
* @param User $user User object to check
* @return bool
*/
public function userCan( $field, User $user ) {
$revision = $this->getRevision();
// User is handled specially
- if ( $field === Revision::DELETED_USER ) {
+ if ( $field === RevisionRecord::DELETED_USER ) {
$agent = $this->getAgent();
if ( !$agent ) {
// No user associated, so they can see it.
return true;
- } elseif ( $revision
- && $agent->getName() === $revision->getUserText( Revision::RAW )
+ }
+
+ if (
+ $revision
+ && $agent->getName() === $revision->getUser( RevisionRecord::RAW )->getName()
) {
// If the agent and the revision user are the same, use rev_deleted
- return $revision->userCan( $field, $user );
+ return $revision->audienceCan( $field, RevisionRecord::FOR_THIS_USER, $user );
} else {
// Use User::isHidden()
- return $user->isAllowedAny( 'viewsuppressed', 'hideuser' ) || !$agent->isHidden();
+ $permManager = MediaWikiServices::getInstance()->getPermissionManager();
+ return $permManager->userHasAnyRight( $user, 'viewsuppressed', 'hideuser' ) || !$agent->isHidden();
}
} elseif ( $revision ) {
// A revision is set, use rev_deleted
- return $revision->userCan( $field, $user );
+ return $revision->audienceCan( $field, RevisionRecord::FOR_THIS_USER, $user );
} else {
// Not a user, and there is no associated revision, so the user can see it
return true;
@@ -460,6 +451,7 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
}
## Accessors
+
/**
* @return int
*/
@@ -496,7 +488,7 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
}
public function getExtraParam( $key, $default = null ) {
- return isset( $this->extra[$key] ) ? $this->extra[$key] : $default;
+ return $this->extra[$key] ?? $default;
}
/**
@@ -507,6 +499,21 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
}
/**
+ * Check whether this event allows its agent to be notified.
+ *
+ * Notifying the agent is only allowed if the event's type allows it, or if the event extra
+ * explicity specifies 'notifyAgent' => true.
+ *
+ * @return bool
+ */
+ public function canNotifyAgent() {
+ global $wgEchoNotifications;
+ $allowedInConfig = $wgEchoNotifications[$this->getType()]['canNotifyAgent'] ?? false;
+ $allowedInExtra = $this->getExtraParam( 'notifyAgent', false );
+ return $allowedInConfig || $allowedInExtra;
+ }
+
+ /**
* @param bool $fromMaster
* @return null|Title
*/
@@ -523,7 +530,7 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
$this->title = Title::newFromID( $this->pageId, $fromMaster ? Title::GAID_FOR_UPDATE : 0 );
return $this->title;
- } elseif ( isset( $this->extra['page_title'], $this->extra['page_namespace'] ) ) {
+ } elseif ( isset( $this->extra['page_title'] ) && isset( $this->extra['page_namespace'] ) ) {
$this->title = Title::makeTitleSafe(
$this->extra['page_namespace'],
$this->extra['page_title']
@@ -535,12 +542,14 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
}
/**
- * @return Revision|null
+ * @return RevisionRecord|null
*/
public function getRevision() {
if ( $this->revision ) {
return $this->revision;
- } elseif ( isset( $this->extra['revid'] ) ) {
+ }
+
+ if ( isset( $this->extra['revid'] ) ) {
$revisionCache = EchoRevisionLocalCache::create();
$revision = $revisionCache->get( $this->extra['revid'] );
if ( $revision ) {
@@ -548,7 +557,8 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
return $this->revision;
}
- $this->revision = Revision::newFromId( $this->extra['revid'] );
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $this->revision = $store->getRevisionById( $this->extra['revid'] );
return $this->revision;
}
@@ -618,44 +628,36 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
/**
* Get the message key of the primary or secondary link for a notification type.
*
- * @param String $rank 'primary' or 'secondary'
- * @return String i18n message key
+ * @param string $rank 'primary' or 'secondary'
+ * @return string i18n message key
*/
public function getLinkMessage( $rank ) {
global $wgEchoNotifications;
$type = $this->getType();
- if ( isset( $wgEchoNotifications[$type][$rank . '-link']['message'] ) ) {
- return $wgEchoNotifications[$type][$rank . '-link']['message'];
- }
-
- return '';
+ return $wgEchoNotifications[$type][$rank . '-link']['message'] ?? '';
}
/**
* Get the link destination of the primary or secondary link for a notification type.
*
- * @param String $rank 'primary' or 'secondary'
- * @return String The link destination, e.g. 'agent'
+ * @param string $rank 'primary' or 'secondary'
+ * @return string The link destination, e.g. 'agent'
*/
public function getLinkDestination( $rank ) {
global $wgEchoNotifications;
$type = $this->getType();
- if ( isset( $wgEchoNotifications[$type][$rank . '-link']['destination'] ) ) {
- return $wgEchoNotifications[$type][$rank . '-link']['destination'];
- }
-
- return '';
+ return $wgEchoNotifications[$type][$rank . '-link']['destination'] ?? '';
}
/**
- * @return string
+ * @return string|null
*/
public function getBundleHash() {
return $this->bundleHash;
}
/**
- * @param string $hash
+ * @param string|null $hash
*/
public function setBundleHash( $hash ) {
$this->bundleHash = $hash;
@@ -668,7 +670,7 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
return $this->deleted === 1;
}
- public function setBundledEvents( $events ) {
+ public function setBundledEvents( array $events ) {
$this->bundledEvents = $events;
}
@@ -693,7 +695,7 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
/**
* @inheritDoc
*/
- public function setBundledElements( $bundleables ) {
+ public function setBundledElements( array $bundleables ) {
$this->setBundledEvents( $bundleables );
}
@@ -703,4 +705,23 @@ class EchoEvent extends EchoAbstractEntity implements Bundleable {
public function getSortingKey() {
return $this->getTimestamp();
}
+
+ /**
+ * Return the list of fields that should be selected to create
+ * a new event with EchoEvent::newFromRow
+ * @return string[]
+ */
+ public static function selectFields() {
+ return [
+ 'event_id',
+ 'event_type',
+ 'event_variant',
+ 'event_agent_id',
+ 'event_agent_ip',
+ 'event_extra',
+ 'event_page_id',
+ 'event_deleted',
+ ];
+ }
+
}
diff --git a/Echo/includes/model/Notification.php b/Echo/includes/model/Notification.php
index 282b3386..5ace7154 100644
--- a/Echo/includes/model/Notification.php
+++ b/Echo/includes/model/Notification.php
@@ -1,5 +1,7 @@
<?php
+use MediaWiki\MediaWikiServices;
+
class EchoNotification extends EchoAbstractEntity implements Bundleable {
/**
@@ -31,25 +33,12 @@ class EchoNotification extends EchoAbstractEntity implements Bundleable {
protected $readTimestamp;
/**
- * Determine whether this is a bundle base. Default is 1,
- * which means it's a bundle base
- * @var int
- */
- protected $bundleBase = 1;
-
- /**
* The hash used to determine if a set of event could be bundled
* @var string
*/
protected $bundleHash = '';
/**
- * The hash used to bundle events to display
- * @var string
- */
- protected $bundleDisplayHash = '';
-
- /**
* @var EchoNotification[]
*/
protected $bundledNotifications;
@@ -115,27 +104,17 @@ class EchoNotification extends EchoAbstractEntity implements Bundleable {
Hooks::run( 'EchoGetBundleRules', [ $this->event, &$bundleKey ] );
}
+ // @phan-suppress-next-line PhanImpossibleCondition May be set by hook
if ( $bundleKey ) {
$hash = md5( $bundleKey );
$this->bundleHash = $hash;
- $lastNotif = $notifMapper->fetchNewestByUserBundleHash( $this->user, $hash );
-
- // Use a new display hash if:
- // 1. there was no last bundle notification
- // 2. last bundle notification with the same hash was read
- if ( $lastNotif && !$lastNotif->getReadTimestamp() ) {
- $this->bundleDisplayHash = $lastNotif->getBundleDisplayHash();
- } else {
- $this->bundleDisplayHash = md5( $bundleKey . '-display-hash-' . wfTimestampNow() );
- }
}
$notifUser = MWEchoNotifUser::newFromUser( $this->user );
- $section = $this->event->getSection();
// Add listener to refresh notification count upon insert
$notifMapper->attachListener( 'insert', 'refresh-notif-count',
- function () use ( $notifUser, $section ) {
+ function () use ( $notifUser ) {
$notifUser->resetNotificationCount();
}
);
@@ -143,7 +122,9 @@ class EchoNotification extends EchoAbstractEntity implements Bundleable {
$notifMapper->insert( $this );
if ( $this->event->getCategory() === 'edit-user-talk' ) {
- $this->user->setNewtalk( true );
+ MediaWikiServices::getInstance()
+ ->getTalkPageNotificationManager()
+ ->setUserHasNewMessages( $this->user );
}
Hooks::run( 'EchoCreateNotificationComplete', [ $this ] );
}
@@ -154,7 +135,7 @@ class EchoNotification extends EchoAbstractEntity implements Bundleable {
* @param EchoTargetPage[]|null $targetPages An array of EchoTargetPage instances, or null if not loaded.
* @return EchoNotification|false False if failed to load/unserialize
*/
- public static function newFromRow( $row, $targetPages = null ) {
+ public static function newFromRow( $row, array $targetPages = null ) {
$notification = new EchoNotification();
if ( property_exists( $row, 'event_type' ) ) {
@@ -176,9 +157,7 @@ class EchoNotification extends EchoAbstractEntity implements Bundleable {
if ( $row->notification_read_timestamp ) {
$notification->readTimestamp = wfTimestamp( TS_MW, $row->notification_read_timestamp );
}
- $notification->bundleBase = $row->notification_bundle_base;
$notification->bundleHash = $row->notification_bundle_hash;
- $notification->bundleDisplayHash = $row->notification_bundle_display_hash;
return $notification;
}
@@ -193,9 +172,7 @@ class EchoNotification extends EchoAbstractEntity implements Bundleable {
'notification_user' => $this->user->getId(),
'notification_timestamp' => $this->timestamp,
'notification_read_timestamp' => $this->readTimestamp,
- 'notification_bundle_base' => $this->bundleBase,
'notification_bundle_hash' => $this->bundleHash,
- 'notification_bundle_display_hash' => $this->bundleDisplayHash
];
}
@@ -237,14 +214,6 @@ class EchoNotification extends EchoAbstractEntity implements Bundleable {
/**
* Getter method
- * @return int Notification bundle base
- */
- public function getBundleBase() {
- return $this->bundleBase;
- }
-
- /**
- * Getter method
* @return string|null Notification bundle hash
*/
public function getBundleHash() {
@@ -252,14 +221,6 @@ class EchoNotification extends EchoAbstractEntity implements Bundleable {
}
/**
- * Getter method
- * @return string|null Notification bundle display hash
- */
- public function getBundleDisplayHash() {
- return $this->bundleDisplayHash;
- }
-
- /**
* Getter method. Returns an array of EchoTargetPages, or null if they have
* not been loaded.
*
@@ -269,7 +230,7 @@ class EchoNotification extends EchoAbstractEntity implements Bundleable {
return $this->targetPages;
}
- public function setBundledNotifications( $notifications ) {
+ public function setBundledNotifications( array $notifications ) {
$this->bundledNotifications = $notifications;
}
@@ -294,7 +255,7 @@ class EchoNotification extends EchoAbstractEntity implements Bundleable {
/**
* @inheritDoc
*/
- public function setBundledElements( $bundleables ) {
+ public function setBundledElements( array $bundleables ) {
$this->setBundledNotifications( $bundleables );
}
@@ -304,4 +265,19 @@ class EchoNotification extends EchoAbstractEntity implements Bundleable {
public function getSortingKey() {
return ( $this->isRead() ? '0' : '1' ) . '_' . $this->getTimestamp();
}
+
+ /**
+ * Return the list of fields that should be selected to create
+ * a new event with EchoNotification::newFromRow
+ * @return string[]
+ */
+ public static function selectFields() {
+ return array_merge( EchoEvent::selectFields(), [
+ 'notification_event',
+ 'notification_user',
+ 'notification_timestamp',
+ 'notification_read_timestamp',
+ 'notification_bundle_hash',
+ ] );
+ }
}
diff --git a/Echo/includes/model/TargetPage.php b/Echo/includes/model/TargetPage.php
index e9ea4d2b..4134f039 100644
--- a/Echo/includes/model/TargetPage.php
+++ b/Echo/includes/model/TargetPage.php
@@ -92,7 +92,7 @@ class EchoTargetPage extends EchoAbstractEntity {
*/
public function getTitle() {
if ( $this->title === false ) {
- $this->title = Title::newFromId( $this->pageId );
+ $this->title = Title::newFromID( $this->pageId );
}
return $this->title;
diff --git a/Echo/includes/ooui/LabelIconWidget.php b/Echo/includes/ooui/LabelIconWidget.php
index 6cceec2d..940b1721 100644
--- a/Echo/includes/ooui/LabelIconWidget.php
+++ b/Echo/includes/ooui/LabelIconWidget.php
@@ -4,8 +4,8 @@ namespace EchoOOUI;
use OOUI\IconElement;
use OOUI\LabelElement;
-use OOUI\TitledElement;
use OOUI\Tag;
+use OOUI\TitledElement;
use OOUI\Widget;
/**
@@ -25,20 +25,20 @@ class LabelIconWidget extends Widget {
public function __construct( $config ) {
parent::__construct( $config );
- $this->tableRow = new Tag( 'div' );
- $this->tableRow->setAttributes( [
+ $tableRow = new Tag( 'div' );
+ $tableRow->setAttributes( [
'class' => 'oo-ui-labelIconWidget-row',
] );
- $this->icon = new Tag( 'div' );
- $this->label = new Tag( 'div' );
+ $icon = new Tag( 'div' );
+ $label = new Tag( 'div' );
- $this->initializeIconElement( array_merge( $config, [ 'iconElement' => $this->icon ] ) );
- $this->initializeLabelElement( array_merge( $config, [ 'labelElement' => $this->label ] ) );
+ $this->initializeIconElement( array_merge( $config, [ 'iconElement' => $icon ] ) );
+ $this->initializeLabelElement( array_merge( $config, [ 'labelElement' => $label ] ) );
$this->initializeTitledElement( $config );
$this->addClasses( [ 'oo-ui-labelIconWidget' ] );
- $this->tableRow->appendContent( $this->icon, $this->label );
- $this->appendContent( $this->tableRow );
+ $tableRow->appendContent( $icon, $label );
+ $this->appendContent( $tableRow );
}
}
diff --git a/Echo/includes/schemaUpdate.php b/Echo/includes/schemaUpdate.php
index 4bc58daa..4ca3a8ae 100644
--- a/Echo/includes/schemaUpdate.php
+++ b/Echo/includes/schemaUpdate.php
@@ -55,7 +55,7 @@ class EchoSuppressionRowUpdateGenerator implements RowUpdateGenerator {
$update = [];
$title = $this->newTitleFromNsAndText( $row->event_page_namespace, $row->event_page_title );
if ( $title !== null ) {
- $pageId = $title->getArticleId();
+ $pageId = $title->getArticleID();
if ( $pageId ) {
// If the title has a proper id from the database, store it
$update['event_page_id'] = $pageId;
@@ -85,13 +85,13 @@ class EchoSuppressionRowUpdateGenerator implements RowUpdateGenerator {
protected function updatePageLinkedExtraData( $row, array $update ) {
$extra = $this->extra( $row, $update );
- if ( isset( $extra['link-from-title'], $extra['link-from-namespace'] ) ) {
+ if ( isset( $extra['link-from-title'] ) && isset( $extra['link-from-namespace'] ) ) {
$title = $this->newTitleFromNsAndText( $extra['link-from-namespace'], $extra['link-from-title'] );
unset( $extra['link-from-title'], $extra['link-from-namespace'] );
// Link from page is always from a content page, if null or no article id it was
// somehow invalid
- if ( $title !== null && $title->getArticleId() ) {
- $extra['link-from-page-id'] = $title->getArticleId();
+ if ( $title !== null && $title->getArticleID() ) {
+ $extra['link-from-page-id'] = $title->getArticleID();
}
$update['event_extra'] = serialize( $extra );
@@ -112,7 +112,9 @@ class EchoSuppressionRowUpdateGenerator implements RowUpdateGenerator {
protected function extra( $row, array $update = [] ) {
if ( isset( $update['event_extra'] ) ) {
return unserialize( $update['event_extra'] );
- } elseif ( $row->event_extra ) {
+ }
+
+ if ( $row->event_extra ) {
return unserialize( $row->event_extra );
}
diff --git a/Echo/includes/special/NotificationPager.php b/Echo/includes/special/NotificationPager.php
index 9aa85cd8..2dfc1238 100644
--- a/Echo/includes/special/NotificationPager.php
+++ b/Echo/includes/special/NotificationPager.php
@@ -6,25 +6,28 @@
* It paginates on notification_event for a specific user, only for the enabled event types.
*/
class NotificationPager extends ReverseChronologicalPager {
- public function __construct() {
+ /**
+ * @param IContextSource $context
+ */
+ public function __construct( IContextSource $context ) {
$dbFactory = MWEchoDbFactory::newFromDefault();
$this->mDb = $dbFactory->getEchoDb( DB_REPLICA );
- parent::__construct();
+ parent::__construct( $context );
}
- function formatRow( $row ) {
- $msg = "This pager does not support row formatting. Use 'getNotifications()' to get a list of EchoNotification objects.";
- throw new Exception( $msg );
+ public function formatRow( $row ) {
+ throw new Exception( "This pager does not support row formatting. " .
+ "Use 'getNotifications()' to get a list of EchoNotification objects." );
}
- function getQueryInfo() {
+ public function getQueryInfo() {
$attributeManager = EchoAttributeManager::newFromGlobalVars();
$eventTypes = $attributeManager->getUserEnabledEvents( $this->getUser(), 'web' );
return [
'tables' => [ 'echo_notification', 'echo_event' ],
- 'fields' => '*',
+ 'fields' => EchoNotification::selectFields(),
'conds' => [
'notification_user' => $this->getUser()->getId(),
'event_type' => $eventTypes,
@@ -63,7 +66,7 @@ class NotificationPager extends ReverseChronologicalPager {
return $notifications;
}
- function getIndexField() {
+ public function getIndexField() {
return 'notification_event';
}
}
diff --git a/Echo/includes/special/SpecialDisplayNotificationsConfiguration.php b/Echo/includes/special/SpecialDisplayNotificationsConfiguration.php
index f5d71e24..c644c043 100644
--- a/Echo/includes/special/SpecialDisplayNotificationsConfiguration.php
+++ b/Echo/includes/special/SpecialDisplayNotificationsConfiguration.php
@@ -84,7 +84,8 @@ class SpecialDisplayNotificationsConfiguration extends UnlistedSpecialPage {
)
)->parse();
- $this->categoryNames[$internalCategoryName] = $formattedFriendlyCategoryName . ' ' . $formattedInternalCategoryName;
+ $this->categoryNames[$internalCategoryName] = $formattedFriendlyCategoryName . ' '
+ . $formattedInternalCategoryName;
}
$this->flippedCategoryNames = array_flip( $this->categoryNames );
@@ -128,7 +129,13 @@ class SpecialDisplayNotificationsConfiguration extends UnlistedSpecialPage {
* @param array $columnLabelMapping Associative array mapping label to tag
* @param array $value Array consisting of strings in the format '$columnTag-$rowTag'
*/
- protected function outputCheckMatrix( $id, $legendMsgKey, array $rowLabelMapping, array $columnLabelMapping, array $value ) {
+ protected function outputCheckMatrix(
+ $id,
+ $legendMsgKey,
+ array $rowLabelMapping,
+ array $columnLabelMapping,
+ array $value
+ ) {
$form = new HTMLForm(
[
$id => [
@@ -174,7 +181,8 @@ class SpecialDisplayNotificationsConfiguration extends UnlistedSpecialPage {
Html::rawElement(
'li',
[],
- $this->categoryNames[$categoryName] . $this->msg( 'colon-separator' )->escaped() . ' ' . $implodedTypes
+ $this->categoryNames[$categoryName] . $this->msg( 'colon-separator' )->escaped() . ' '
+ . $implodedTypes
)
);
}
@@ -199,7 +207,8 @@ class SpecialDisplayNotificationsConfiguration extends UnlistedSpecialPage {
$types = $this->attributeManager->getEventsForSection( $section );
// echo-notification-alert-text-only, echo-notification-notice-text-only
$msgSection = $section == 'message' ? 'notice' : $section;
- $flippedSectionNames[$this->msg( 'echo-notification-' . $msgSection . '-text-only' )->escaped()] = $section;
+ $flippedSectionNames[$this->msg( 'echo-notification-' . $msgSection . '-text-only' )->escaped()]
+ = $section;
foreach ( $types as $type ) {
$bySectionValue[] = "$section-$type";
}
@@ -218,8 +227,6 @@ class SpecialDisplayNotificationsConfiguration extends UnlistedSpecialPage {
* Output which notify types are available for each category
*/
protected function outputAvailability() {
- global $wgEchoNotifications;
-
$this->getOutput()->addHTML( Html::element(
'h2',
[ 'id' => 'mw-echo-displaynotificationsconfiguration-available-notification-methods' ],
@@ -243,27 +250,6 @@ class SpecialDisplayNotificationsConfiguration extends UnlistedSpecialPage {
$this->flippedNotifyTypes,
$byCategoryValue
);
-
- $byTypeValue = [];
-
- $specialNotificationTypes = array_keys( array_filter( $wgEchoNotifications, function ( $val ) {
- return isset( $val['notify-type-availability'] );
- } ) );
- foreach ( $specialNotificationTypes as $notificationType ) {
- $allowedNotifyTypes = $this->notificationController->getEventNotifyTypes( $notificationType );
- foreach ( $allowedNotifyTypes as $notifyType ) {
- $byTypeValue[] = "$notifyType-$notificationType";
- }
- }
-
- // No user-friendly label for rows yet
- $this->outputCheckMatrix(
- 'availability-by-type',
- 'echo-displaynotificationsconfiguration-available-notification-methods-by-type-legend',
- array_combine( $specialNotificationTypes, $specialNotificationTypes ),
- $this->flippedNotifyTypes,
- $byTypeValue
- );
}
/**
diff --git a/Echo/includes/special/SpecialNotifications.php b/Echo/includes/special/SpecialNotifications.php
index aae26e4b..3c775628 100644
--- a/Echo/includes/special/SpecialNotifications.php
+++ b/Echo/includes/special/SpecialNotifications.php
@@ -12,8 +12,7 @@ class SpecialNotifications extends SpecialPage {
}
/**
- * @param string $par
- * @suppress SecurityCheck-DoubleEscaped Different members of $notifArray being conflated
+ * @param string|null $par
*/
public function execute( $par ) {
$this->setHeaders();
@@ -24,7 +23,6 @@ class SpecialNotifications extends SpecialPage {
$this->addHelpLink( 'Help:Notifications/Special:Notifications' );
$out->addJsConfigVars( 'wgNotificationsSpecialPageLinks', [
- 'help' => '//www.mediawiki.org/wiki/Special:MyLanguage/Help:Notifications/Special:Notifications',
'preferences' => SpecialPage::getTitleFor( 'Preferences' )->getLinkURL() . '#mw-prefsection-echo',
] );
@@ -41,7 +39,7 @@ class SpecialNotifications extends SpecialPage {
$pager = new NotificationPager( $this->getContext() );
$pager->setOffset( $this->getRequest()->getVal( 'offset' ) );
- $pager->setLimit( $this->getRequest()->getVal( 'limit', self::DISPLAY_NUM ) );
+ $pager->setLimit( $this->getRequest()->getInt( 'limit', self::DISPLAY_NUM ) );
$notifications = $pager->getNotifications();
$noJSDiv = new OOUI\Tag();
@@ -124,6 +122,7 @@ class SpecialNotifications extends SpecialPage {
// Ensure there are some unread notifications
if ( $anyUnread ) {
$markReadSpecialPage = new SpecialNotificationsMarkRead();
+ $markReadSpecialPage->setContext( $this->getContext() );
$markAllAsReadText = $this->msg( 'echo-mark-all-as-read' )->text();
$markAllAsReadLabelIcon = new EchoOOUI\LabelIconWidget( [
@@ -151,8 +150,8 @@ class SpecialNotifications extends SpecialPage {
$notices->addClasses( [ 'mw-echo-special-notifications' ] );
$markReadSpecialPage = new SpecialNotificationsMarkRead();
+ $markReadSpecialPage->setContext( $this->getContext() );
foreach ( $notifArray as $section => $data ) {
- // Heading
$heading = ( new OOUI\Tag( 'li' ) )->addClasses( [ 'mw-echo-date-section' ] );
$dateTitle = new OOUI\LabelWidget( [
@@ -232,8 +231,8 @@ class SpecialNotifications extends SpecialPage {
$out->addModuleStyles( [
'ext.echo.styles.notifications',
'ext.echo.styles.special',
- // We already load badgeicons in the BeforePageDisplay hook, but not for minerva
- 'ext.echo.badgeicons'
+ // We already load OOUI icons in the BeforePageDisplay hook, but not for minerva
+ 'oojs-ui.styles.icons-alerts'
] );
// Log visit
diff --git a/Echo/includes/special/SpecialNotificationsMarkRead.php b/Echo/includes/special/SpecialNotificationsMarkRead.php
index 1fb873a8..bac4d8a8 100644
--- a/Echo/includes/special/SpecialNotificationsMarkRead.php
+++ b/Echo/includes/special/SpecialNotificationsMarkRead.php
@@ -103,7 +103,9 @@ class SpecialNotificationsMarkRead extends FormSpecialPage {
// manually.
$form->suppressDefaultSubmit();
- $form->setAction( $this->getPageTitle()->getLocalURL() );
+ $pageTitle = $this->getPageTitle();
+ $form->setTitle( $pageTitle );
+ $form->setAction( $pageTitle->getLocalURL() );
$form->addButton( [
'name' => 'submit',
@@ -146,6 +148,6 @@ class SpecialNotificationsMarkRead extends FormSpecialPage {
public function onSuccess() {
$page = SpecialPage::getTitleFor( 'Notifications' );
- $this->getOutput()->redirect( $page->getFullUrl() );
+ $this->getOutput()->redirect( $page->getFullURL() );
}
}