diff options
Diffstat (limited to 'Echo/includes')
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×tampFormat=MW' ); + $this->addDeprecation( + 'apiwarn-echo-deprecation-timestampformat', + 'action=echomarkseen×tampFormat=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() ); } } |