summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'Echo/includes')
-rw-r--r--Echo/includes/AttributeManager.php258
-rw-r--r--Echo/includes/BatchRowUpdate.php454
-rw-r--r--Echo/includes/Bundleable.php29
-rw-r--r--Echo/includes/Bundler.php48
-rw-r--r--Echo/includes/ContainmentSet.php90
-rw-r--r--Echo/includes/DataOutputFormatter.php125
-rw-r--r--Echo/includes/DeferredMarkAsDeletedUpdate.php72
-rw-r--r--Echo/includes/DeferredMarkAsReadUpdate.php39
-rw-r--r--Echo/includes/DiffParser.php194
-rw-r--r--Echo/includes/DiscussionParser.php796
-rw-r--r--Echo/includes/EchoDbFactory.php98
-rw-r--r--Echo/includes/EmailBatch.php175
-rw-r--r--Echo/includes/EmailBundler.php303
-rw-r--r--Echo/includes/EmailFormat.php6
-rw-r--r--Echo/includes/EmailFormatter.php858
-rw-r--r--Echo/includes/EmailFrequency.php8
-rw-r--r--Echo/includes/EventLogging.php81
-rw-r--r--Echo/includes/ForeignNotifications.php254
-rw-r--r--Echo/includes/ForeignWikiRequest.php164
-rw-r--r--Echo/includes/NotifUser.php611
-rw-r--r--Echo/includes/Notifier.php135
-rw-r--r--Echo/includes/ResourceLoaderEchoImageModule.php62
-rw-r--r--Echo/includes/SeenTime.php119
-rw-r--r--Echo/includes/UnreadWikis.php144
-rw-r--r--Echo/includes/UserLocator.php47
-rw-r--r--Echo/includes/api/ApiCrossWikiBase.php126
-rw-r--r--Echo/includes/api/ApiEchoArticleReminder.php112
-rw-r--r--Echo/includes/api/ApiEchoMarkRead.php76
-rw-r--r--Echo/includes/api/ApiEchoMarkSeen.php65
-rw-r--r--Echo/includes/api/ApiEchoNotifications.php650
-rw-r--r--Echo/includes/api/ApiEchoUnreadNotificationPages.php212
-rw-r--r--Echo/includes/cache/LocalCache.php12
-rw-r--r--Echo/includes/cache/RevisionLocalCache.php15
-rw-r--r--Echo/includes/cache/TitleLocalCache.php5
-rw-r--r--Echo/includes/controller/ModerationController.php41
-rw-r--r--Echo/includes/controller/NotificationController.php360
-rw-r--r--Echo/includes/formatters/ArticleReminderPresentationModel.php30
-rw-r--r--Echo/includes/formatters/BasicFormatter.php925
-rw-r--r--Echo/includes/formatters/CommentFormatter.php34
-rw-r--r--Echo/includes/formatters/EchoEventDigestFormatter.php53
-rw-r--r--Echo/includes/formatters/EchoEventFormatter.php71
-rw-r--r--Echo/includes/formatters/EchoFlyoutFormatter.php78
-rw-r--r--Echo/includes/formatters/EchoForeignPresentationModel.php49
-rw-r--r--Echo/includes/formatters/EchoHtmlDigestEmailFormatter.php226
-rw-r--r--Echo/includes/formatters/EchoHtmlEmailFormatter.php159
-rw-r--r--Echo/includes/formatters/EchoIcon.php88
-rw-r--r--Echo/includes/formatters/EchoModelFormatter.php31
-rw-r--r--Echo/includes/formatters/EchoPlainTextDigestEmailFormatter.php78
-rw-r--r--Echo/includes/formatters/EchoPlainTextEmailFormatter.php53
-rw-r--r--Echo/includes/formatters/EditFormatter.php87
-rw-r--r--Echo/includes/formatters/EditThresholdPresentationModel.php22
-rw-r--r--Echo/includes/formatters/EditUserTalkFormatter.php42
-rw-r--r--Echo/includes/formatters/EditUserTalkPresentationModel.php90
-rw-r--r--Echo/includes/formatters/EmailUserPresentationModel.php21
-rw-r--r--Echo/includes/formatters/EventPresentationModel.php678
-rw-r--r--Echo/includes/formatters/MentionFormatter.php27
-rw-r--r--Echo/includes/formatters/MentionPresentationModel.php129
-rw-r--r--Echo/includes/formatters/MentionStatusPresentationModel.php142
-rw-r--r--Echo/includes/formatters/NotificationFormatter.php190
-rw-r--r--Echo/includes/formatters/PageLinkFormatter.php199
-rw-r--r--Echo/includes/formatters/PageLinkedPresentationModel.php114
-rw-r--r--Echo/includes/formatters/PresentationModelSectionTrait.php96
-rw-r--r--Echo/includes/formatters/RevertedPresentationModel.php85
-rw-r--r--Echo/includes/formatters/SpecialNotificationsFormatter.php113
-rw-r--r--Echo/includes/formatters/UserRightsFormatter.php71
-rw-r--r--Echo/includes/formatters/UserRightsPresentationModel.php110
-rw-r--r--Echo/includes/formatters/WelcomePresentationModel.php25
-rw-r--r--Echo/includes/gateway/UserNotificationGateway.php95
-rw-r--r--Echo/includes/iterator/CallbackFilterIterator.php24
-rw-r--r--Echo/includes/iterator/FilteredSequentialIterator.php60
-rw-r--r--Echo/includes/iterator/MultipleIterator.php5
-rw-r--r--Echo/includes/jobs/NotificationDeleteJob.php15
-rw-r--r--Echo/includes/jobs/NotificationEmailBundleJob.php26
-rw-r--r--Echo/includes/jobs/NotificationJob.php12
-rw-r--r--Echo/includes/mapper/AbstractMapper.php8
-rw-r--r--Echo/includes/mapper/EventMapper.php173
-rw-r--r--Echo/includes/mapper/NotificationMapper.php356
-rw-r--r--Echo/includes/mapper/TargetPageMapper.php126
-rw-r--r--Echo/includes/model/Event.php244
-rw-r--r--Echo/includes/model/Notification.php138
-rw-r--r--Echo/includes/model/TargetPage.php68
-rw-r--r--Echo/includes/ooui/LabelIconWidget.php44
-rw-r--r--Echo/includes/schemaUpdate.php48
-rw-r--r--Echo/includes/special/NotificationPager.php71
-rw-r--r--Echo/includes/special/SpecialDisplayNotificationsConfiguration.php302
-rw-r--r--Echo/includes/special/SpecialNotifications.php276
-rw-r--r--Echo/includes/special/SpecialNotificationsMarkRead.php152
87 files changed, 8057 insertions, 5146 deletions
diff --git a/Echo/includes/AttributeManager.php b/Echo/includes/AttributeManager.php
index 312e0d62..d918f196 100644
--- a/Echo/includes/AttributeManager.php
+++ b/Echo/includes/AttributeManager.php
@@ -17,20 +17,48 @@ class EchoAttributeManager {
protected $categories;
/**
+ * @var array
+ */
+ protected $defaultNotifyTypeAvailability;
+
+ /**
+ * @var array
+ */
+ protected $notifyTypeAvailabilityByCategory;
+
+ /**
+ * @var array
+ */
+ protected $dismissabilityByCategory;
+
+ /**
+ * @var array
+ */
+ protected $notifiers;
+
+ /**
* Notification section constant
*/
const ALERT = 'alert';
const MESSAGE = 'message';
const ALL = 'all';
+ protected static $DEFAULT_SECTION = self::ALERT;
+
/**
* Notifications are broken down to two sections, default is alert
* @var array
*/
- public static $sections = array (
+ public static $sections = [
self::ALERT,
self::MESSAGE
- );
+ ];
+
+ /**
+ * Names for keys in $wgEchoNotifications notification config
+ */
+ const ATTR_LOCATORS = 'user-locators';
+ const ATTR_FILTERS = 'user-filters';
/**
* An EchoAttributeManager instance created from global variables
@@ -39,13 +67,27 @@ class EchoAttributeManager {
protected static $globalVarInstance = null;
/**
- * @param array $notifications notification attributes
- * @param array $categories notification categories
+ * @param array $notifications Notification attributes
+ * @param array $categories Notification categories
+ * @param array $defaultNotifyTypeAvailability Associative array with output
+ * formats as keys and whether they are available as boolean values.
+ * @param array $notifyTypeAvailabilityByCategory Associative array with
+ * categories as keys and value an associative array as with
+ * $defaultNotifyTypeAvailability.
+ * @param array $notifiers Associative array mapping notify types to notifier
+ * that handles them
*/
- public function __construct( array $notifications, array $categories ) {
+ public function __construct( array $notifications, array $categories, array $defaultNotifyTypeAvailability, array $notifyTypeAvailabilityByCategory, array $notifiers ) {
// Extensions can define their own notifications and categories
$this->notifications = $notifications;
$this->categories = $categories;
+
+ $this->defaultNotifyTypeAvailability = $defaultNotifyTypeAvailability;
+ $this->notifyTypeAvailabilityByCategory = $notifyTypeAvailabilityByCategory;
+
+ $this->dismissabilityByCategory = null;
+
+ $this->notifiers = $notifiers;
}
/**
@@ -53,52 +95,57 @@ class EchoAttributeManager {
* @return EchoAttributeManager
*/
public static function newFromGlobalVars() {
- global $wgEchoNotifications, $wgEchoNotificationCategories;
+ global $wgEchoNotifications, $wgEchoNotificationCategories, $wgDefaultNotifyTypeAvailability, $wgNotifyTypeAvailabilityByCategory, $wgEchoNotifiers;
// Unit test may alter the global data for test purpose
- if ( defined( 'MW_PHPUNIT_TEST' ) && MW_PHPUNIT_TEST ) {
- return new self( $wgEchoNotifications, $wgEchoNotificationCategories );
+ if ( defined( 'MW_PHPUNIT_TEST' ) ) {
+ return new self( $wgEchoNotifications, $wgEchoNotificationCategories, $wgDefaultNotifyTypeAvailability, $wgNotifyTypeAvailabilityByCategory, $wgEchoNotifiers );
}
if ( self::$globalVarInstance === null ) {
self::$globalVarInstance = new self(
$wgEchoNotifications,
- $wgEchoNotificationCategories
+ $wgEchoNotificationCategories,
+ $wgDefaultNotifyTypeAvailability,
+ $wgNotifyTypeAvailabilityByCategory,
+ $wgEchoNotifiers
);
}
+
return self::$globalVarInstance;
}
/**
- * Get the user-locators related to the provided event type
+ * Get the user-locators|user-filters related to the provided event type
*
* @param string $type
+ * @param string $locator Either self::ATTR_LOCATORS or self::ATTR_FILTERS
* @return array
*/
- public function getUserLocators( $type ) {
- if ( isset( $this->notifications[$type]['user-locators'] ) ) {
- return (array)$this->notifications[$type]['user-locators'];
+ public function getUserCallable( $type, $locator = self::ATTR_LOCATORS ) {
+ if ( isset( $this->notifications[$type][$locator] ) ) {
+ return (array)$this->notifications[$type][$locator];
} else {
- return array();
+ return [];
}
}
/**
* Get the enabled events for a user, which excludes user-dismissed events
* from the general enabled events
- * @param User
- * @param string web/email
+ * @param User $user
+ * @param string $notifyType Either "web" or "email".
* @return string[]
*/
- public function getUserEnabledEvents( User $user, $outputFormat ) {
+ 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 recieve this type of notification
+ // 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-' . $outputFormat . '-' . $category ) ) {
+ if ( !$user->getOption( 'echo-subscriptions-' . $notifyType . '-' . $category ) ) {
unset( $eventTypesToLoad[$eventType] );
}
}
@@ -108,71 +155,79 @@ class EchoAttributeManager {
}
/**
- * Get the uesr enabled events for the specified sections
- * @param User
- * @param string
- * @param string[]
+ * Get the user enabled events for the specified sections
+ * @param User $user
+ * @param string $notifyType Either "web" or "email".
+ * @param string[] $sections
* @return string[]
*/
- public function getUserEnabledEventsbySections( User $user, $outputFormat, array $sections ) {
- $events = array();
+ public function getUserEnabledEventsbySections( User $user, $notifyType, array $sections ) {
+ $events = [];
foreach ( $sections as $section ) {
$events = array_merge(
$events,
- call_user_func(
- array( $this, 'get' . ucfirst( $section ) . 'Events' )
- )
+ $this->getEventsForSection( $section )
);
}
+
return array_intersect(
- $this->getUserEnabledEvents( $user, $outputFormat ),
+ $this->getUserEnabledEvents( $user, $notifyType ),
$events
);
}
/**
- * Get alert notification event. Notifications without a section attributes
- * default to section alert
- * @return array
+ * Gets events (notification types) for a given section
+ *
+ * @param string $section Internal section name, one of the values from self::$sections
+ *
+ * @return array Array of notification types in this section
*/
- public function getAlertEvents() {
- $events = array();
+ public function getEventsForSection( $section ) {
+ $events = [];
+
+ $isDefault = ( $section === self::$DEFAULT_SECTION );
+
foreach ( $this->notifications as $event => $attribs ) {
if (
- !isset( $attribs['section'] )
- || !in_array( $attribs['section'], self::$sections )
- || $attribs['section'] === 'alert'
+ (
+ isset( $attribs['section'] ) &&
+ $attribs['section'] === $section
+ ) ||
+ (
+ $isDefault &&
+ (
+ !isset( $attribs['section'] ) ||
+
+ // Invalid section
+ !in_array( $attribs['section'], self::$sections )
+ )
+ )
+
) {
$events[] = $event;
}
}
+
return $events;
}
/**
- * Get message notification event
- * @return array
+ * Gets array of internal category names
+ *
+ * @return array All internal names
*/
- public function getMessageEvents() {
- $events = array();
- foreach ( $this->notifications as $event => $attribs ) {
- if (
- isset( $attribs['section'] )
- && $attribs['section'] === 'message'
- ) {
- $events[] = $event;
- }
- }
- return $events;
+ public function getInternalCategoryNames() {
+ return array_keys( $this->categories );
}
/**
- * See if a user is eligible to recieve a certain type of notification
+ * See if a user is eligible to receive a certain type of notification
* (based on user groups, not user preferences)
*
- * @param User
- * @param string A notification category defined in $wgEchoNotificationCategories
- * @return boolean
+ * @param User $user
+ * @param string $category A notification category defined in $wgEchoNotificationCategories
+ * @return bool
*/
public function getCategoryEligibility( $user, $category ) {
$usersGroups = $user->getGroups();
@@ -182,25 +237,27 @@ class EchoAttributeManager {
return false;
}
}
+
return true;
}
/**
* Get the priority for a specific notification type
*
- * @param string A notification type defined in $wgEchoNotifications
- * @return integer From 1 to 10 (10 is default)
+ * @param string $notificationType A notification type defined in $wgEchoNotifications
+ * @return int From 1 to 10 (10 is default)
*/
public function getNotificationPriority( $notificationType ) {
$category = $this->getNotificationCategory( $notificationType );
+
return $this->getCategoryPriority( $category );
}
/**
* Get the priority for a notification category
*
- * @param string A notification category defined in $wgEchoNotificationCategories
- * @return integer From 1 to 10 (10 is default)
+ * @param string $category A notification category defined in $wgEchoNotificationCategories
+ * @return int From 1 to 10 (10 is default)
*/
public function getCategoryPriority( $category ) {
if ( isset( $this->categories[$category]['priority'] ) ) {
@@ -209,13 +266,14 @@ class EchoAttributeManager {
return $priority;
}
}
+
return 10;
}
/**
* Get the notification category for a notification type
*
- * @param string A notification type defined in $wgEchoNotifications
+ * @param string $notificationType A notification type defined in $wgEchoNotifications
* @return string The name of the notification category or 'other' if no
* category is explicitly assigned.
*/
@@ -226,19 +284,99 @@ class EchoAttributeManager {
return $category;
}
}
+
return 'other';
}
/**
+ * Gets an associative array mapping categories to the notification types in
+ * the category
+ *
+ * @return array Associative array with category as key
+ */
+ public function getEventsByCategory() {
+ $eventsByCategory = [];
+
+ foreach ( $this->categories as $category => $categoryDetails ) {
+ $eventsByCategory[$category] = [];
+ }
+
+ foreach ( $this->notifications as $notificationType => $notificationDetails ) {
+ $category = $notificationDetails['category'];
+ if ( isset( $eventsByCategory[$category] ) ) {
+ // Only real categories. Currently, this excludes the 'foreign'
+ // psuedo-category.
+ $eventsByCategory[$category][] = $notificationType;
+ }
+ }
+
+ return $eventsByCategory;
+ }
+
+ /**
+ * Checks whether the specified notify type is available for the specified
+ * 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
+ * @param string $notifyType notify type, e.g. email/web.
+ * @return bool
+ */
+ public function isNotifyTypeAvailableForCategory( $category, $notifyType ) {
+ if ( isset( $this->notifyTypeAvailabilityByCategory[$category][$notifyType] ) ) {
+ return $this->notifyTypeAvailabilityByCategory[$category][$notifyType];
+ } else {
+ return $this->defaultNotifyTypeAvailability[$notifyType];
+ }
+ }
+
+ /**
+ * Checks whether category is displayed in preferences
+ *
+ * @param string $category Category name
+ * @return bool
+ */
+ public function isCategoryDisplayedInPreferences( $category ) {
+ return !(
+ isset( $this->categories[$category]['no-dismiss'] ) &&
+ in_array( 'all', $this->categories[$category]['no-dismiss'] )
+ );
+ }
+
+ /**
+ * Checks whether the specified notify type is dismissable for the specified
+ * category.
+ *
+ * This means whether the user is allowed to opt out of receiving notifications
+ * for this category and format.
+ *
+ * @param string $category Name of category
+ * @param string $notifyType notify type, e.g. email/web.
+ * @return bool
+ */
+ public function isNotifyTypeDismissableForCategory( $category, $notifyType ) {
+ return !(
+ isset( $this->categories[$category]['no-dismiss'] ) &&
+ (
+ in_array( 'all', $this->categories[$category]['no-dismiss'] ) ||
+ in_array( $notifyType, $this->categories[$category]['no-dismiss'] )
+ )
+ );
+ }
+
+ /**
* Get notification section for a notification type
* @todo add a unit test case
- * @parm string
+ * @param string $notificationType
* @return string
*/
public function getNotificationSection( $notificationType ) {
if ( isset( $this->notifications[$notificationType]['section'] ) ) {
return $this->notifications[$notificationType]['section'];
}
+
return 'alert';
}
diff --git a/Echo/includes/BatchRowUpdate.php b/Echo/includes/BatchRowUpdate.php
deleted file mode 100644
index 45f0e1a5..00000000
--- a/Echo/includes/BatchRowUpdate.php
+++ /dev/null
@@ -1,454 +0,0 @@
-<?php
-/**
- * Provides components to update a tables rows via a batching process
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup Maintenance
- */
-
-/**
- * Ties together the batch update components to provide a composable method
- * of batch updating rows in a database. To use create a class implementing
- * the EchoRowUpdateGenerator interface and configure the EchoBatchRowIterator and
- * EchoBatchRowWriter for access to the correct table. The components will
- * handle reading, writing, and waiting for slaves while the generator implementation
- * handles generating update arrays for singular rows.
- *
- * Instantiate:
- * $updater = new EchoBatchRowUpdate(
- * new EchoBatchRowIterator( $dbr, 'some_table', 'primary_key_column', 500 ),
- * new EchoBatchRowWriter( $dbw, 'some_table', 'clusterName' ),
- * new MyImplementationOfEchoRowUpdateGenerator
- * );
- *
- * Run:
- * $updater->execute();
- *
- * An example maintenance script utilizing the EchoBatchRowUpdate can be located in the Echo
- * extension file maintenance/updateSchema.php
- *
- * @ingroup Maintenance
- */
-class EchoBatchRowUpdate {
- /**
- * @var EchoBatchRowIterator $reader Iterator that returns an array of database rows
- */
- protected $reader;
-
- /**
- * @var EchoBatchRowWriter $writer Writer capable of pushing row updates to the database
- */
- protected $writer;
-
- /**
- * @var EchoRowUpdateGenerator $generator Generates single row updates based on the rows content
- */
- protected $generator;
-
- /**
- * @var callable $output Output callback
- */
- protected $output;
-
- /**
- * @param EchoBatchRowIterator $reader Iterator that returns an array of database rows
- * @param EchoBatchRowWriter $writer Writer capable of pushing row updates to the database
- * @param EchoRowUpdateGenerator $generator Generates single row updates based on the rows content
- */
- public function __construct( EchoBatchRowIterator $reader, EchoBatchRowWriter $writer, EchoRowUpdateGenerator $generator ) {
- $this->reader = $reader;
- $this->writer = $writer;
- $this->generator = $generator;
- $this->output = function() {
- }; // nop
- }
-
- /**
- * Runs the batch update process
- */
- public function execute() {
- foreach ( $this->reader as $rows ) {
- $updates = array();
- foreach ( $rows as $row ) {
- $update = $this->generator->update( $row );
- if ( $update ) {
- $updates[] = array(
- 'primaryKey' => $this->reader->extractPrimaryKeys( $row ),
- 'changes' => $update,
- );
- }
- }
-
- if ( $updates ) {
- $this->output( "Processing " . count( $updates ) . " rows\n" );
- $this->writer->write( $updates );
- }
- }
-
- $this->output( "Completed\n" );
- }
-
- /**
- * Accepts a callable which will receive a single parameter containing
- * string status updates
- *
- * @param callable $output A callback taking a single string parameter to output
- *
- * @throws MWException
- */
- public function setOutput( $output ) {
- if ( !is_callable( $output ) ) {
- throw new MWException( 'Provided $output param is required to be callable.' );
- }
- $this->output = $output;
- }
-
- /**
- * Write out a status update
- *
- * @param string $text The value to print
- */
- protected function output( $text ) {
- call_user_func( $this->output, $text );
- }
-}
-
-/**
- * Interface for generating updates to single rows in the database.
- *
- * @ingroup Maintenance
- */
-interface EchoRowUpdateGenerator {
-
- /**
- * Given a database row, generates an array mapping column names to updated value within the database row
- *
- * Sample Response:
- * return array(
- * 'some_col' => 'new value',
- * 'other_col' => 99,
- * );
- *
- * @param stdClass $row A row from the database
- * @return array Map of column names to updated value within the database row. When no update is required
- * returns an empty array.
- */
- public function update( $row );
-}
-
-/**
- * Updates database rows by primary key in batches. There are two options for writing to tables
- * with a composite primary key.
- *
- * @ingroup Maintenance
- */
-class EchoBatchRowWriter {
- /**
- * @var DatabaseBase $db The database to write to
- */
- protected $db;
-
- /**
- * @var string $table The name of the table to update
- */
- protected $table;
-
- /**
- * @var string $clusterName A cluster name valid for use with LBFactory
- */
- protected $clusterName;
-
- /**
- * @param DatabaseBase $db The database to write to
- * @param string $table The name of the table to update
- * @param string|bool $clusterName A cluster name valid for use with LBFactory
- */
- public function __construct( DatabaseBase $db, $table, $clusterName = false ) {
- $this->db = $db;
- $this->table = $table;
- $this->clusterName = $clusterName;
- }
-
- /**
- * @param array $updates Array of arrays each containing two keys, 'primaryKey' and 'changes'.
- * primaryKey must contain a map of column names to values sufficient to uniquely identify the row
- * changes must contain a map of column names to update values to apply to the row
- */
- public function write( array $updates ) {
- $this->db->begin();
-
- foreach ( $updates as $update ) {
- //echo "Updating: ";var_dump( $update['primaryKey'] );
- //echo "With values: ";var_dump( $update['changes'] );
- $this->db->update(
- $this->table,
- $update['changes'],
- $update['primaryKey'],
- __METHOD__
- );
- }
-
- $this->db->commit();
- wfWaitForSlaves( false, false, $this->clusterName );
- }
-}
-
-/**
- * Fetches rows batched into groups from the database in ascending order of the primary key(s).
- *
- * @ingroup Maintenance
- */
-class EchoBatchRowIterator implements RecursiveIterator {
-
- /**
- * @var DatabaseBase $db The database to read from
- */
- protected $db;
-
- /**
- * @var string $table The name of the table to read from
- */
- protected $table;
-
- /**
- * @var array $primaryKey The name of the primary key(s)
- */
- protected $primaryKey;
-
- /**
- * @var integer $batchSize The number of rows to fetch per iteration
- */
- protected $batchSize;
-
- /**
- * @var array $conditions Array of strings containing SQL conditions to add to the query
- */
- protected $conditions = array();
-
- /**
- * @var array $joinConditions
- */
- protected $joinConditions = array();
-
- /**
- * @var array $fetchColumns List of column names to select from the table suitable for use with DatabaseBase::select()
- */
- protected $fetchColumns;
-
- /**
- * @var string $orderBy SQL Order by condition generated from $this->primaryKey
- */
- protected $orderBy;
-
- /**
- * @var array $current The current iterator value
- */
- private $current = array();
-
- /**
- * @var integer key 0-indexed number of pages fetched since self::reset()
- */
- private $key;
-
- /**
- * @param DatabaseBase $db The database to read from
- * @param string $table The name of the table to read from
- * @param string|array $primaryKey The name or names of the primary key columns
- * @param integer $batchSize The number of rows to fetch per iteration
- *
- * @throws MWException
- */
- public function __construct( DatabaseBase $db, $table, $primaryKey, $batchSize ) {
- if ( $batchSize < 1 ) {
- throw new MWException( 'Batch size must be at least 1 row.' );
- }
- $this->db = $db;
- $this->table = $table;
- $this->primaryKey = (array) $primaryKey;
- $this->fetchColumns = $this->primaryKey;
- $this->orderBy = implode( ' ASC,', $this->primaryKey ) . ' ASC';
- $this->batchSize = $batchSize;
- }
-
- /**
- * @param string $condition Query conditions suitable for use with DatabaseBase::select
- */
- public function addConditions( array $conditions ) {
- $this->conditions = array_merge( $this->conditions, $conditions );
- }
-
- public function addJoinConditions( array $conditions ) {
- $this->joinConditions = array_merge( $this->joinConditions, $conditions );
- }
-
- /**
- * @param array $columns List of column names to select from the table suitable for use with DatabaseBase::select()
- */
- public function setFetchColumns( array $columns ) {
- // If it's not the all column selector merge in the primary keys we need
- if ( count( $columns ) === 1 && reset( $columns ) === '*' ) {
- $this->fetchColumns = $columns;
- } else {
- $this->fetchColumns = array_unique( array_merge( $this->primaryKey, $columns ) );
- }
- }
-
- /**
- * Extracts the primary key(s) from a database row.
- *
- * @param stdClass $row An individual database row from this iterator
- * @return array Map of primary key column to value within the row
- */
- public function extractPrimaryKeys( $row ) {
- $pk = array();
- foreach ( $this->primaryKey as $column ) {
- $pk[$column] = $row->$column;
- }
- return $pk;
- }
-
- /**
- * @return array The most recently fetched set of rows from the database
- */
- public function current() {
- return $this->current;
- }
-
- /**
- * @return integer 0-indexed count of the page number fetched
- */
- public function key() {
- return $this->key;
- }
-
- /**
- * Reset the iterator to the begining of the table.
- */
- public function rewind() {
- $this->key = -1; // self::next() will turn this into 0
- $this->current = array();
- $this->next();
- }
-
- /**
- * @return boolean True when the iterator is in a valid state
- */
- public function valid() {
- return (bool) $this->current;
- }
-
- /**
- * @return boolean True when this result set has rows
- */
- public function hasChildren() {
- return $this->current && count( $this->current );
- }
-
- /**
- * @return RecursiveIterator
- */
- public function getChildren() {
- return new EchoNotRecursiveIterator( new ArrayIterator( $this->current ) );
- }
-
- /**
- * Fetch the next set of rows from the database.
- */
- public function next() {
- $res = $this->db->select(
- $this->table,
- $this->fetchColumns,
- $this->buildConditions(),
- __METHOD__,
- array(
- 'LIMIT' => $this->batchSize,
- 'ORDER BY' => $this->orderBy,
- ),
- $this->joinConditions
- );
-
- // The iterator is converted to an array because in addition to returning it
- // in self::current() we need to use the end value in self::buildConditions()
- $this->current = iterator_to_array( $res );
- $this->key++;
- }
-
- /**
- * Uses the primary key list and the maximal result row from the previous iteration to build
- * an SQL condition sufficient for selecting the next page of results. All except the final
- * key use `=` conditions while the final key uses a `>` condition
- *
- * Example output:
- * array( '( foo = 42 AND bar > 7 ) OR ( foo > 42 )' )
- *
- * @return array The SQL conditions necessary to select the next set of rows in the batched query
- */
- protected function buildConditions() {
- if ( !$this->current ) {
- return $this->conditions;
- }
-
- $maxRow = end( $this->current );
- $maximumValues = array();
- foreach ( $this->primaryKey as $column ) {
- $maximumValues[$column] = $this->db->addQuotes( $maxRow->$column );
- }
-
- $pkConditions = array();
- // For example: If we have 3 primary keys
- // first run through will generate
- // col1 = 4 AND col2 = 7 AND col3 > 1
- // second run through will generate
- // col1 = 4 AND col2 > 7
- // and the final run through will generate
- // col1 > 4
- while ( $maximumValues ) {
- $pkConditions[] = $this->buildGreaterThanCondition( $maximumValues );
- array_pop( $maximumValues );
- }
-
- $conditions = $this->conditions;
- $conditions[] = sprintf( '( %s )', implode( ' ) OR ( ', $pkConditions ) );
-
- return $conditions;
- }
-
- /**
- * Given an array of column names and their maximum value generate an SQL
- * condition where all keys except the last match $quotedMaximumValues
- * exactly and the last column is greater than the matching value in $quotedMaximumValues
- *
- * @param array $quotedMaximumValues The maximum values quoted with $this->db->addQuotes()
- * @return string An SQL condition that will select rows where all columns match the
- * maximum value exactly except the last column which must be greater than the provided
- * maximum value
- */
- protected function buildGreaterThanCondition( array $quotedMaximumValues ) {
- $keys = array_keys( $quotedMaximumValues );
- $lastColumn = end( $keys );
- $lastValue = array_pop( $quotedMaximumValues );
- $conditions = array();
- foreach ( $quotedMaximumValues as $column => $value ) {
- $conditions[] = "$column = $value";
- }
- $conditions[] = "$lastColumn > $lastValue";
-
- return implode( ' AND ', $conditions );
- }
-}
-
diff --git a/Echo/includes/Bundleable.php b/Echo/includes/Bundleable.php
new file mode 100644
index 00000000..2469027f
--- /dev/null
+++ b/Echo/includes/Bundleable.php
@@ -0,0 +1,29 @@
+<?php
+
+/**
+ * Interface Bundleable
+ *
+ * Indicates that an object can be bundled.
+ */
+interface Bundleable {
+
+ /**
+ * @return bool Whether this object can be bundled.
+ */
+ public function canBeBundled();
+
+ /**
+ * @return string objects with the same bundling key can be bundled together
+ */
+ public function getBundlingKey();
+
+ /**
+ * @param Bundleable[] $bundleables other object that have been bundled with this one
+ */
+ public function setBundledElements( $bundleables );
+
+ /**
+ * @return mixed the key by which this object should be sorted during the bundling process
+ */
+ public function getSortingKey();
+}
diff --git a/Echo/includes/Bundler.php b/Echo/includes/Bundler.php
new file mode 100644
index 00000000..5c162687
--- /dev/null
+++ b/Echo/includes/Bundler.php
@@ -0,0 +1,48 @@
+<?php
+
+class Bundler {
+
+ private function sort( &$array ) {
+ // We have to ignore the error here (use @usort)
+ // otherwise this code fails when executed by unit tests
+ // See: https://bugs.php.net/bug.php?id=50688
+
+ // @codingStandardsIgnoreStart
+ @usort( $array, function( Bundleable $a, Bundleable $b ) {
+ return strcmp( $b->getSortingKey(), $a->getSortingKey() );
+ } );
+ // @codingStandardsIgnoreEnd
+ }
+
+ /**
+ * Bundle bundleable elements that can be bundled by their bundling keys
+ *
+ * @param Bundleable[] $bundleables
+ * @return Bundleable[] Grouped notifications sorted by timestamp DESC
+ */
+ public function bundle( $bundleables ) {
+ $groups = [];
+ $bundled = [];
+
+ /** @var Bundleable $element */
+ foreach ( $bundleables as $element ) {
+ if ( $element->canBeBundled() && $element->getBundlingKey() ) {
+ $groups[ $element->getBundlingKey() ][] = $element;
+ } else {
+ $bundled[] = $element;
+ }
+ }
+
+ foreach ( $groups as $bundlingKey => $group ) {
+ $this->sort( $group );
+ /** @var Bundleable $base */
+ $base = array_shift( $group );
+ $base->setBundledElements( $group );
+ $bundled[] = $base;
+ }
+
+ $this->sort( $bundled );
+ return $bundled;
+ }
+
+}
diff --git a/Echo/includes/ContainmentSet.php b/Echo/includes/ContainmentSet.php
index 95c1c8af..4b6ce1f8 100644
--- a/Echo/includes/ContainmentSet.php
+++ b/Echo/includes/ContainmentSet.php
@@ -22,9 +22,10 @@ interface EchoContainmentList {
* from multiple sources like global variables, wiki pages, etc.
*
* Initialize:
+ * $cache = ObjectCache::getLocalClusterIntance();
* $set = new EchoContainmentSet;
* $set->addArray( $wgSomeGlobalParameter );
- * $set->addOnWiki( NS_USER, 'Foo/bar-baz', $wgMemc, 'some_user_specific_cache_key' );
+ * $set->addOnWiki( NS_USER, 'Foo/bar-baz', $cache, 'some_user_specific_cache_key' );
*
* Usage:
* if ( $set->contains( 'SomeUser' ) ) {
@@ -33,14 +34,23 @@ interface EchoContainmentList {
*/
class EchoContainmentSet {
/**
- * @var $lists array of EchoContainmentList objects
+ * @var EchoContainmentList[]
*/
- protected $lists = array();
+ protected $lists = [];
+
+ /**
+ * @var User
+ */
+ protected $recipient;
+
+ public function __construct( User $recipient ) {
+ $this->recipient = $recipient;
+ }
/**
* Add an EchoContainmentList to the set of lists checked by self::contains()
*
- * @param $list EchoContainmentList
+ * @param EchoContainmentList $list
*/
public function add( EchoContainmentList $list ) {
$this->lists[] = $list;
@@ -49,21 +59,36 @@ class EchoContainmentSet {
/**
* Add a php array to the set of lists checked by self::contains()
*
- * @param $list array
+ * @param array $list
*/
public function addArray( array $list ) {
$this->add( new EchoArrayList( $list ) );
}
/**
+ * Add a list from a user preference to the set of lists checked by self::contains().
+ *
+ * @param string $preferenceName
+ */
+ public function addFromUserOption( $preferenceName ) {
+ $preference = $this->recipient->getOption( $preferenceName );
+
+ if ( $preference ) {
+ $items = explode( "\n", $preference );
+
+ $this->addArray( $items );
+ }
+ }
+
+ /**
* 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 $namespace integer An NS_* constant representing the mediawiki namespace of the page containing the list.
- * @param $title string The title of the page containing the list.
- * @param $cache BagOStuff An object to cache the page with or null for no cache.
- * @param $cacheKeyPrefix string A prefix to be combined with the pages latest revision id and used as a cache key.
+ * @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 $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
*/
@@ -81,8 +106,8 @@ class EchoContainmentSet {
/**
* Test the wrapped lists for existence of $value
*
- * @param $value mixed The value to look for
- * @return boolean True when the set contains the provided value
+ * @param mixed $value The value to look for
+ * @return bool True when the set contains the provided value
*/
public function contains( $value ) {
foreach ( $this->lists as $list ) {
@@ -102,26 +127,26 @@ class EchoContainmentSet {
*/
class EchoArrayList implements EchoContainmentList {
/**
- * @param $list array
+ * @var array
*/
protected $list;
/**
- * @param $list array
+ * @param array $list
*/
public function __construct( array $list ) {
$this->list = $list;
}
/**
- * {@inheritDoc}
+ * @inheritDoc
*/
public function getValues() {
return $this->list;
}
/**
- * {@inheritDoc}
+ * @inheritDoc
*/
public function getCacheKey() {
return '';
@@ -134,14 +159,14 @@ class EchoArrayList implements EchoContainmentList {
*/
class EchoOnWikiList implements EchoContainmentList {
/**
- * @var $title Title|null A title object representing the page to source the list from,
+ * @var Title|null A title object representing the page to source the list from,
* or null if the page does not exist.
*/
protected $title;
/**
- * @param $titleNs integer An NS_* constant representing the mediawiki namespace of the page
- * @param $titleString string String portion of the wiki page title
+ * @param int $titleNs An NS_* constant representing the mediawiki namespace of the page
+ * @param string $titleString String portion of the wiki page title
*/
public function __construct( $titleNs, $titleString ) {
$title = Title::newFromText( $titleString, $titleNs );
@@ -151,23 +176,26 @@ class EchoOnWikiList implements EchoContainmentList {
}
/**
- * {@inheritDoc}
+ * @inheritDoc
*/
public function getValues() {
if ( !$this->title ) {
- return array();
+ return [];
}
$article = WikiPage::newFromID( $this->title->getArticleId() );
if ( $article === null || !$article->exists() ) {
- return array();
+ return [];
}
-
- return array_filter( array_map( 'trim', explode( "\n", $article->getText() ) ) );
+ $text = ContentHandler::getContentText( $article->getContent() );
+ if ( $text === null ) {
+ return [];
+ }
+ return array_filter( array_map( 'trim', explode( "\n", $text ) ) );
}
/**
- * {@inheritDoc}
+ * @inheritDoc
*/
public function getCacheKey() {
if ( !$this->title ) {
@@ -193,11 +221,11 @@ class EchoCachedList implements EchoContainmentList {
private $result;
/**
- * @param $cache BagOStuff Bag to stored cached data in.
- * @param $partialCacheKey string Partial cache key, $nestedList->getCacheKey() will be appended to this
- * to construct the cache key used.
- * @param $nestedList EchoContainmentList The nested EchoContainmentList to cache the result of.
- * @param $timeout integer How long in seconds to cache the nested list, defaults to 1 week.
+ * @param BagOStuff $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 ) {
$this->cache = $cache;
@@ -207,7 +235,7 @@ class EchoCachedList implements EchoContainmentList {
}
/**
- * {@inheritDoc}
+ * @inheritDoc
*/
public function getValues() {
if ( $this->result ) {
@@ -234,7 +262,7 @@ class EchoCachedList implements EchoContainmentList {
}
/**
- * {@inheritDoc}
+ * @inheritDoc
*/
public function getCacheKey() {
return $this->partialCacheKey . '_' . $this->nestedList->getCacheKey();
diff --git a/Echo/includes/DataOutputFormatter.php b/Echo/includes/DataOutputFormatter.php
index b7519c94..58eec3a1 100644
--- a/Echo/includes/DataOutputFormatter.php
+++ b/Echo/includes/DataOutputFormatter.php
@@ -6,25 +6,42 @@
class EchoDataOutputFormatter {
/**
+ * @var array type => class
+ */
+ protected static $formatters = [
+ 'flyout' => 'EchoFlyoutFormatter',
+ 'model' => 'EchoModelFormatter',
+ 'special' => 'SpecialNotificationsFormatter',
+ 'html' => 'SpecialNotificationsFormatter',
+ ];
+
+ /**
* Format a notification for a user in the format specified
*
* @param EchoNotification $notification
- * @param string|bool $format specifify output format, false to not format any notifications
- * @param User|null $user the target user viewing the notification
- * @return array
+ * @param string|bool $format output format, false to not format any notifications
+ * @param User $user the target user viewing the notification
+ * @param Language $lang Language to format the notification in
+ * @return array|bool false if it could not be formatted
*/
- public static function formatOutput( EchoNotification $notification, $format = false, User $user = null ) {
+ public static function formatOutput( EchoNotification $notification, $format = false, User $user, Language $lang ) {
$event = $notification->getEvent();
$timestamp = $notification->getTimestamp();
+ $utcTimestampIso8601 = wfTimestamp( TS_ISO_8601, $timestamp );
$utcTimestampUnix = wfTimestamp( TS_UNIX, $timestamp );
-
- // Default to notification user if user is not specified
- if ( !$user ) {
- $user = $notification->getUser();
- }
-
- if ( $notification->getBundleBase() && $notification->getBundleDisplayHash() ) {
- $event->setBundleHash( $notification->getBundleDisplayHash() );
+ $utcTimestampMW = wfTimestamp( TS_MW, $timestamp );
+ $bundledIds = null;
+
+ $bundledNotifs = $notification->getBundledNotifications();
+ if ( $bundledNotifs ) {
+ $bundledEvents = array_map( function ( EchoNotification $notif ) {
+ return $notif->getEvent();
+ }, $bundledNotifs );
+ $event->setBundledEvents( $bundledEvents );
+
+ $bundledIds = array_map( function ( $event ) {
+ return (int)$event->getId();
+ }, $bundledEvents );
}
$timestampMw = self::getUserLocalTime( $user, $timestamp );
@@ -48,18 +65,28 @@ class EchoDataOutputFormatter {
}
// End creating date section header
- $output = array(
+ $output = [
+ 'wiki' => wfWikiID(),
'id' => $event->getId(),
'type' => $event->getType(),
'category' => $event->getCategory(),
- 'timestamp' => array(
+ 'timestamp' => [
+ // ISO 8601 is supposed to be the *only* format used for
+ // date output, but back-compat...
+ 'utciso8601' => $utcTimestampIso8601,
+
// UTC timestamp in UNIX format used for loading more notification
'utcunix' => $utcTimestampUnix,
'unix' => self::getUserLocalTime( $user, $timestamp, TS_UNIX ),
+ 'utcmw' => $utcTimestampMW,
'mw' => $timestampMw,
'date' => $date
- ),
- );
+ ],
+ ];
+
+ if ( $bundledIds ) {
+ $output['bundledIds'] = $bundledIds;
+ }
if ( $event->getVariant() ) {
$output['variant'] = $event->getVariant();
@@ -67,23 +94,23 @@ class EchoDataOutputFormatter {
$title = $event->getTitle();
if ( $title ) {
- $output['title'] = array(
+ $output['title'] = [
'full' => $title->getPrefixedText(),
'namespace' => $title->getNSText(),
- 'namespace-key' =>$title->getNamespace(),
+ 'namespace-key' => $title->getNamespace(),
'text' => $title->getText(),
- );
+ ];
}
$agent = $event->getAgent();
if ( $agent ) {
if ( $event->userCan( Revision::DELETED_USER, $user ) ) {
- $output['agent'] = array(
+ $output['agent'] = [
'id' => $agent->getId(),
'name' => $agent->getName(),
- );
+ ];
} else {
- $output['agent'] = array( 'userhidden' => '' );
+ $output['agent'] = [ 'userhidden' => '' ];
}
}
@@ -97,8 +124,8 @@ class EchoDataOutputFormatter {
// This is only meant for unread notifications, if a notification has a target
// page, then it shouldn't be auto marked as read unless the user visits
- // the target page or a user marks it as read manully ( coming soon )
- $output['targetpages'] = array();
+ // the target page or a user marks it as read manually ( coming soon )
+ $output['targetpages'] = [];
if ( $notification->getTargetPages() ) {
foreach ( $notification->getTargetPages() as $targetPage ) {
$output['targetpages'][] = $targetPage->getPageId();
@@ -106,13 +133,48 @@ class EchoDataOutputFormatter {
}
if ( $format ) {
- $output['*'] = EchoNotificationController::formatNotification( $event, $user, $format );
+ $formatted = self::formatNotification( $event, $user, $format, $lang );
+ if ( $formatted === false ) {
+ // Can't display it, so mark it as read
+ EchoDeferredMarkAsDeletedUpdate::add( $event );
+ return false;
+ }
+ $output['*'] = $formatted;
+
+ if ( $notification->getBundledNotifications() && self::isBundleExpandable( $event->getType() ) ) {
+ $output['bundledNotifications'] = array_values( array_filter( array_map( function ( EchoNotification $notification ) use ( $format, $user, $lang ) {
+ // remove nested notifications to
+ // - ensure they are formatted as single notifications (not bundled)
+ // - prevent further re-entrance on the current notification
+ $notification->setBundledNotifications( [] );
+ $notification->getEvent()->setBundledEvents( [] );
+ return self::formatOutput( $notification, $format, $user, $lang );
+ }, array_merge( [ $notification ], $notification->getBundledNotifications() ) ) ) );
+ }
}
return $output;
}
/**
+ * @param EchoEvent $event
+ * @param User $user
+ * @param string $format
+ * @param Language $lang
+ * @return string|bool false if it could not be formatted
+ */
+ protected static function formatNotification( EchoEvent $event, User $user, $format, $lang ) {
+ if ( isset( self::$formatters[$format] ) ) {
+ $class = self::$formatters[$format];
+ /** @var EchoEventFormatter $formatter */
+ $formatter = new $class( $user, $lang );
+ return $formatter->format( $event );
+ } else {
+ return false;
+ }
+ }
+
+ /**
* Get the date header in user's format, 'May 10' or '10 May', depending
* on user's date format preference
* @param User $user
@@ -122,6 +184,7 @@ class EchoDataOutputFormatter {
protected static function getDateHeader( User $user, $timestampMw ) {
$lang = RequestContext::getMain()->getLanguage();
$dateFormat = $lang->getDateFormatString( 'pretty', $user->getDatePreference() ?: 'default' );
+
return $lang->sprintfDate( $dateFormat, $timestampMw );
}
@@ -130,14 +193,24 @@ class EchoDataOutputFormatter {
*
* @param User
* @param string
- * @param int output format
+ * @param int $format output format
*
* @return string
*/
public static function getUserLocalTime( User $user, $ts, $format = TS_MW ) {
$timestamp = new MWTimestamp( $ts );
$timestamp->offsetForUser( $user );
+
return $timestamp->getTimestamp( $format );
}
+ /**
+ * @param string $type
+ * @return bool Whether a notification type can be an expandable bundle
+ */
+ public static function isBundleExpandable( $type ) {
+ global $wgEchoNotifications;
+ return isset( $wgEchoNotifications[$type]['bundle']['expandable'] ) && $wgEchoNotifications[$type]['bundle']['expandable'];
+ }
+
}
diff --git a/Echo/includes/DeferredMarkAsDeletedUpdate.php b/Echo/includes/DeferredMarkAsDeletedUpdate.php
new file mode 100644
index 00000000..3a384466
--- /dev/null
+++ b/Echo/includes/DeferredMarkAsDeletedUpdate.php
@@ -0,0 +1,72 @@
+<?php
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * Mark event notifications as deleted at the end of a request. Used to queue up
+ * individual events to mark due to formatting failures.
+ */
+class EchoDeferredMarkAsDeletedUpdate implements DeferrableUpdate {
+ /**
+ * @var array
+ */
+ protected $events = [];
+
+ /**
+ * @param EchoEvent $event
+ */
+ public static function add( EchoEvent $event ) {
+ static $update;
+ if ( $update === null ) {
+ $update = new self();
+ DeferredUpdates::addUpdate( $update );
+ }
+ $update->addInternal( $event );
+ }
+
+ /**
+ * @param EchoEvent $event
+ */
+ private function addInternal( EchoEvent $event ) {
+ $this->events[] = $event;
+ }
+
+ private function filterEventsWithTitleDbLag() {
+ return array_filter(
+ $this->events,
+ function ( EchoEvent $event ) {
+ if ( !$event->getTitle() && $event->getTitle( true ) ) {
+ // It is very likely this event was found
+ // unreaderable because of slave 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.',
+ [
+ 'eventId' => $event->getId(),
+ 'title' => $event->getTitle()->getPrefixedText(),
+ ]
+ );
+ return false;
+ }
+ return true;
+ }
+ );
+ }
+
+ /**
+ * Marks all queued notifications as read.
+ * Satisfies DeferrableUpdate interface
+ */
+ public function doUpdate() {
+ $events = $this->filterEventsWithTitleDbLag();
+
+ $eventIds = array_map(
+ function ( EchoEvent $event ) {
+ return $event->getId();
+ },
+ $events
+ );
+
+ EchoModerationController::moderate( $eventIds, true );
+ $this->events = [];
+ }
+}
diff --git a/Echo/includes/DeferredMarkAsReadUpdate.php b/Echo/includes/DeferredMarkAsReadUpdate.php
deleted file mode 100644
index 9b02e8cc..00000000
--- a/Echo/includes/DeferredMarkAsReadUpdate.php
+++ /dev/null
@@ -1,39 +0,0 @@
-<?php
-
-/**
- * Mark event notifications as read at the end of a request. Used to queue up
- * individual events to mark due to formatting failures or other uses.
- */
-class EchoDeferredMarkAsReadUpdate implements DeferrableUpdate {
- /**
- * @var array
- */
- protected $events = array();
-
- /**
- * @param EchoEvent $event
- * @param User $user
- */
- public function add( EchoEvent $event, User $user ) {
- $uid = $user->getId();
- if ( isset( $this->events[$uid] ) ) {
- $this->events[$uid]['eventIds'][] = $event->getId();
- } else {
- $this->events[$uid] = array(
- 'user' => $user,
- 'eventIds' => array( $event->getId() ),
- );
- }
- }
-
- /**
- * Mark's all queue'd notifications as read.
- * Satisfies DeferrableUpdate interface
- */
- public function doUpdate() {
- foreach ( $this->events as $data ) {
- MWEchoNotifUser::newFromUser( $data['user'] )->markRead( $data['eventIds'] );
- }
- $this->events = array();
- }
-}
diff --git a/Echo/includes/DiffParser.php b/Echo/includes/DiffParser.php
index d18035c1..7b70fae6 100644
--- a/Echo/includes/DiffParser.php
+++ b/Echo/includes/DiffParser.php
@@ -26,13 +26,13 @@
* Calculates the individual sets of differences between two pieces of text
* as individual groupings of add, subtract, and change actions. Internally
* uses 0-indexing for positions. All results from the class are 1 indexed
- * to stay consistent with the origional diff output and the previous diff
+ * to stay consistent with the original diff output and the previous diff
* parsing code.
*/
class EchoDiffParser {
/**
- * @var integer $prefixLength The number of characters the diff prefixes a line with
+ * @var int $prefixLength The number of characters the diff prefixes a line with
*/
protected $prefixLength = 1;
@@ -42,7 +42,7 @@ class EchoDiffParser {
protected $left;
/**
- * @var integer $leftPos The current position within the left text
+ * @var int $leftPos The current position within the left text
*/
protected $leftPos;
@@ -52,7 +52,7 @@ class EchoDiffParser {
protected $right;
/**
- * @var integer $rightPos The current position within the right text
+ * @var int $rightPos The current position within the right text
*/
protected $rightPos;
@@ -77,25 +77,55 @@ class EchoDiffParser {
* 'left_pos' and 'right_pos' (in 1-indexed lines) of the change.
*/
public function getChangeSet( $leftText, $rightText ) {
- $left = trim( $leftText ) . "\n";
- $right = trim( $rightText ) . "\n";
- $diff = wfDiff( $left, $right, '-u -w' );
+ $left = trim( $leftText );
+ $right = trim( $rightText );
+
+ if ( $left === '' ) {
+ // fixes T155998
+ return $this->getChangeSetFromEmptyLeft( $right );
+ }
+
+ $diffs = new Diff( explode( "\n", $left ), explode( "\n", $right ) );
+ $format = new UnifiedDiffFormatter();
+ $diff = $format->format( $diffs );
return $this->parse( $diff, $left, $right );
}
/**
+ * If we add content to an empty page the changeSet can be composed straightaway
+ *
+ * @param string $right
+ * @return array[] see getChangeSet()
+ */
+ private function getChangeSetFromEmptyLeft( $right ) {
+ $rightLines = explode( "\n", $right );
+
+ return [
+ '_info' => [
+ 'lhs-length' => 1,
+ 'rhs-length' => count( $rightLines ),
+ 'lhs' => [ '' ],
+ 'rhs' => $rightLines
+ ],
+ [
+ 'right-pos' => 1,
+ 'left-pos' => 1,
+ 'action' => 'add',
+ 'content' => $right,
+ ]
+ ];
+ }
+
+ /**
* Duplicates the check from the global wfDiff function to determine
* if we are using internal or external diff utilities
+ *
+ * @deprecated since 1.29, the internal diff parser is always used
+ * @return bool
*/
- static protected function usingInternalDiff() {
- global $wgDiff;
-
- wfSuppressWarnings();
- $haveDiff = $wgDiff && file_exists( $wgDiff );
- wfRestoreWarnings();
-
- return !$haveDiff;
+ protected static function usingInternalDiff() {
+ return true;
}
/**
@@ -114,14 +144,14 @@ class EchoDiffParser {
$this->leftPos = 0;
$this->rightPos = 0;
- $this->changeSet = array(
- '_info' => array(
+ $this->changeSet = [
+ '_info' => [
'lhs-length' => count( $this->left ),
'rhs-length' => count( $this->right ),
'lhs' => $this->left,
'rhs' => $this->right,
- ),
- );
+ ],
+ ];
$change = null;
foreach ( $diff as $line ) {
@@ -151,59 +181,59 @@ class EchoDiffParser {
} else {
$line = '';
}
- } else {
+ } else {
$op = ' ';
}
- switch( $op ) {
- case '@': // metadata
- if ( $change !== null ) {
- $this->changeSet = array_merge( $this->changeSet, $change->getChangeSet() );
- $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 ) );
-
- // -1 because diff is 1 indexed and we are 0 indexed
- $this->leftPos--;
- $this->rightPos--;
- break;
-
- case ' ': // No changes
- if ( $change !== null ) {
- $this->changeSet = array_merge( $this->changeSet, $change->getChangeSet() );
- $change = null;
- }
- $this->leftPos++;
- $this->rightPos++;
- break;
-
- case '-': // subtract
- if ( $this->left[$this->leftPos] !== $line ) {
- throw new MWException( 'Positional error: left' );
- }
- if ( $change === null ) {
- $change = new EchoDiffGroup( $this->leftPos, $this->rightPos );
- }
- $change->subtract( $line );
- $this->leftPos++;
- break;
-
- case '+': // add
- if ( $this->right[$this->rightPos] !== $line ) {
- throw new MWException( 'Positional error: right' );
- }
- if ( $change === null ) {
- $change = new EchoDiffGroup( $this->leftPos, $this->rightPos );
- }
- $change->add( $line );
- $this->rightPos++;
- break;
-
- default:
- throw new MWException( 'Unknown Diff Operation: ' . $op );
+ switch ( $op ) {
+ case '@': // metadata
+ if ( $change !== null ) {
+ $this->changeSet = array_merge( $this->changeSet, $change->getChangeSet() );
+ $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 ) );
+
+ // -1 because diff is 1 indexed and we are 0 indexed
+ $this->leftPos--;
+ $this->rightPos--;
+ break;
+
+ case ' ': // No changes
+ if ( $change !== null ) {
+ $this->changeSet = array_merge( $this->changeSet, $change->getChangeSet() );
+ $change = null;
+ }
+ $this->leftPos++;
+ $this->rightPos++;
+ break;
+
+ case '-': // subtract
+ if ( $this->left[$this->leftPos] !== $line ) {
+ throw new MWException( 'Positional error: left' );
+ }
+ if ( $change === null ) {
+ $change = new EchoDiffGroup( $this->leftPos, $this->rightPos );
+ }
+ $change->subtract( $line );
+ $this->leftPos++;
+ break;
+
+ case '+': // add
+ if ( $this->right[$this->rightPos] !== $line ) {
+ throw new MWException( 'Positional error: right' );
+ }
+ if ( $change === null ) {
+ $change = new EchoDiffGroup( $this->leftPos, $this->rightPos );
+ }
+ $change->add( $line );
+ $this->rightPos++;
+ break;
+
+ default:
+ throw new MWException( 'Unknown Diff Operation: ' . $op );
}
return $change;
@@ -222,23 +252,23 @@ class EchoDiffGroup {
/**
* @var array The lines that have been added
*/
- protected $new = array();
+ protected $new = [];
/**
* @var array The lines that have been removed
*/
- protected $old = array();
+ protected $old = [];
/**
- * @param integer $leftPos The starting line number in the left text
- * @param integer $rightPos The starting line number in the right text
+ * @param int $leftPos The starting line number in the left text
+ * @param int $rightPos The starting line number in the right text
*/
public function __construct( $leftPos, $rightPos ) {
// +1 due to the origional code use 1 indexing for this result
- $this->position = array(
+ $this->position = [
'right-pos' => $rightPos + 1,
'left-pos' => $leftPos + 1,
- );
+ ];
}
/**
@@ -270,17 +300,17 @@ class EchoDiffGroup {
$old = implode( "\n", $this->old );
$new = implode( "\n", $this->new );
$position = $this->position;
- $changeSet = array();
+ $changeSet = [];
// The implodes must come first because we consider array( '' ) to also be false
// meaning a blank link replaced with content is an addition
if ( $old && $new ) {
$min = min( count( $this->old ), count( $this->new ) );
- $changeSet[] = $position + array(
+ $changeSet[] = $position + [
'action' => 'change',
'old_content' => implode( "\n", array_slice( $this->old, 0, $min ) ),
'new_content' => implode( "\n", array_slice( $this->new, 0, $min ) ),
- );
+ ];
$position['left-pos'] += $min;
$position['right-pos'] += $min;
$old = implode( "\n", array_slice( $this->old, $min ) );
@@ -288,15 +318,15 @@ class EchoDiffGroup {
}
if ( $new ) {
- $changeSet[] = $position + array(
+ $changeSet[] = $position + [
'action' => 'add',
'content' => $new,
- );
+ ];
} elseif ( $old ) {
- $changeSet[] = $position + array(
+ $changeSet[] = $position + [
'action' => 'subtract',
'content' => $old,
- );
+ ];
}
return $changeSet;
diff --git a/Echo/includes/DiscussionParser.php b/Echo/includes/DiscussionParser.php
index a5dab307..11d5e7fe 100644
--- a/Echo/includes/DiscussionParser.php
+++ b/Echo/includes/DiscussionParser.php
@@ -1,21 +1,24 @@
<?php
+use MediaWiki\MediaWikiServices;
+
abstract class EchoDiscussionParser {
const HEADER_REGEX = '^(==+)\s*([^=].*)\s*\1$';
static protected $timestampRegex;
- static protected $revisionInterpretationCache = array();
+ static protected $revisionInterpretationCache = [];
static protected $diffParser;
/**
* Given a Revision object, generates EchoEvent objects for
* the discussion-related actions that occurred in that Revision.
*
- * @param $revision Revision object
+ * @param Revision $revision
* @return null
*/
- static function generateEventsForRevision( $revision ) {
- $interpretation = self::getChangeInterpretationForRevision( $revision );
+ static function generateEventsForRevision( Revision $revision ) {
+ global $wgEchoMentionsOnMultipleSectionEdits;
+ global $wgEchoMentionOnChanges;
// use slave database if there is a previous revision
if ( $revision->getPrevious() ) {
@@ -30,6 +33,8 @@ abstract class EchoDiscussionParser {
return;
}
+ $interpretation = self::getChangeInterpretationForRevision( $revision );
+
$userID = $revision->getUser();
$userName = $revision->getUserText();
$user = $userID != 0 ? User::newFromId( $userID ) : User::newFromName( $userName, false );
@@ -38,11 +43,28 @@ abstract class EchoDiscussionParser {
if ( $action['type'] == 'add-comment' ) {
$fullSection = $action['full-section'];
$header = self::extractHeader( $fullSection );
- self::generateMentionEvents( $header, $action['content'], $revision, $user );
+ $userLinks = self::getUserLinks( $action['content'], $title );
+ self::generateMentionEvents( $header, $userLinks, $action['content'], $revision, $user );
} elseif ( $action['type'] == 'new-section-with-comment' ) {
$content = $action['content'];
$header = self::extractHeader( $content );
- self::generateMentionEvents( $header, $content, $revision, $user );
+ $userLinks = self::getUserLinks( $content, $title );
+ self::generateMentionEvents( $header, $userLinks, $content, $revision, $user );
+ } elseif ( $action['type'] == 'add-section-multiple' && $wgEchoMentionsOnMultipleSectionEdits ) {
+ $content = self::stripHeader( $action['content'] );
+ $content = self::stripSignature( $content );
+ $userLinks = self::getUserLinks( $content, $title );
+ 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 ) ?: []
+ );
+ $header = self::extractHeader( $action['full-section'] );
+
+ if ( $wgEchoMentionOnChanges ) {
+ self::generateMentionEvents( $header, $userLinks, $action['new_content'], $revision, $user );
+ }
}
}
@@ -50,24 +72,26 @@ abstract class EchoDiscussionParser {
$notifyUser = User::newFromName( $title->getText() );
// 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() ) {
- // if this is a minor edit, only notify if the agent doesn't have talk page minor edit notification blocked
+ if ( $notifyUser && $notifyUser->getId() ) {
+ // 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' ) ) {
$section = self::detectSectionTitleAndText( $interpretation, $title );
if ( $section['section-text'] === '' ) {
$section['section-text'] = $revision->getComment();
}
- EchoEvent::create( array(
+ EchoEvent::create( [
'type' => 'edit-user-talk',
'title' => $title,
- 'extra' => array(
- 'revid' => $revision->getID(),
+ 'extra' => [
+ 'revid' => $revision->getId(),
'minoredit' => $revision->isMinor(),
'section-title' => $section['section-title'],
- 'section-text' => $section['section-text']
- ),
+ 'section-text' => $section['section-text'],
+ 'target-page' => $title->getArticleID(),
+ ],
'agent' => $user,
- ) );
+ ] );
}
}
}
@@ -76,7 +100,7 @@ abstract class EchoDiscussionParser {
/**
* Attempts to determine what section title the edit was performed under (if any)
*
- * @param $interpretation array Results of self::getChangeInterpretationForRevision
+ * @param array $interpretation Results of self::getChangeInterpretationForRevision
* @return array Array containing section title and text
* @param Title $title
*/
@@ -85,21 +109,25 @@ abstract class EchoDiscussionParser {
$header = $snippet = '';
$found = false;
+ StubObject::unstub( $wgLang );
+
foreach ( $interpretation as $action ) {
- switch( $action['type'] ) {
+ switch ( $action['type'] ) {
case 'add-comment':
- $header = self::extractHeader( $action['full-section'] );
+ $header = self::extractHeader( $action['full-section'] );
$snippet = self::getTextSnippet(
- self::stripSignature( self::stripHeader( $action['content'] ), $title ),
- $wgLang,
- 150 );
+ self::stripSignature( self::stripHeader( $action['content'] ), $title ),
+ $wgLang,
+ 150,
+ $title );
break;
case 'new-section-with-comment':
- $header = self::extractHeader( $action['content'] );
+ $header = self::extractHeader( $action['content'] );
$snippet = self::getTextSnippet(
- self::stripSignature( self::stripHeader( $action['content'] ), $title ),
- $wgLang,
- 150 );
+ self::stripSignature( self::stripHeader( $action['content'] ), $title ),
+ $wgLang,
+ 150,
+ $title );
break;
}
if ( $header ) {
@@ -113,72 +141,230 @@ abstract class EchoDiscussionParser {
}
}
if ( $found === false ) {
- return array( 'section-title' => '', 'section-text' => '' );
+ return [ 'section-title' => '', 'section-text' => '' ];
}
- return array( 'section-title' => $header, 'section-text' => $snippet );
+
+ return [ 'section-title' => $header, 'section-text' => $snippet ];
}
/**
* For an action taken on a talk page, notify users whose user pages
* are linked.
- * @param $header string The subject line for the discussion.
- * @param $content string The content of the post, as a wikitext string.
- * @param $revision Revision object.
- * @param $agent User The user who made the comment.
+ * @param string $header The subject line for the discussion.
+ * @param array $userLinks
+ * @param string $content The content of the post, as a wikitext string.
+ * @param Revision $revision
+ * @param User $agent The user who made the comment.
*/
- public static function generateMentionEvents( $header, $content, $revision, $agent ) {
+ public static function generateMentionEvents(
+ $header,
+ $userLinks,
+ $content,
+ Revision $revision,
+ User $agent
+ ) {
+ global $wgEchoMaxMentionsCount, $wgEchoMentionStatusNotifications;
+
$title = $revision->getTitle();
if ( !$title ) {
return;
}
+ $content = self::stripHeader( $content );
+ $content = self::stripSignature( $content, $title );
- $output = self::parseNonEditWikitext( $content, new Article( $title ) );
- $links = $output->getLinks();
+ if ( !$userLinks ) {
+ return;
+ }
- if ( !isset( $links[NS_USER] ) || !is_array( $links[NS_USER] ) ) {
+ $userMentions = self::getUserMentions( $title, $revision->getUser( Revision::RAW ), $userLinks );
+ $overallMentionsCount = self::getOverallUserMentionsCount( $userMentions );
+ if ( $overallMentionsCount === 0 ) {
return;
}
- $mentionedUsers = array();
+
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+
+ if ( $overallMentionsCount > $wgEchoMaxMentionsCount ) {
+ if ( $wgEchoMentionStatusNotifications ) {
+ EchoEvent::create( [
+ 'type' => 'mention-failure-too-many',
+ 'title' => $title,
+ 'extra' => [
+ 'max-mentions' => $wgEchoMaxMentionsCount,
+ 'section-title' => $header,
+ 'notifyAgent' => true
+ ],
+ 'agent' => $agent,
+ ] );
+ $stats->increment( 'echo.event.mention.notification.failure-too-many' );
+ }
+ return;
+ }
+
+ if ( $userMentions['validMentions'] ) {
+ EchoEvent::create( [
+ 'type' => 'mention',
+ 'title' => $title,
+ 'extra' => [
+ 'content' => $content,
+ 'section-title' => $header,
+ 'revid' => $revision->getId(),
+ 'mentioned-users' => $userMentions['validMentions'],
+ ],
+ 'agent' => $agent,
+ ] );
+ }
+
+ if ( $wgEchoMentionStatusNotifications ) {
+ // TODO batch?
+ foreach ( $userMentions['validMentions'] as $mentionedUserId ) {
+ EchoEvent::create( [
+ 'type' => 'mention-success',
+ 'title' => $title,
+ 'extra' => [
+ 'subject-name' => User::newFromId( $mentionedUserId )->getName(),
+ 'section-title' => $header,
+ 'revid' => $revision->getId(),
+ 'notifyAgent' => true
+ ],
+ 'agent' => $agent,
+ ] );
+ $stats->increment( 'echo.event.mention.notification.success' );
+ }
+
+ // TODO batch?
+ foreach ( $userMentions['anonymousUsers'] as $anonymousUser ) {
+ EchoEvent::create( [
+ 'type' => 'mention-failure',
+ 'title' => $title,
+ 'extra' => [
+ 'failure-type' => 'user-anonymous',
+ 'subject-name' => $anonymousUser,
+ 'section-title' => $header,
+ 'revid' => $revision->getId(),
+ 'notifyAgent' => true
+ ],
+ 'agent' => $agent,
+ ] );
+ $stats->increment( 'echo.event.mention.notification.failure-user-anonymous' );
+ }
+
+ // TODO batch?
+ foreach ( $userMentions['unknownUsers'] as $unknownUser ) {
+ EchoEvent::create( [
+ 'type' => 'mention-failure',
+ 'title' => $title,
+ 'extra' => [
+ 'failure-type' => 'user-unknown',
+ 'subject-name' => $unknownUser,
+ 'section-title' => $header,
+ 'revid' => $revision->getId(),
+ 'notifyAgent' => true
+ ],
+ 'agent' => $agent,
+ ] );
+ $stats->increment( 'echo.event.mention.notification.failure-user-unknown' );
+ }
+ }
+ }
+
+ private static function getOverallUserMentionsCount( $userMentions ) {
+ return count( $userMentions, COUNT_RECURSIVE ) - count( $userMentions );
+ }
+
+ /**
+ * @return array[]
+ * Set of arrays containing valid mentions and possible intended but failed mentions.
+ * - [validMentions]: An array of valid users to mention with ID => ID.
+ * - [unknownUsers]: An array of DBKey strings representing unknown users.
+ * - [anonymousUsers]: An array of DBKey strings representing anonymous IP users.
+ */
+ private static function getUserMentions( Title $title, $revisionUserId, array $userLinks ) {
+ global $wgEchoMaxMentionsCount;
+ $userMentions = [
+ 'validMentions' => [],
+ 'unknownUsers' => [],
+ 'anonymousUsers' => [],
+ ];
+
$count = 0;
+ $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
- foreach ( $links[NS_USER] as $dbk => $page_id ) {
- $user = User::newFromName( $dbk );
+ foreach ( $userLinks as $dbk => $page_id ) {
+ // If more users are being pinged this is likely a spam/attack vector
+ // Don't send any mention notifications.
+ if ( $count > $wgEchoMaxMentionsCount ) {
+ $stats->increment( 'echo.event.mention.error.tooMany' );
+ break;
+ }
// we should not add user to 'mention' notification list if
- // 1. the user name is not valid
- // 2. the user mentions themselves
- // 3. the user is the owner of the talk page
- // 4. user is anonymous
- if (
- !$user || $user->isAnon() || $user->getId() == $revision->getUser() ||
- ( $title->getNamespace() === NS_USER_TALK && $title->getDBkey() === $dbk )
- ) {
+ // 1. the user link links to a subpage
+ if ( self::hasSubpage( $dbk ) ) {
continue;
}
- $mentionedUsers[$user->getId()] = $user->getId();
- $count++;
- // If more than 50 users are being pinged this is likely a spam/attack vector
- // Don't send any mention notifications.
- if ( $count > 50 ) {
- return;
+
+ // 2. user is an anonymous IP
+ if ( User::isIP( $dbk ) ) {
+ $userMentions['anonymousUsers'][] = $dbk;
+ $count++;
+ $stats->increment( 'echo.event.mention.error.anonUser' );
+ continue;
+ }
+
+ $user = User::newFromName( $dbk );
+ // 3. the user name is not valid
+ if ( !$user ) {
+ $userMentions['unknownUsers'][] = str_replace( '_', ' ', $dbk );
+ $count++;
+ $stats->increment( 'echo.event.mention.error.invalidUser' );
+ continue;
+ }
+
+ // 4. the user mentions themselves
+ if ( $user->getId() === $revisionUserId ) {
+ $stats->increment( 'echo.event.mention.error.sameUser' );
+ continue;
+ }
+
+ // 5. the user is the owner of the talk page
+ if ( $title->getNamespace() === NS_USER_TALK && $title->getDBkey() === $dbk ) {
+ $stats->increment( 'echo.event.mention.error.ownPage' );
+ continue;
}
+
+ // 6. user does not exist
+ if ( $user->getId() === 0 ) {
+ $userMentions['unknownUsers'][] = str_replace( '_', ' ', $dbk );
+ $count++;
+ $stats->increment( 'echo.event.mention.error.unknownUser' );
+ continue;
+ }
+
+ $userMentions['validMentions'][$user->getId()] = $user->getId();
+ $count++;
}
- if ( !$mentionedUsers ) {
- return;
+ return $userMentions;
+ }
+
+ /**
+ * @return bool|array
+ * Array of links in the user namespace with DBKey => ID.
+ */
+ private static function getUserLinks( $content, Title $title ) {
+ $output = self::parseNonEditWikitext( $content, new Article( $title ) );
+ $links = $output->getLinks();
+
+ if ( !isset( $links[NS_USER] ) || !is_array( $links[NS_USER] ) ) {
+ return false;
}
- EchoEvent::create( array(
- 'type' => 'mention',
- 'title' => $title,
- 'extra' => array(
- 'content' => $content,
- 'section-title' => $header,
- 'revid' => $revision->getId(),
- 'mentioned-users' => $mentionedUsers,
- ),
- 'agent' => $agent,
- ) );
+ return $links[NS_USER];
+ }
+
+ private static function hasSubpage( $dbk ) {
+ return strpos( $dbk, '/' ) !== false;
}
/**
@@ -186,13 +372,13 @@ abstract class EchoDiscussionParser {
* but not for editing (old wikitext usually)
* Stolen from AbuseFilterVariableHolder
*
- * @param $wikitext String
- * @param $article Article
+ * @param string $wikitext
+ * @param Article $article
*
* @return ParserOutput
*/
- static function parseNonEditWikitext( $wikitext, $article ) {
- static $cache = array();
+ static function parseNonEditWikitext( $wikitext, Article $article ) {
+ static $cache = [];
$cacheKey = md5( $wikitext ) . ':' . $article->getTitle()->getPrefixedText();
@@ -213,13 +399,13 @@ abstract class EchoDiscussionParser {
* Given a Revision object, returns a talk-page-centric interpretation
* of the changes made in it.
*
- * @param $revision Revision object
+ * @param Revision $revision
* @see EchoDiscussionParser::interpretDiff
- * @return Array, see interpretDiff for details.
+ * @return array see interpretDiff for details.
*/
- static function getChangeInterpretationForRevision( $revision ) {
- if ( $revision->getID() && isset( self::$revisionInterpretationCache[$revision->getID()] ) ) {
- return self::$revisionInterpretationCache[$revision->getID()];
+ static function getChangeInterpretationForRevision( Revision $revision ) {
+ if ( $revision->getId() && isset( self::$revisionInterpretationCache[$revision->getId()] ) ) {
+ return self::$revisionInterpretationCache[$revision->getId()];
}
$userID = $revision->getUser();
@@ -229,14 +415,18 @@ abstract class EchoDiscussionParser {
if ( $revision->getParentId() ) {
$prevRevision = Revision::newFromId( $revision->getParentId() );
if ( $prevRevision ) {
- $prevText = $prevRevision->getText();
+ $prevText = ContentHandler::getContentText( $prevRevision->getContent() );
}
}
- $changes = self::getMachineReadableDiff( $prevText, $revision->getText() );
+ $changes = self::getMachineReadableDiff(
+ $prevText,
+ ContentHandler::getContentText( $revision->getContent() )
+ );
$output = self::interpretDiff( $changes, $user->getName(), $revision->getTitle() );
- self::$revisionInterpretationCache[$revision->getID()] = $output;
+ self::$revisionInterpretationCache[$revision->getId()] = $output;
+
return $output;
}
@@ -245,10 +435,12 @@ abstract class EchoDiscussionParser {
* in terms of discussion page actions
*
* @todo Expand recognisable actions.
+ *
* @param array $changes Output of EchoEvent::getMachineReadableDiff
- * @param string $user Username
+ * @param string $username Username
* @param Title $title
- * @return Array of associative arrays.
+ * @return array[] Array of associative arrays.
+ *
* Each entry represents an action, which is classified in the 'action' field.
* All types contain a 'content' field except 'unknown'
* (which instead passes through the machine-readable diff in 'details')
@@ -258,9 +450,8 @@ abstract class EchoDiscussionParser {
* existing section.
* - new-section-with-comment: A new section is added, containing
* a single comment signed by the user in question.
- * - unknown-signed-addition: Some signed content is added, but it
- * includes section headers, is signed by another user or
- * otherwise confuses the interpretation engine.
+ * - add-section-multiple: A new section or additions to a section
+ * while editing multiple sections at once.
* - unknown-multi-signed-addition: Some signed content is added,
* but it contains multiple signatures.
* - unknown-unsigned-addition: Some content is added, but it is
@@ -268,12 +459,15 @@ abstract class EchoDiscussionParser {
* - unknown-subtraction: Some content was removed. These actions are
* not currently analysed.
* - unknown-change: Some content was replaced with other content.
- * These actions are not currently analysed.
+ * - unknown-signed-change: Same as unknown-change, but signed.
+ * - unknown-multi-signed-change: Same as unknown-change,
+ * but it contains multiple signatures.
* - unknown: Unrecognised change type.
*/
- static function interpretDiff( $changes, $user, Title $title = null ) {
+ static function interpretDiff( $changes, $username, Title $title = null ) {
// One extra item in $changes for _info
- $actions = array();
+ $actions = [];
+ $signedSections = [];
foreach ( $changes as $index => $change ) {
if ( !is_numeric( $index ) ) {
@@ -287,8 +481,8 @@ abstract class EchoDiscussionParser {
if ( $change['action'] == 'add' ) {
$content = trim( $change['content'] );
- // The \A means the regex must match at the begining of the string.
- // This is slightly different than ^ which matches begining of each
+ // The \A means the regex must match at the beginning of the string.
+ // This is slightly different than ^ which matches beginning of each
// line in multiline mode.
$startSection = preg_match( "/\A" . self::HEADER_REGEX . '/um', $content );
$sectionCount = self::getSectionCount( $content );
@@ -296,112 +490,214 @@ abstract class EchoDiscussionParser {
if (
count( $signedUsers ) == 1 &&
- in_array( $user, $signedUsers )
+ in_array( $username, $signedUsers )
) {
if ( $sectionCount === 0 ) {
+ $signedSections[] = self::getSectionSpan( $change['right-pos'], $changes['_info']['rhs'] );
$fullSection = self::getFullSection( $changes['_info']['rhs'], $change['right-pos'] );
- $actions[] = array(
+ $actions[] = [
'type' => 'add-comment',
'content' => $content,
'full-section' => $fullSection,
- );
+ ];
} elseif ( $startSection && $sectionCount === 1 ) {
- $actions[] = array(
+ $signedSections[] = self::getSectionSpan( $change['right-pos'], $changes['_info']['rhs'] );
+ $actions[] = [
'type' => 'new-section-with-comment',
'content' => $content,
- );
+ ];
} else {
- $actions[] = array(
- 'type' => 'unknown-signed-addition',
- 'content' => $content,
- );
+ $nextSectionStart = $change['right-pos'];
+ $sectionData = self::extractSections( $content );
+ foreach ( $sectionData as $section ) {
+ $sectionSpan = self::getSectionSpan( $nextSectionStart, $changes['_info']['rhs'] );
+ $nextSectionStart = $sectionSpan[1] + 1;
+ $sectionSignedUsers = self::extractSignatures( $section['content'], $title );
+ if ( !empty( $sectionSignedUsers ) ) {
+ $signedSections[] = $sectionSpan;
+ if ( !$section['header'] ) {
+ $fullSection = self::getFullSection( $changes['_info']['rhs'], $change['right-pos'] );
+ $section['header'] = self::extractHeader( $fullSection );
+ }
+ $actions[] = [
+ 'type' => 'add-section-multiple',
+ 'content' => $section['content'],
+ 'header' => $section['header'],
+ ];
+ } else {
+ $actions[] = [
+ 'type' => 'unknown-unsigned-addition',
+ 'content' => $section['content'],
+ ];
+ }
+ }
}
} elseif ( count( $signedUsers ) >= 1 ) {
- $actions[] = array(
+ $actions[] = [
'type' => 'unknown-multi-signed-addition',
'content' => $content,
- );
+ ];
} else {
- $actions[] = array(
+ $actions[] = [
'type' => 'unknown-unsigned-addition',
'content' => $content,
- );
+ ];
}
} elseif ( $change['action'] == 'subtract' ) {
- $actions[] = array(
+ $actions[] = [
'type' => 'unknown-subtraction',
'content' => $change['content'],
- );
+ ];
} elseif ( $change['action'] == 'change' ) {
- $actions[] = array(
+ $actions[] = [
'type' => 'unknown-change',
'old_content' => $change['old_content'],
'new_content' => $change['new_content'],
- );
+ 'right-pos' => $change['right-pos'],
+ 'full-section' => self::getFullSection( $changes['_info']['rhs'], $change['right-pos'] ),
+ ];
+
+ if ( self::hasNewSignature(
+ $change['old_content'],
+ $change['new_content'],
+ $username,
+ $title
+ ) ) {
+ $signedSections[] = self::getSectionSpan( $change['right-pos'], $changes['_info']['rhs'] );
+ }
} else {
- $actions[] = array(
+ $actions[] = [
'type' => 'unknown',
'details' => $change,
- );
+ ];
}
}
- // $actions['_diff'] = $changes;
- // unset( $actions['_diff']['_info'] );
+ if ( !empty( $signedSections ) ) {
+ $actions = self::convertToUnknownSignedChanges( $signedSections, $actions );
+ }
return $actions;
}
+ static function getSignedUsers( $content, $title ) {
+ return array_keys( self::extractSignatures( $content, $title ) );
+ }
+
+ static function hasNewSignature( $oldContent, $newContent, $username, $title ) {
+ $oldSignedUsers = self::getSignedUsers( $oldContent, $title );
+ $newSignedUsers = self::getSignedUsers( $newContent, $title );
+
+ return !in_array( $username, $oldSignedUsers ) && in_array( $username, $newSignedUsers );
+ }
+
+ /**
+ * Converts actions of type "unknown-change" to "unknown-signed-change" if the change is in a signed section.
+ *
+ * @param array $signedSections Array of arrays containing first and last line number of signed sections
+ * @param array $actions
+ * @return array converted actions
+ */
+ static function convertToUnknownSignedChanges( $signedSections, $actions ) {
+ return array_map( function ( $action ) use( $signedSections ) {
+ if (
+ $action['type'] === 'unknown-change' &&
+ self::isInSignedSection( $action['right-pos'], $signedSections )
+ ) {
+ $signedUsers = self::getSignedUsers( $action['new_content'], null );
+ if ( count( $signedUsers ) === 1 ) {
+ $action['type'] = 'unknown-signed-change';
+ } else {
+ $action['type'] = 'unknown-multi-signed-change';
+ }
+ }
+
+ return $action;
+ }, $actions );
+ }
+
+ static function isInSignedSection( $line, $signedSections ) {
+ foreach ( $signedSections as $section ) {
+ if ( $line > $section[0] && $line <= $section[1] ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/**
* Finds the section that a given line is in.
*
- * @param $lines Array of lines in the page.
- * @param $offset int The line to find the full section for.
+ * @param array $lines of lines in the page.
+ * @param int $offset The line to find the full section for.
* @return string Content of the section.
*/
- static function getFullSection( $lines, $offset ) {
- $content = $lines[$offset - 1];
- $headerRegex = '/' . self::HEADER_REGEX . '/um';
-
- // Expand backwards...
- $continue = !preg_match( $headerRegex, $lines[$offset - 1] );
- $i = $offset - 1;
- while ( $continue && $i > 0 ) {
- --$i;
- $line = $lines[$i];
- $content = "$line\n$content";
- if ( preg_match( $headerRegex, $line ) ) {
- $continue = false;
+ 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 ) );
+
+ return trim( $content, "\n" );
+ }
+
+ /**
+ * Given a line number and a text, find the first and last line of the section the line number is in.
+ * If there are subsections, the last line index will be the line before the beginning of the first subsection.
+ * @param $offset line number
+ * @param $lines
+ * @return array tuple [$firstLine, $lastLine]
+ */
+ static function getSectionSpan( $offset, $lines ) {
+ return [
+ self::getSectionStartIndex( $offset, $lines ),
+ self::getSectionEndIndex( $offset, $lines )
+ ];
+ }
+
+ /**
+ * Finds the line number of the start of the section that $offset is in.
+ * @param int $offset
+ * @param array $lines
+ * @return int
+ */
+ static function getSectionStartIndex( $offset, array $lines ) {
+ for ( $i = $offset - 1; $i >= 0; $i-- ) {
+ if ( self::getSectionCount( $lines[$i] ) ) {
+ break;
}
}
- // And then forwards...
+ return $i;
+ }
- $continue = true;
- $i = $offset - 1;
- while ( $continue && $i < count( $lines ) - 1 ) {
- ++$i;
- $line = $lines[$i];
- if ( preg_match( $headerRegex, $line ) ) {
- $continue = false;
- } else {
- $content .= "\n$line";
+ /**
+ * Finds the line number of the end of the section that $offset is in.
+ * @param int $offset
+ * @param array $lines
+ * @return int
+ */
+ static function getSectionEndIndex( $offset, array $lines ) {
+ $lastLine = count( $lines );
+ for ( $i = $offset; $i < $lastLine; $i++ ) {
+ if ( self::getSectionCount( $lines[$i] ) ) {
+ break;
}
}
- return trim( $content, "\n" );
+ return $i;
}
/**
* Gets the number of section headers in a string.
*
- * @param $text string The text.
+ * @param string $text The text.
* @return int Number of section headers found.
*/
static function getSectionCount( $text ) {
$text = trim( $text );
- $matches = array();
+ $matches = [];
preg_match_all( '/' . self::HEADER_REGEX . '/um', $text, $matches );
return count( $matches[0] );
@@ -410,13 +706,13 @@ abstract class EchoDiscussionParser {
/**
* Gets the title of a section or sub section
*
- * @param $text string The text of the section.
- * @return string The title of the section.
+ * @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 ) {
$text = trim( $text );
- $matches = array();
+ $matches = [];
if ( !preg_match_all( '/' . self::HEADER_REGEX . '/um', $text, $matches ) ) {
return false;
@@ -426,9 +722,52 @@ abstract class EchoDiscussionParser {
}
/**
+ * Extracts sections and their contents from text.
+ *
+ * @param string $text The text to parse.
+ * @return array[]
+ * Array of arrays containing sections with header and content.
+ * - [header]: The full header string of the section or false if there is preceding text without header.
+ * - [content]: The content of the section including the header string.
+ */
+ private static function extractSections( $text ) {
+ $matches = [];
+
+ if ( !preg_match_all( '/' . self::HEADER_REGEX . '/um', $text, $matches, PREG_OFFSET_CAPTURE ) ) {
+ return [ [
+ 'header' => false,
+ 'content' => $text
+ ] ];
+ }
+
+ $sectionNum = count( $matches[0] );
+ $sections = [];
+
+ if ( $matches[0][0][1] > 1 ) { // is there text before the first headline?
+ $sections[] = [
+ 'header' => false,
+ 'content' => substr( $text, 0, $matches[0][0][1] - 1 )
+ ];
+ }
+ for ( $i = 0; $i < $sectionNum; $i++ ) {
+ if ( $i + 1 < $sectionNum ) {
+ $content = substr( $text, $matches[0][$i][1], $matches[0][$i + 1][1] - $matches[0][$i][1] );
+ } else {
+ $content = substr( $text, $matches[0][$i][1] );
+ }
+ $sections[] = [
+ 'header' => self::extractHeader( $matches[0][$i][0] ),
+ 'content' => trim( $content )
+ ];
+ }
+
+ return $sections;
+ }
+
+ /**
* Strips out a signature if possible.
*
- * @param $text string The wikitext to strip
+ * @param string $text The wikitext to strip
* @param Title $title
* @return string
*/
@@ -436,41 +775,21 @@ abstract class EchoDiscussionParser {
$output = self::getUserFromLine( $text, $title );
if ( $output === false ) {
$timestampPos = self::getTimestampPosition( $text );
+
return substr( $text, 0, $timestampPos );
}
// Use truncate() instead of truncateHTML() because truncateHTML()
// would not strip signature if the text contains < or &
global $wgContLang;
- return $wgContLang->truncate( $text, $output[0], '' );
- }
-
- /**
- * Strips unnecessary indentation and so on from comments
- *
- * @param $text string The text to strip from
- * @return string Stripped wikitext
- */
- static function stripIndents( $text ) {
- // First strip all indentation from the beginning of lines
- $text = preg_replace( '/^\s*\:+/m', '', $text );
-
- // Now if there is only one list item, strip that too
- $listRegex = '/^\s*(?:[\:#*]\s*)*[#*]/m';
- $matches = array();
- if ( preg_match_all( $listRegex, $text, $matches ) ) {
- if ( count( $matches ) == 1 ) {
- $text = preg_replace( $listRegex, '', $text );
- }
- }
- return $text;
+ return $wgContLang->truncate( $text, $output[0], '' );
}
/**
* Strips out a section header
- * @param $text string The text to strip out the section header from.
- * @return string: The same text, with the section header stripped out.
+ * @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 ) {
$text = preg_replace( '/' . self::HEADER_REGEX . '/um', '', $text );
@@ -481,11 +800,11 @@ abstract class EchoDiscussionParser {
/**
* Determines whether the input is a signed comment.
*
- * @param $text string The text to check.
- * @param $user User|bool If set, will only return true if the comment is
+ * @param string $text The text to check.
+ * @param User|bool $user If set, will only return true if the comment is
* signed by this user.
* @param Title $title
- * @return bool: true or false.
+ * @return bool
*/
static function isSignedComment( $text, $user = false, Title $title = null ) {
$userData = self::getUserFromLine( $text, $title );
@@ -504,13 +823,13 @@ abstract class EchoDiscussionParser {
/**
* Finds the start position, if any, of the timestamp on a line
*
- * @param $line string The line to search for a signature on
+ * @param string $line The line to search for a signature on
* @return int|bool Integer position
*/
static function getTimestampPosition( $line ) {
$timestampRegex = self::getTimestampRegex();
$endOfLine = self::getLineEndingRegex();
- $tsMatches = array();
+ $tsMatches = [];
if ( !preg_match(
"/$timestampRegex$endOfLine/mu",
$line,
@@ -527,10 +846,10 @@ abstract class EchoDiscussionParser {
* Finds differences between $oldText and $newText
* and returns the result in a machine-readable format.
*
- * @param $oldText string The "left hand side" of the diff.
- * @param $newText string The "right hand side" of the diff.
+ * @param string $oldText The "left hand side" of the diff.
+ * @param string $newText The "right hand side" of the diff.
* @throws MWException
- * @return Array of changes.
+ * @return array of changes.
* Each change consists of:
* * An 'action', one of:
* - add
@@ -544,21 +863,22 @@ abstract class EchoDiscussionParser {
if ( !isset( self::$diffParser ) ) {
self::$diffParser = new EchoDiffParser;
}
+
return self::$diffParser->getChangeSet( $oldText, $newText );
}
/**
* Finds and extracts signatures in $text
*
- * @param $text string The text in which to look for signed comments.
+ * @param string $text The text in which to look for signed comments.
* @param Title $title
- * @return array. Associative array, the key is the username, the value
+ * @return array Associative array, the key is the username, the value
* is the last signature that was found.
*/
static function extractSignatures( $text, Title $title = null ) {
$lines = explode( "\n", $text );
- $output = array();
+ $output = [];
$lineNumber = 0;
@@ -588,23 +908,22 @@ abstract class EchoDiscussionParser {
*
* @param string $line Line of text potentially including linked user, user talk,
* and contribution pages
- * @return array Array of users; empty array for none detected
+ * @return array of usernames, empty array for none detected
*/
- static public function extractUsersFromLine( $line ) {
+ public static function extractUsersFromLine( $line ) {
/*
* Signatures can look like anything (as defined by i18n messages
* "signature" & "signature-anon").
* A signature can, e.g., be both a link to user & user-talk page.
- *
*/
// match all title-like excerpts in this line
if ( !preg_match_all( '/\[\[([^\[]+)\]\]/', $line, $matches ) ) {
- return array();
+ return [];
}
$matches = $matches[1];
- $usernames = array();
+ $usernames = [];
foreach ( $matches as $match ) {
/*
@@ -619,7 +938,10 @@ abstract class EchoDiscussionParser {
$title = Title::newFromText( $match[0] );
// figure out if we the link is related to a user
- if ( $title && ( $title->getNamespace() === NS_USER || $title->getNamespace() === NS_USER_TALK ) ) {
+ if (
+ $title &&
+ ( $title->getNamespace() === NS_USER || $title->getNamespace() === NS_USER_TALK )
+ ) {
$usernames[] = $title->getText();
} elseif ( $title && $title->isSpecial( 'Contributions' ) ) {
$parts = explode( '/', $title->getText(), 2 );
@@ -643,7 +965,7 @@ abstract class EchoDiscussionParser {
* - First element is the position of the signature.
* - Second element is the normalised user name.
*/
- static public function getUserFromLine( $line, Title $title = null ) {
+ public static function getUserFromLine( $line, Title $title = null ) {
global $wgParser;
/*
@@ -659,14 +981,18 @@ abstract class EchoDiscussionParser {
// discovered the signature from
// don't validate the username - anon (IP) is fine!
$user = User::newFromName( $username, false );
- $sig = $wgParser->preSaveTransform( '~~~', $title ?: Title::newMainPage(), $user, new ParserOptions() );
+ $sig = $wgParser->preSaveTransform(
+ '~~~',
+ $title ?: Title::newMainPage(),
+ $user,
+ new ParserOptions()
+ );
// see if we can find this user's generated signature in the content
$pos = strrpos( $line, $sig );
if ( $pos !== false ) {
- return array( $pos, $username );
+ return [ $pos, $username ];
}
-
// couldn't find sig, move on to next link excerpt and try there
}
@@ -677,10 +1003,10 @@ abstract class EchoDiscussionParser {
/**
* Find the last link beginning with a given prefix on a line.
*
- * @param $line string The line to search.
- * @param $linkPrefix string The prefix to search for.
- * @param $failureOffset bool
- * @return bool false for failure, array for success.
+ * @param string $line The line to search.
+ * @param string $linkPrefix The prefix to search for.
+ * @param bool $failureOffset
+ * @return array|bool false for failure, array for success.
* - First element is the string offset of the link.
* - Second element is the user the link refers to.
*/
@@ -711,22 +1037,22 @@ abstract class EchoDiscussionParser {
// Look for another place.
return self::getLinkFromLine( $line, $linkPrefix, $linkPos );
} else {
- return array( $linkPos, $linkUser );
+ return [ $linkPos, $linkUser ];
}
}
/**
* Given text including a link, gives the user that that link refers to
*
- * @param $text string The text to extract from.
- * @param $prefix string The link prefix that was used to find the link.
- * @param $offset int Optionally, the offset of the start of the link.
+ * @param string $text The text to extract from.
+ * @param string $prefix The link prefix that was used to find the link.
+ * @param int $offset Optionally, the offset of the start of the link.
* @return bool|string Type description
*/
static function extractUserFromLink( $text, $prefix, $offset = 0 ) {
$userPart = substr( $text, strlen( $prefix ) + $offset );
- $userMatches = array();
+ $userMatches = [];
if ( !preg_match(
'/^[^\|\]\#]+/u',
$userPart,
@@ -756,16 +1082,16 @@ abstract class EchoDiscussionParser {
* Gets a regular expression fragmentmatching characters that
* can appear in a line after the signature.
*
- * @return String regular expression fragment.
+ * @return string regular expression fragment.
*/
static function getLineEndingRegex() {
- $ignoredEndings = array(
+ $ignoredEndings = [
'\s*',
preg_quote( '}' ),
preg_quote( '{' ),
'\<[^\>]+\>',
preg_quote( '{{' ) . '[^}]+' . preg_quote( '}}' ),
- );
+ ];
$regex = '(?:' . implode( '|', $ignoredEndings ) . ')*';
@@ -777,7 +1103,7 @@ abstract class EchoDiscussionParser {
* timestamps as given by ~~~~.
*
* @throws MWException
- * @return String regular expression fragment.
+ * @return string regular expression fragment.
*/
static function getTimestampRegex() {
if ( self::$timestampRegex !== null ) {
@@ -797,7 +1123,7 @@ abstract class EchoDiscussionParser {
// Trim off the timezone to replace at the end
$output = $exemplarTimestamp;
$tzRegex = '/\s*\(\w+\)\s*$/';
- $tzMatches = array();
+ $tzMatches = [];
if ( preg_match( $tzRegex, $output, $tzMatches ) ) {
$output = preg_replace( $tzRegex, '', $output );
}
@@ -819,49 +1145,45 @@ abstract class EchoDiscussionParser {
}
/**
- * This function returns plain text snippet, it also removes html tag,
- * template from text content
- * @param $text string
+ * Parse wikitext into truncated plain text.
+ * @param string $text
* @param Language $lang
- * @param $length int default 150
+ * @param int $length default 150
+ * @param Title|null $title Page from which the text snippet is being extracted
* @return string
*/
- static function getTextSnippet( $text, Language $lang, $length = 150 ) {
- $text = strip_tags( $text );
- $attempt = 0;
-
- // 10 attempts at most, the logic here is to find the first }} and
- // find the matching {{ for that }}
- while ( $attempt < 10 ) {
- $closeCurPos = strpos( $text, '}}' );
-
- if ( $closeCurPos === false ) {
- break;
- }
- $tempStr = substr( $text, 0, $closeCurPos + 2 );
-
- $openCurPos = strrpos( $tempStr, '{{' );
- if ( $openCurPos === false ) {
- $text = substr_replace( $text, '', $closeCurPos, 2 );
- } else {
- $text = substr_replace( $text, '', $openCurPos, $closeCurPos - $openCurPos + 2 );
- }
- $attempt++;
- }
-
- // See Parser::parse() function, &#160; is replaced specifically, replace it back here
- // with a space as this html entity won't be handled by htmlspecialchars_decode()
- $text = str_replace( '&#160;', ' ', MessageCache::singleton()->parse( $text )->getText() );
- $text = trim( strip_tags( htmlspecialchars_decode( $text ) ) );
- // strip out non-useful data for snippet
- $text = str_replace( array( '{', '}' ), '', $text );
- $text = $lang->truncate( $text, $length );
+ static function getTextSnippet( $text, Language $lang, $length = 150, $title = null ) {
+ // Parse wikitext
+ $html = MessageCache::singleton()->parse( $text, $title )->getText();
+ $plaintext = trim( Sanitizer::stripAllTags( $html ) );
+ return $lang->truncate( $plaintext, $length );
+ }
- // Return empty string if there is undecoded char left
- if ( strpos( $text, '&#' ) !== false ) {
- $text = '';
- }
+ /**
+ * Parse an edit summary into truncated plain text.
+ * @param string $text
+ * @param Language $lang
+ * @param int $length default 150
+ * @return string
+ */
+ static function getTextSnippetFromSummary( $text, Language $lang, $length = 150 ) {
+ // Parse wikitext with summary parser
+ $html = Linker::formatLinksInComment( Sanitizer::escapeHtmlAllowEntities( $text ) );
+ $plaintext = trim( Sanitizer::stripAllTags( $html ) );
+ return $lang->truncate( $plaintext, $length );
+ }
- return $text;
+ /**
+ * Extract an edit excerpt from a revision
+ *
+ * @param Revision $revision
+ * @param Language $lang
+ * @param int $length
+ * @return string
+ */
+ public static function getEditExcerpt( Revision $revision, Language $lang, $length = 150 ) {
+ $interpretation = self::getChangeInterpretationForRevision( $revision );
+ $section = self::detectSectionTitleAndText( $interpretation );
+ return $lang->truncate( $section['section-title'] . ' ' . $section['section-text'], $length );
}
}
diff --git a/Echo/includes/EchoDbFactory.php b/Echo/includes/EchoDbFactory.php
index e908e658..12012632 100644
--- a/Echo/includes/EchoDbFactory.php
+++ b/Echo/includes/EchoDbFactory.php
@@ -1,4 +1,5 @@
<?php
+use MediaWiki\MediaWikiServices;
/**
* Database factory class, this will determine whether to use the main database
@@ -7,24 +8,24 @@
class MWEchoDbFactory {
/**
- * The wiki to access the database for
- * @var string|bool
- */
- protected $wiki;
-
- /**
* The cluster for the database
* @var string|bool
*/
- protected $cluster;
+ private $cluster;
+
+ private $shared;
+
+ private $sharedCluster;
/**
- * @param string|bool
- * @param string|bool
+ * @param string|bool $cluster
+ * @param string|bool $shared
+ * @param string|bool $sharedCluster
*/
- public function __construct( $cluster = false, $wiki = false ) {
+ public function __construct( $cluster = false, $shared = false, $sharedCluster = false ) {
$this->cluster = $cluster;
- $this->wiki = $wiki;
+ $this->shared = $shared;
+ $this->sharedCluster = $sharedCluster;
}
/**
@@ -35,21 +36,34 @@ class MWEchoDbFactory {
* @return MWEchoDbFactory
*/
public static function newFromDefault() {
- global $wgEchoCluster;
- return new self( $wgEchoCluster );
+ global $wgEchoCluster, $wgEchoSharedTrackingDB, $wgEchoSharedTrackingCluster;
+
+ return new self( $wgEchoCluster, $wgEchoSharedTrackingDB, $wgEchoSharedTrackingCluster );
}
/**
* Get the database load balancer
- * @param $wiki string|bool The wiki ID, or false for the current wiki
* @return LoadBalancer
*/
protected function getLB() {
// Use the external db defined for Echo
if ( $this->cluster ) {
- $lb = wfGetLBFactory()->getExternalLB( $this->cluster, $this->wiki );
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancerFactory()->getExternalLB( $this->cluster );
+ } else {
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+ }
+
+ return $lb;
+ }
+
+ /**
+ * @return LoadBalancer
+ */
+ protected function getSharedLB() {
+ if ( $this->sharedCluster ) {
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancerFactory()->getExternalLB( $this->sharedCluster );
} else {
- $lb = wfGetLB( $this->wiki );
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
}
return $lb;
@@ -59,10 +73,23 @@ class MWEchoDbFactory {
* Get the database connection for Echo
* @param $db int Index of the connection to get
* @param $groups mixed Query groups.
- * @return DatabaseBase
+ * @return IDatabase
+ */
+ public function getEchoDb( $db, $groups = [] ) {
+ return $this->getLB()->getConnection( $db, $groups );
+ }
+
+ /**
+ * @param $db int Index of the connection to get
+ * @param array $groups Query groups
+ * @return bool|IDatabase false if no shared db is configured
*/
- public function getEchoDb( $db, $groups = array() ) {
- return $this->getLB()->getConnection( $db, $groups, $this->wiki );
+ public function getSharedDb( $db, $groups = [] ) {
+ if ( !$this->shared ) {
+ return false;
+ }
+
+ return $this->getSharedLB()->getConnection( $db, $groups, $this->shared );
}
/**
@@ -75,20 +102,25 @@ class MWEchoDbFactory {
* @param $db int Index of the connection to get
* @param $groups mixed Query groups.
* @param $wiki string|bool The wiki ID, or false for the current wiki
- * @return DatabaseBase
+ * @return IDatabase
*/
- public static function getDB( $db, $groups = array(), $wiki = false ) {
+ public static function getDB( $db, $groups = [], $wiki = false ) {
global $wgEchoCluster;
+ $services = MediaWikiServices::getInstance();
+
// Use the external db defined for Echo
if ( $wgEchoCluster ) {
- $lb = wfGetLBFactory()->getExternalLB( $wgEchoCluster, $wiki );
+ $lb = $services->getDBLoadBalancerFactory()->getExternalLB( $wgEchoCluster, $wiki );
} else {
- $lb = wfGetLB( $wiki );
+ if ( $wiki === false ) {
+ $lb = $services->getDBLoadBalancer();
+ } else {
+ $lb = $services->getDBLoadBalancerFactory()->getMainLB( $wiki );
+ }
}
return $lb->getConnection( $db, $groups, $wiki );
-
}
/**
@@ -105,11 +137,11 @@ class MWEchoDbFactory {
* @return array
*/
public function getMasterPosition() {
- $position = array(
+ $position = [
'wikiDb' => false,
'echoDb' => false,
- );
- $lb = wfGetLB();
+ ];
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
if ( $lb->getServerCount() > 1 ) {
$position['wikiDb'] = $lb->getMasterPos();
};
@@ -125,7 +157,7 @@ class MWEchoDbFactory {
}
/**
- * Recieves the output of self::getMasterPosition. Waits
+ * Receives the output of self::getMasterPosition. Waits
* for slaves to catch up to the master position at that
* point.
*
@@ -133,10 +165,18 @@ class MWEchoDbFactory {
*/
public function waitFor( array $position ) {
if ( $position['wikiDb'] ) {
- wfGetLB()->waitFor( $position['wikiDb'] );
+ MediaWikiServices::getInstance()->getDBLoadBalancer()->waitFor( $position['wikiDb'] );
}
if ( $position['echoDb'] ) {
$this->getLB()->waitFor( $position['echoDb'] );
}
}
+
+ /**
+ * Check whether it makes sense to retry a failed lookup on the master.
+ * @return bool True if there are multiple servers and changes were made in this request; false otherwise
+ */
+ public function canRetryMaster() {
+ return $this->getLB()->getServerCount() > 1 && $this->getLB()->hasOrMadeRecentMasterChanges();
+ }
}
diff --git a/Echo/includes/EmailBatch.php b/Echo/includes/EmailBatch.php
index 1802ece8..7947fda0 100644
--- a/Echo/includes/EmailBatch.php
+++ b/Echo/includes/EmailBatch.php
@@ -5,25 +5,43 @@
*/
class MWEchoEmailBatch {
- // the user to be notified
+ /**
+ * @var User the user to be notified
+ */
protected $mUser;
- // list of email content
- protected $content = array();
- // the last notification event of this batch
+ /**
+ * @var Language
+ */
+ protected $language;
+
+ /**
+ * @var EchoEvent[] events included in this email
+ */
+ protected $events = [];
+
+ /**
+ * @var EchoEvent the last notification event of this batch
+ */
protected $lastEvent;
- // the event count, this count is supported up to self::$displaySize + 1
+
+ /**
+ * @var int the event count, this count is supported up to self::$displaySize + 1
+ */
protected $count = 0;
- // number of bundle events to include in an email, we couldn't include
- // all events in a batch email
+ /**
+ * @var int number of bundle events to include in an email,
+ * we cannot include all events in a batch email
+ */
protected static $displaySize = 20;
/**
- * @param $user User
+ * @param User $user
*/
public function __construct( User $user ) {
$this->mUser = $user;
+ $this->language = wfGetLangObj( $this->mUser->getOption( 'language' ) );
}
/**
@@ -34,8 +52,8 @@ class MWEchoEmailBatch {
* 0 - instant
* 1 - once everyday
* 7 - once every 7 days
- * @param $userId int
- * @param $enforceFrequency boolean Whether or not email sending frequency should
+ * @param int $userId
+ * @param bool $enforceFrequency Whether or not email sending frequency should
* be enforced.
*
* When true, today's notifications won't be returned if they are
@@ -53,6 +71,7 @@ class MWEchoEmailBatch {
if ( $userEmailSetting == -1 ) {
$emailBatch = new self( $user );
$emailBatch->clearProcessedEvent();
+
return false;
}
@@ -97,15 +116,22 @@ class MWEchoEmailBatch {
$events = $this->getEvents();
if ( $events ) {
- foreach( $events as $row ) {
+ foreach ( $events as $row ) {
$this->count++;
if ( $this->count > self::$displaySize ) {
break;
}
$event = EchoEvent::newFromRow( $row );
- $this->appendContent( $event, $row->eeb_event_hash );
+ if ( !$event ) {
+ continue;
+ }
+ $event->setBundleHash( $row->eeb_event_hash );
+ $this->events[] = $event;
}
+ $bundler = new Bundler();
+ $this->events = $bundler->bundle( $this->events );
+
$this->sendEmail();
}
@@ -122,14 +148,15 @@ class MWEchoEmailBatch {
protected function setLastEvent() {
$dbr = MWEchoDbFactory::getDB( DB_SLAVE );
$res = $dbr->selectField(
- array( 'echo_email_batch' ),
- array( 'MAX( eeb_event_id )' ),
- array( 'eeb_user_id' => $this->mUser->getId() ),
+ [ 'echo_email_batch' ],
+ [ 'MAX( eeb_event_id )' ],
+ [ 'eeb_user_id' => $this->mUser->getId() ],
__METHOD__
);
if ( $res ) {
$this->lastEvent = $res;
+
return true;
} else {
return false;
@@ -152,7 +179,7 @@ class MWEchoEmailBatch {
protected function getEvents() {
global $wgEchoNotifications;
- $events = array();
+ $events = [];
$validEvents = array_keys( $wgEchoNotifications );
@@ -163,11 +190,11 @@ class MWEchoEmailBatch {
if ( $validEvents ) {
$dbr = MWEchoDbFactory::getDB( DB_SLAVE );
- $conds = array(
+ $conds = [
'eeb_user_id' => $this->mUser->getId(),
'event_id = eeb_event_id',
'event_type' => $validEvents
- );
+ ];
// See setLastEvent() for more detail for this variable
if ( $this->lastEvent ) {
@@ -175,15 +202,14 @@ class MWEchoEmailBatch {
}
$res = $dbr->select(
- array( 'echo_email_batch', 'echo_event' ),
- array( '*' ),
+ [ 'echo_email_batch', 'echo_event' ],
+ [ '*' ],
$conds,
__METHOD__,
- array(
+ [
'ORDER BY' => 'eeb_event_priority',
'LIMIT' => self::$displaySize + 1,
- 'GROUP BY' => 'eeb_event_hash'
- )
+ ]
);
foreach ( $res as $row ) {
@@ -199,37 +225,21 @@ class MWEchoEmailBatch {
}
/**
- * Add individual event template to the big email content
- *
- * @param EchoEvent $event
- * @param string $hash
- */
- protected function appendContent( EchoEvent $event, $hash ) {
- // get the category for this event
- $category = $event->getCategory();
- $event->setBundleHash( $hash );
- $email = EchoNotificationController::formatNotification( $event, $this->mUser, 'email', 'emaildigest' );
-
- $this->content[$category][] = $email;
- }
-
- /**
* Clear "processed" events in the queue, processed could be: email sent, invalid, users do not want to receive emails
*/
public function clearProcessedEvent() {
- $conds = array( 'eeb_user_id' => $this->mUser->getId() );
+ $conds = [ 'eeb_user_id' => $this->mUser->getId() ];
// there is a processed cutoff point
if ( $this->lastEvent ) {
$conds[] = 'eeb_event_id <= ' . intval( $this->lastEvent );
}
- $dbw = MWEchoDbFactory::getDB( DB_MASTER );
+ $dbw = MWEchoDbFactory::newFromDefault()->getEchoDb( DB_MASTER );
$dbw->delete(
'echo_email_batch',
$conds,
- __METHOD__,
- array()
+ __METHOD__
);
}
@@ -239,8 +249,7 @@ class MWEchoEmailBatch {
public function sendEmail() {
global $wgNotificationSender, $wgNotificationReplyName;
- // @Todo - replace them with the CONSTANT in 33810 once it is merged
- if ( $this->mUser->getOption( 'echo-email-frequency' ) == 7 ) {
+ if ( $this->mUser->getOption( 'echo-email-frequency' ) == EchoEmailFrequency::WEEKLY_DIGEST ) {
$frequency = 'weekly';
$emailDeliveryMode = 'weekly_digest';
} else {
@@ -248,52 +257,44 @@ class MWEchoEmailBatch {
$emailDeliveryMode = 'daily_digest';
}
- // Echo digest email mode
- $emailDigest = new EchoEmailDigest( $this->mUser, $this->content, $frequency );
-
- $textEmailFormatter = new EchoTextEmailFormatter( $emailDigest );
-
- $body = $textEmailFormatter->formatEmail();
+ $textEmailDigestFormatter = new EchoPlainTextDigestEmailFormatter( $this->mUser, $this->language, $frequency );
+ $content = $textEmailDigestFormatter->format( $this->events, 'email' );
- $format = MWEchoNotifUser::newFromUser( $this->mUser )->getEmailFormat();
- if ( $format == EchoHooks::EMAIL_FORMAT_HTML ) {
- $htmlEmailFormatter = new EchoHTMLEmailFormatter( $emailDigest );
- $body = array(
- 'text' => $body,
- 'html' => $htmlEmailFormatter->formatEmail()
- );
+ if ( !$content ) {
+ // no event could be formatted
+ return;
}
- // email subject
- if ( $this->count > self::$displaySize ) {
- $count = wfMessage( 'echo-notification-count' )
- ->inLanguage( $this->mUser->getOption( 'language' ) )
- ->params( self::$displaySize )->text();
- } else {
- $count = $this->count;
+ $format = MWEchoNotifUser::newFromUser( $this->mUser )->getEmailFormat();
+ if ( $format == EchoEmailFormat::HTML ) {
+ $htmlEmailDigestFormatter = new EchoHtmlDigestEmailFormatter( $this->mUser, $this->language, $frequency );
+ $htmlContent = $htmlEmailDigestFormatter->format( $this->events, 'email' );
+
+ $content = [
+ 'body' => [
+ 'text' => $content['body'],
+ 'html' => $htmlContent['body'],
+ ],
+ 'subject' => $htmlContent['subject'],
+ ];
}
- // Give grep a chance to find the usages:
- // echo-email-batch-subject-daily, echo-email-batch-subject-weekly
- $subject = wfMessage( 'echo-email-batch-subject-' . $frequency )
- ->inLanguage( $this->mUser->getOption( 'language' ) )
- ->params( $count, $this->count )->text();
$toAddress = MailAddress::newFromUser( $this->mUser );
$fromAddress = new MailAddress( $wgNotificationSender, EchoHooks::getNotificationSenderName() );
- $replyAddress = new MailAddress( $wgNotificationSender, $wgNotificationReplyName );
+ $replyTo = new MailAddress( $wgNotificationSender, $wgNotificationReplyName );
// @Todo Push the email to job queue or just send it out directly?
- UserMailer::send( $toAddress, $fromAddress, $subject, $body, $replyAddress );
+ UserMailer::send( $toAddress, $fromAddress, $content['subject'], $content['body'], [ 'replyTo' => $replyTo ] );
MWEchoEventLogging::logSchemaEchoMail( $this->mUser, $emailDeliveryMode );
}
/**
* Insert notification event into email queue
*
- * @param $userId int
- * @param $eventId int
- * @param $priority int
- * @param $hash string
+ * @param int $userId
+ * @param int $eventId
+ * @param int $priority
+ * @param string $hash
*
* @throws MWException
*/
@@ -304,32 +305,26 @@ class MWEchoEmailBatch {
$dbw = MWEchoDbFactory::getDB( DB_MASTER );
- $row = array(
+ $row = [
'eeb_user_id' => $userId,
'eeb_event_id' => $eventId,
'eeb_event_priority' => $priority,
'eeb_event_hash' => $hash
- );
-
- $id = $dbw->nextSequenceValue( 'echo_email_batch_eeb_id' );
-
- if ( $id ) {
- $row['eeb_id'] = $id;
- }
+ ];
$dbw->insert(
'echo_email_batch',
$row,
__METHOD__,
- array( 'IGNORE' )
+ [ 'IGNORE' ]
);
}
/**
* Get a list of users to be notified for the batch
*
- * @param $startUserId int
- * @param $batchSize int
+ * @param int $startUserId
+ * @param int $batchSize
*
* @throws MWException
* @return ResultWrapper|bool
@@ -337,11 +332,11 @@ class MWEchoEmailBatch {
public static function getUsersToNotify( $startUserId, $batchSize ) {
$dbr = MWEchoDbFactory::getDB( DB_SLAVE );
$res = $dbr->select(
- array( 'echo_email_batch' ),
- array( 'eeb_user_id' ),
- array( 'eeb_user_id > ' . intval( $startUserId ) ),
+ [ 'echo_email_batch' ],
+ [ 'eeb_user_id' ],
+ [ 'eeb_user_id > ' . intval( $startUserId ) ],
__METHOD__,
- array( 'ORDER BY' => 'eeb_user_id', 'LIMIT' => $batchSize )
+ [ 'ORDER BY' => 'eeb_user_id', 'LIMIT' => $batchSize ]
);
return $res;
diff --git a/Echo/includes/EmailBundler.php b/Echo/includes/EmailBundler.php
deleted file mode 100644
index 7360498d..00000000
--- a/Echo/includes/EmailBundler.php
+++ /dev/null
@@ -1,303 +0,0 @@
-<?php
-
-/**
- * This class handles email bundling, it has only two public interfacing entries:
- *
- * 1. a single notification is triggered which calls self::addToEmailBatch()
- * (a) cycle is null/reset, send single notification, schedule a bundle job for next notification
- * (b) cycle is in bundle mode, add the notification to the queue
- *
- * 2. a job is popped off the queue which calls self::processBundleEmail()
- *
- */
-class MWEchoEmailBundler {
-
- /**
- * @var User
- */
- protected $mUser;
-
- /**
- * @var string
- */
- protected $bundleHash;
-
- /**
- * @var string
- *
- * The timestamp of email being sent
- */
- protected $timestamp;
-
- /**
- * @var EchoEvent
- */
- protected $baseEvent;
-
- /**
- * @var int
- *
- * seconds between sending batch email for a bundle notification
- * this only applies to a bundle type
- */
- protected $emailInterval;
-
- /**
- * Protected constructor so subclasses can call it
- */
- protected function __construct( $user, $hash ) {
- global $wgEchoBundleEmailInterval;
-
- $this->mUser = $user;
- $this->bundleHash = $hash;
- $this->emailInterval = $wgEchoBundleEmailInterval;
-
- if ( $this->emailInterval < 0 ) {
- $this->emailInterval = 0;
- }
- }
-
- /**
- * Factory method
- */
- public static function newFromUserHash( User $user, $hash ) {
- if ( !$user->getId() ) {
- return false;
- }
- if ( !$hash || !preg_match( '/^[a-f0-9]{32}$/', $hash ) ) {
- return false;
- }
- return new self( $user, $hash );
- }
-
- /**
- * Check if a new notification should be added to the batch queue
- * true - added to the queue for bundling email
- * false - not added, the client should send single email
- *
- * @param int $eventId
- * @param int $eventPriority
- *
- * @return bool
- */
- public function addToEmailBatch( $eventId, $eventPriority ) {
- $this->retrieveLastEmailTimestamp();
- $this->retrieveBaseEvent();
-
- // send instant single notification email if there is no base event in the batch queue
- // and the email is ready to send, otherwiase, add the email to batch and schedule
- // a delayed job
- if ( !$this->baseEvent && $this->shouldSendEmailNow() ) {
- $this->timestamp = wfTimestampNow();
- $this->updateEmailMetadata();
- return false;
- } else {
- // add to email batch queue
- MWEchoEmailBatch::addToQueue(
- $this->mUser->getId(),
- $eventId,
- $eventPriority,
- $this->bundleHash
- );
-
- // always push the job to job queue in case the previous job
- // was lost, job queue will ignore duplicate
- $this->pushToJobQueue( $this->getDelayTime() );
- return true;
- }
- }
-
- /**
- * Get the time diff since last email
- */
- protected function timeSinceLastEmail() {
- // if there is no timestamp, next email should be sent right away
- // set the time diff longer than the email interval
- if ( !$this->timestamp ) {
- return $this->emailInterval + 600;
- }
-
- $now = wfTimestamp( TS_UNIX );
-
- return $now - wfTimestamp( TS_UNIX, $this->timestamp );
- }
-
- /**
- * Check if an email should be sent right away
- * @return bool
- */
- protected function shouldSendEmailNow() {
- if ( $this->timeSinceLastEmail() > $this->emailInterval ) {
- return true;
- } else {
- return false;
- }
- }
-
- /**
- * Get the delay time
- * @return int
- */
- protected function getDelayTime() {
- $delay = $this->emailInterval - $this->timeSinceLastEmail();
- if ( $delay <= 0 ) {
- $delay = 0;
- }
- return $delay;
- }
-
- /**
- * Get the timestamp of last email
- */
- protected function retrieveLastEmailTimestamp() {
- global $wgMemc;
-
- $data = $wgMemc->get( $this->getMemcacheKey() );
- if ( $data !== false ) {
- $this->timestamp = $data['timestamp'];
- }
- }
-
- /**
- * Get the memcache key
- * @return string
- */
- protected function getMemcacheKey() {
- return wfMemcKey( 'echo', 'email_bundle_status', $this->mUser->getId(), $this->bundleHash );
- }
-
- /**
- * Retrieve the base event for email bundling, the one with the largest eeb_id
- * @return bool
- */
- protected function retrieveBaseEvent() {
- $dbr = MWEchoDbFactory::getDB( DB_SLAVE );
- $res = $dbr->selectRow(
- array( 'echo_email_batch' ),
- array( 'eeb_event_id' ),
- array(
- 'eeb_user_id' => $this->mUser->getId(),
- 'eeb_event_hash' => $this->bundleHash
- ),
- __METHOD__,
- array( 'ORDER BY' => 'eeb_event_priority DESC, eeb_id DESC', 'LIMIT' => 1 )
- );
- if ( !$res ) {
- return false;
- }
- $this->baseEvent = EchoEvent::newFromId( $res->eeb_event_id );
- return true;
- }
-
- /**
- * Push the latest bundle data to the queue
- * @param $delay int To delay the job in $delay seconds
- */
- public function pushToJobQueue( $delay = 0 ) {
- $title = Title::newMainPage();
- $job = new MWEchoNotificationEmailBundleJob(
- $title,
- array(
- 'user_id' => $this->mUser->getId(),
- 'bundle_hash' => $this->bundleHash,
- 'jobReleaseTimestamp' => wfTimestamp( TS_MW, wfTimestamp( TS_UNIX ) + $delay )
- )
- );
- JobQueueGroup::singleton()->push( $job );
- }
-
- /**
- * Main function for processinig bundle email
- */
- public function processBundleEmail() {
- $emailSetting = intval( $this->mUser->getOption( 'echo-email-frequency' ) );
-
- // User has switched to email digest or decided not to receive email,
- // the daily cron will handle events left in the queue
- if ( $emailSetting != 0 ) {
- throw new MWException( "User has switched to email digest/no email option!" );
- }
-
- // If there is nothing in the queue, do not update timestamp so next
- // email would be just an instant email
- if ( $this->retrieveBaseEvent() ) {
- $this->timestamp = wfTimestampNow();
- $this->updateEmailMetadata();
- $this->sendEmail();
- $this->clearProcessedEvent();
- } else {
- throw new MWException( "There is no bundle notification to process!" );
- }
- }
-
- /**
- * Send the bundle email
- */
- protected function sendEmail() {
- $content = $this->generateEmailContent();
-
- if ( !isset( $content['subject'] ) || !isset( $content['body'] ) ) {
- throw new MWException( "Fail to create bundle email content!" );
- }
-
- global $wgNotificationSender, $wgNotificationReplyName;
-
- $toAddress = MailAddress::newFromUser( $this->mUser );
- $fromAddress = new MailAddress( $wgNotificationSender, EchoHooks::getNotificationSenderName() );
- $replyAddress = new MailAddress( $wgNotificationSender, $wgNotificationReplyName );
-
- // Schedule a email job or just send the email directly?
- UserMailer::send( $toAddress, $fromAddress, $content['subject'], $content['body'], $replyAddress );
- MWEchoEventLogging::logSchemaEchoMail( $this->mUser, 'bundle' );
- }
-
- /**
- * Generate the content for bundle email
- * @return string
- */
- protected function generateEmailContent() {
- if ( !$this->baseEvent ) {
- return '';
- }
- $this->baseEvent->setBundleHash( $this->bundleHash );
- return EchoNotificationController::formatNotification( $this->baseEvent, $this->mUser, 'email', 'email' );
- }
-
- /**
- * Update bundle email metadata for user/hash pair
- */
- protected function updateEmailMetadata() {
- global $wgMemc;
-
- $key = $this->getMemcacheKey();
-
- // Store new data and make it expire in 7 days
- $wgMemc->set(
- $key,
- array(
- 'timestamp' => $this->timestamp
- ),
- 3600 * 24 * 7
- );
- }
-
- /**
- * clear processed event in the queue
- */
- protected function clearProcessedEvent() {
- if ( !$this->baseEvent ) {
- return;
- }
- $conds = array( 'eeb_user_id' => $this->mUser->getId(), 'eeb_event_hash' => $this->bundleHash );
-
- $conds[] = 'eeb_event_id <= ' . intval( $this->baseEvent->getId() );
-
- $dbw = MWEchoDbFactory::getDB( DB_MASTER );
- $dbw->delete(
- 'echo_email_batch',
- $conds,
- __METHOD__,
- array()
- );
- }
-}
diff --git a/Echo/includes/EmailFormat.php b/Echo/includes/EmailFormat.php
new file mode 100644
index 00000000..582ece97
--- /dev/null
+++ b/Echo/includes/EmailFormat.php
@@ -0,0 +1,6 @@
+<?php
+
+class EchoEmailFormat {
+ const HTML = 'html';
+ const PLAIN_TEXT = 'plain-text';
+}
diff --git a/Echo/includes/EmailFormatter.php b/Echo/includes/EmailFormatter.php
deleted file mode 100644
index aa359485..00000000
--- a/Echo/includes/EmailFormatter.php
+++ /dev/null
@@ -1,858 +0,0 @@
-<?php
-
-/**
- * Abstract class for formatting email notifications
- */
-abstract class EchoEmailFormatter {
-
- /**
- * @var EchoEmailMode
- */
- protected $emailMode;
-
- /**
- * @param $emailMode EchoEmailMode
- */
- public function __construct( EchoEmailMode $emailMode ) {
- $this->emailMode = $emailMode;
- }
-
- /**
- * Abstract method for formatting email
- * @return string
- */
- abstract public function formatEmail();
-}
-
-/**
- * Formatter class for formatting text email notification
- */
-class EchoTextEmailFormatter extends EchoEmailFormatter {
-
- /**
- * @param $emailMode EchoEmailMode
- */
- public function __construct( EchoEmailMode $emailMode ) {
- parent::__construct( $emailMode );
- $this->emailMode->attachDecorator( new EchoTextEmailDecorator() );
- }
-
- /**
- * {@inheritDoc}
- */
- public function formatEmail() {
- $template = $this->emailMode->getTextTemplate();
-
- foreach ( $this->emailMode->getComponent() as $val ) {
- $func = 'build' . ucfirst( $val );
- $template = str_replace( "%%$val%%", $this->emailMode->$func(), $template );
- }
-
- // Remove redundant newline characters
- return $this->removeExtraNewLine( $template );
- }
-
- /**
- * Remove extra newline from a text content
- * @param $text string
- * @return string
- */
- protected function removeExtraNewLine( $text ) {
- return preg_replace( "/\n{3,}/", "\n\n", $text );
- }
-
-}
-
-/**
- * Formatter class for formatting HTML email notification
- */
-class EchoHTMLEmailFormatter extends EchoEmailFormatter {
-
- /**
- * @param $emailMode EchoEmailMode
- */
- public function __construct( EchoEmailMode $emailMode ) {
- parent::__construct( $emailMode );
- $this->emailMode->attachDecorator( new EchoHTMLEmailDecorator() );
- }
-
- /**
- * {@inheritDoc}
- */
- public function formatEmail() {
- $template = $this->emailMode->getHTMLTemplate();
-
- foreach ( $this->emailMode->getComponent() as $val ) {
- $func = 'build' . ucfirst( $val );
- $template = str_replace( "%%$val%%", $this->emailMode->$func(), $template );
- }
-
- return $template;
- }
-}
-
-/**
- * Abstract entity that represents an email delivery mode
- */
-abstract class EchoEmailMode {
-
- /**
- * @var array
- * Email components
- */
- protected $component;
-
- /**
- * @var User
- * The user who receives email notifications
- */
- protected $user;
-
- /**
- * @var EchoEmailDecorator
- * Email decorator
- */
- protected $decorator;
-
- /**
- * @var Language
- * The language object for the user language
- */
- protected $lang;
-
- /**
- * @param $user User
- * @param $component array
- */
- public function __construct( User $user, array $component ) {
- $this->user = $user;
- // All email delivery mode share the same footer
- $this->component = array_merge( $component, array( 'footer' ) );
- // Initialize with a text decorator, the decorator can be altered
- // via attachDecorator() based on text/html emails
- $this->decorator = new EchoTextEmailDecorator();
- $this->lang = Language::factory( $user->getOption( 'language' ) );
- }
-
- /**
- * Get text email template
- * @return string
- */
- abstract public function getTextTemplate();
-
- /**
- * Get html email template
- * @return string
- */
- abstract public function getHTMLTemplate();
-
- /**
- * Get the footer component
- * @return string
- */
- public function buildFooter() {
- global $wgEchoEmailFooterAddress;
- return $this->decorator->decorateFooter( $wgEchoEmailFooterAddress, $this->user );
- }
-
- /**
- * Getter method for email template component
- * @return array
- */
- public function getComponent() {
- return $this->component;
- }
-
- /**
- * Get the notification icon path
- * @param $icon string
- * @return string
- */
- public static function getNotifIcon( $icon ) {
- global $wgLang;
-
- $iconUrl = EchoNotificationFormatter::getIconUrl( $icon, $wgLang->getDir() );
-
- return wfExpandUrl( $iconUrl, PROTO_CANONICAL );
- }
-
- /**
- * Attach an email decorator to the email mode object
- * @param $decorator EchoEmailDecorator
- */
- public function attachDecorator( EchoEmailDecorator $decorator ) {
- $this->decorator = $decorator;
- }
-
- /**
- * Format the message in the user's language
- * @param $message string
- * @param $user User
- * @return Message
- */
- public static function message( $message, User $user ) {
- return wfMessage( $message )->inLanguage( $user->getOption( 'language' ) );
- }
-
-}
-
-/**
- * Entity that represents a single email delivery mode
- */
-class EchoEmailSingle extends EchoEmailMode {
-
- /**
- * @var EchoBasicFormatter
- */
- protected $notifFormatter;
-
- /**
- * @var EchoEvent
- */
- protected $event;
-
- /**
- * @param $notifFormatter EchoBasicFormatter
- * @param $event EchoEvent
- * @param $user User
- */
- public function __construct( EchoBasicFormatter $notifFormatter, EchoEvent $event, User $user ) {
- parent::__construct( $user, array ( 'emailIcon', 'intro', 'summary', 'action' ) );
- $this->notifFormatter = $notifFormatter;
- $this->event = $event;
- }
-
- /**
- * Build the intro component
- * @return string
- */
- public function buildIntro() {
- $bundle = $this->notifFormatter->getValue( 'bundleData' );
- $email = $this->notifFormatter->getValue( 'email' );
-
- if ( $bundle['use-bundle'] && $email['batch-bundle-body']['message'] ) {
- $detail = $email['batch-bundle-body'];
- } else {
- $detail = $email['batch-body'];
- }
-
- $message = $this->notifFormatter->formatFragment(
- $detail,
- $this->event,
- $this->user
- );
-
- return $this->decorator->decorateIntro( $message );
- }
-
- /**
- * Build the summary component
- * @return string
- */
- public function buildSummary() {
- return $this->decorator->decorateRevisionSnippet(
- $this->notifFormatter->getRevisionSnippet(
- $this->event,
- $this->user
- )
- );
- }
-
- /**
- * Build the action component
- * @return string
- */
- public function buildAction() {
- $link = array();
- $ranks = array( 'primary', 'secondary' );
-
- foreach ( $ranks as $rank ) {
- $message = $this->event->getLinkMessage( $rank );
-
- // Valid call to action should have link text
- if ( !$message ) {
- continue;
- }
-
- $link[] = $this->decorator->decorateSingleAction(
- $this->notifFormatter,
- $this->event,
- $this->user,
- $rank,
- $message
- );
- }
-
- // Add some spacing between the two action links
- $spacing = $this->decorator->getActionLinkSeparator();
- return implode( $spacing . $spacing, $link );
- }
-
- /**
- * Build the email icon component
- * @return string
- */
- public function buildEmailIcon() {
- return EchoEmailMode::getNotifIcon( $this->notifFormatter->getValue( 'icon' ) );
- }
-
- /**
- * {@inheritDoc}
- */
- public function getTextTemplate() {
- return <<< EOF
-%%intro%%
-
-%%summary%%
-
-%%action%%
-
-%%footer%%
-EOF;
- }
-
- /**
- * {@inheritDoc}
- */
- public function getHTMLTemplate() {
- $alignStart = $this->lang->alignStart();
- return <<< EOF
-<html><head>
- <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <style>
- @media only screen and (max-width: 480px){
- table[id="email-container"]{max-width:600px !important; width:100% !important;}
- }
- </style>
-</head><body>
-<table cellspacing="0" cellpadding="0" border="0" width="100%" align="center" lang="{$this->lang->getCode()}" dir="{$this->lang->getDir()}">
-<tr>
- <td bgcolor="#E6E7E8"><center>
- <br /><br />
- <table cellspacing="0" cellpadding="0" border="0" width="600" id="email-container">
- <tr>
- <td bgcolor="#FFFFFF" width="5%">&nbsp;</td>
- <td bgcolor="#FFFFFF" width="10%">&nbsp;</td>
- <td bgcolor="#FFFFFF" width="80%" style="line-height:40px;">&nbsp;</td>
- <td bgcolor="#FFFFFF" width="5%">&nbsp;</td>
- </tr><tr>
- <td bgcolor="#FFFFFF" rowspan="2">&nbsp;</td>
- <td bgcolor="#FFFFFF" align="center" valign="top" rowspan="2"><img src="%%emailIcon%%" alt="" height="30" width="30"></td>
- <td bgcolor="#FFFFFF" align="{$alignStart}" style="font-family: Arial, Helvetica, sans-serif; font-size:13px; line-height:20px; color:#6D6E70;">%%intro%%</td>
- <td bgcolor="#FFFFFF" rowspan="2">&nbsp;</td>
- </tr><tr>
- <td bgcolor="#FFFFFF" align="{$alignStart}" style="font-family: Arial, Helvetica, sans-serif; line-height: 20px; font-weight: 600;">
- <table cellspacing="0" cellpadding="0" border="0">
- <tr>
- <td bgcolor="#FFFFFF" align="{$alignStart}" style="font-family: Arial, Helvetica, sans-serif; padding-top: 8px; font-size:13px; font-weight: bold; color: #58585B;">
- %%summary%%
- </td>
- </tr>
- </table>
- <table cellspacing="0" cellpadding="0" border="0">
- <tr>
- <td bgcolor="#FFFFFF" align="{$alignStart}" style="font-family: Arial, Helvetica, sans-serif; font-size:14px; padding-top: 25px;">
- %%action%%
- </td>
- </tr>
- </table>
- </td>
- </tr><tr>
- <td bgcolor="#FFFFFF">&nbsp;</td>
- <td bgcolor="#FFFFFF">&nbsp;</td>
- <td bgcolor="#FFFFFF" style="line-height:40px;">&nbsp;</td>
- <td bgcolor="#FFFFFF">&nbsp;</td>
- </tr><tr>
- <td>&nbsp;</td>
- <td>&nbsp;</td>
- <td align="{$alignStart}" style="font-family: Arial, Helvetica, sans-serif; font-size:10px; line-height:13px; color:#6D6E70; padding:10px 20px;"><br />
- %%footer%%
- <br /><br />
- </td>
- <td>&nbsp;</td>
- </tr><tr>
- <td colspan="4">&nbsp;</td>
- </tr>
- </table>
- <br><br></center>
- </td>
-</tr>
-</table>
-</body></html>
-EOF;
- }
-
-}
-
-/**
- * Class that represents email digest delivery mode
- */
-class EchoEmailDigest extends EchoEmailMode {
-
- /**
- * @var string
- * The mode of email digest, 'weekly' or 'daily'
- */
- protected $digestMode;
-
- /**
- * @var array
- * Raw email digest list
- */
- protected $rawDigestList;
-
- /**
- * @param $user User
- * @param $rawDigestList array the raw notification event list
- * @param $digestMode string 'daily'/'weekly'
- */
- public function __construct( User $user, array $rawDigestList, $digestMode = 'daily' ) {
- parent::__construct( $user, array( 'intro', 'digestList', 'action' ) );
- // Some data validation
- if ( !in_array( $digestMode, array( 'daily', 'weekly' ) ) ) {
- $digestMode = 'daily';
- }
- $this->digestMode = $digestMode;
- $this->rawDigestList = $rawDigestList;
- }
-
- /**
- * Build the intro component
- * @return string
- */
- public function buildIntro() {
- // Give grep a chance to find the usages:
- // echo-email-batch-body-intro-daily, echo-email-batch-body-intro-weekly
- $message = EchoEmailMode::message(
- 'echo-email-batch-body-intro-' . $this->digestMode, $this->user
- )->params( $this->user->getName() );
-
- return $this->decorator->decorateIntro( $message );
- }
-
- /**
- * Build the digestList component
- * @return string
- */
- public function buildDigestList() {
- if ( !$this->rawDigestList ) {
- return '';
- }
-
- return $this->decorator->decorateDigestList( $this->rawDigestList, $this->user );
- }
-
- /**
- * Build the action component
- * @return string
- */
- public function buildAction() {
- $title = SpecialPage::getTitleFor( 'Notifications' );
-
- return $this->decorator->decorateDigestAction( $title, $this->user );
- }
-
- /**
- * {@inheritDoc}
- */
- public function getTextTemplate() {
- return <<< EOF
-%%intro%%
-
-%%digestList%%
-
-%%action%%
-
-%%footer%%
-
-EOF;
- }
-
- /**
- * {@inheritDoc}
- */
- public function getHTMLTemplate() {
- $alignStart = $this->lang->alignStart();
- return <<< EOF
-<html><head>
- <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <style>
- @media only screen and (max-width: 480px){
- table[id="email-container"]{max-width:600px !important; width:100% !important;}
- }
- </style>
-</head><body>
-<table cellspacing="0" cellpadding="0" border="0" width="100%" align="center" lang="{$this->lang->getCode()}" dir="{$this->lang->getDir()}">
-<tr>
- <td bgcolor="#E6E7E8"><center>
- <br /><br />
- <table cellspacing="0" cellpadding="0" border="0" width="600" id="email-container">
- <tr>
- <td bgcolor="#FFFFFF" width="5%">&nbsp;</td>
- <td bgcolor="#FFFFFF" width="6%">&nbsp;</td>
- <td bgcolor="#FFFFFF" width="79%" style="line-height:40px;">&nbsp;</td>
- <td bgcolor="#FFFFFF" width="10%">&nbsp;</td>
- </tr>
- <tr>
- <td bgcolor="#FFFFFF" rowspan="2">&nbsp;</td>
- <td bgcolor="#FFFFFF" rowspan="2">&nbsp;</td>
- <td bgcolor="#FFFFFF" align="center" style="font-family: Arial, Helvetica, sans-serif; font-size:13px; line-height:20px; color:#6D6E70; text-align: center;">%%intro%%</td>
- <td bgcolor="#FFFFFF" rowspan="2">&nbsp;</td>
- </tr>
- <tr>
- <td bgcolor="#FFFFFF" align="{$alignStart}" style="font-family: Arial, Helvetica, sans-serif; line-height: 20px; font-weight: 600;">
- <table cellspacing="0" cellpadding="0" border="0" width="100%">
- <tr>
- <td bgcolor="#FFFFFF" align="{$alignStart}" style="font-family: Arial, Helvetica, sans-serif; font-size:13px; color: #58585B; padding-top: 25px;">
- %%digestList%%
- </td>
- </tr>
- </table>
- <br /><br />
- </td>
- </tr>
- <tr>
- <td bgcolor="#FFFFFF">&nbsp;</td>
- <td bgcolor="#FFFFFF">&nbsp;</td>
- <td bgcolor="#FFFFFF" style="line-height:60px;" align="center">%%action%%</td>
- <td bgcolor="#FFFFFF">&nbsp;</td>
- </tr>
- <tr>
- <td bgcolor="#FFFFFF">&nbsp;</td>
- <td bgcolor="#FFFFFF">&nbsp;</td>
- <td bgcolor="#FFFFFF" style="line-height:40px;">&nbsp;</td>
- <td bgcolor="#FFFFFF">&nbsp;</td>
- </tr>
- <tr>
- <td>&nbsp;</td>
- <td>&nbsp;</td>
- <td align="{$alignStart}" style="font-family: Arial, Helvetica, sans-serif; font-size:10px; line-height:13px; color:#6D6E70; padding: 10px 20px;"><br />
- %%footer%%
- <br /><br />
- </td>
- <td>&nbsp;</td>
- </tr>
- <tr>
- <td colspan="4">&nbsp;</td>
- </tr>
- </table>
- <br><br></center>
- </td>
-</tr>
-</table>
-</body></html>
-EOF;
- }
-
-}
-
-/**
- * Email decorator interface
- */
-interface EchoEmailDecorator {
- /**
- * Decorate the intro for all modes
- * @param $message Message the intro message object
- * @return string
- */
- public function decorateIntro( $message );
-
- /**
- * Decorate the digest list for digest mode
- * @param $digestList array
- * @param $user User
- * @return string
- */
- public function decorateDigestList( $digestList, User $user );
-
- /**
- * Decorate the primary action for digest mode
- * @param $title Title
- * @param $user User
- * @return string
- */
- public function decorateDigestAction( $title, User $user );
-
- /**
- * Decorate the footer for all mode
- * @param $address string
- * @param $user User
- * @return string
- */
- public function decorateFooter( $address, User $user );
-
- /**
- * Decorate the actions for single mode
- * @param $notifFormatter EchoBasicFormatter
- * @param $event EchoEvent
- * @param $user User
- * @param $rank string
- * @param $message string
- * @return string
- */
- public function decorateSingleAction( $notifFormatter, EchoEvent $event, User $user, $rank, $message );
-
- /**
- * Decorate a revision snippet
- * @param string $snippet the raw revision snippet
- * @return string
- */
- public function decorateRevisionSnippet( $snippet );
-
- /**
- * Get the spacing for between action links
- * @return string
- */
- public function getActionLinkSeparator();
-}
-
-/**
- * Text email decorator
- */
-class EchoTextEmailDecorator implements EchoEmailDecorator {
-
- /**
- * {@inheritDoc}
- */
- public function decorateIntro( $message ) {
- return $message->text();
- }
-
- /**
- * {@inheritDoc}
- */
- public function decorateDigestList( $digestList, User $user ) {
- $result = array();
-
- // build the text section for each category
- foreach( $digestList as $category => $notifs ) {
- $output = EchoEmailMode::message( 'echo-category-title-' . $category, $user )->numParams( count( $notifs ) )->text()
- . EchoEmailMode::message( 'colon-separator', $user )->text() . "\n";
-
- foreach( $notifs as $notif ) {
- $output .= "\n " . EchoEmailMode::message( 'echo-email-batch-bullet', $user )->text() . ' ' . $notif['batch-body'];
- }
- $result[] = $output;
- }
-
- // for prepending and appending 'echo-email-batch-separator'
- $result = array_merge( array( '' ), $result, array( '' ) );
-
- return trim(
- implode(
- "\n\n" . EchoEmailMode::message( 'echo-email-batch-separator', $user )->text() . "\n\n",
- $result
- )
- );
- }
-
- /**
- * {@inheritDoc}
- */
- public function decorateDigestAction( $title, User $user ) {
- return EchoEmailMode::message( 'echo-email-batch-link-text-view-all-notifications', $user )->text()
- . EchoEmailMode::message( 'colon-separator', $user )->text()
- . '<'
- . $title->getFullURL( '', false, PROTO_CANONICAL )
- . '>';
- }
-
- /**
- * {@inheritDoc}
- */
- public function decorateFooter( $address, User $user ) {
- return EchoEmailMode::message( 'echo-email-footer-default', $user )
- ->params(
- $address,
- EchoEmailMode::message( 'echo-email-batch-separator', $user )->text()
- )
- ->text();
- }
-
- /**
- * {@inheritDoc}
- */
- public function decorateSingleAction( $notifFormatter, EchoEvent $event, User $user, $rank, $message ) {
- $url = $notifFormatter->getLink( $event, $user, $rank, false, true );
-
- return EchoEmailMode::message( $message, $user )->text()
- . EchoEmailMode::message( 'colon-separator', $user )->text()
- . '<'
- . $notifFormatter->sanitizeEmailLink( $url )
- . '>';
- }
-
- /**
- * {@inheritDoc}
- */
- public function decorateRevisionSnippet( $snippet ) {
- // Doing nothing now, but there is a potential to wrap the text
- // around snippet with quote in plain text email
- return $snippet;
- }
-
- /**
- * {@inheritDoc}
- */
- public function getActionLinkSeparator() {
- return "\n";
- }
-}
-
-/**
- * HTML email decorator
- */
-class EchoHTMLEmailDecorator implements EchoEmailDecorator {
-
- /**
- * {@inheritDoc}
- */
- public function decorateIntro( $message ) {
- return nl2br( $message->parse() );
- }
-
- /**
- * {@inheritDoc}
- */
- public function decorateDigestList( $digestList, User $user ) {
- $result = array();
- // build the html section for each category
- foreach( $digestList as $category => $notifs ) {
- $output = $this->applyStyleToCategory(
- EchoEmailMode::message( 'echo-category-title-' . $category, $user )
- ->numParams( count( $notifs ) )
- ->escaped()
- );
- foreach( $notifs as $notif ) {
- $output .= "\n" . $this->applyStyleToEvent( $notif );
- }
- $result[] = '<table border="0" width="100%">' . $output . '</table>';
- }
-
- return trim( implode( "\n", $result ) );
- }
-
- /**
- * {@inheritDoc}
- */
- public function decorateDigestAction( $title, User $user ) {
- /*
- * Linker::link() will try to figure out if $title already exists
- * (Title::isKnown) and alter the link depending on the outcome
- * (&action=edit&redlink=1)
- * Notifications are usually triggered by new content, so we better
- * make damn sure that slave lag doesn't mess that up. Especially
- * in emails, which we can't rerender once they've been sent.
- * I'll force the status for this $title to be read from master, so
- * Linker::link is guaranteed to get the correct exists() result.
- */
- $title->exists( wfGetLB()->hasOrMadeRecentMasterChanges() ? Title::GAID_FOR_UPDATE : 0 );
-
- return Linker::link(
- $title,
- EchoEmailMode::message( 'echo-email-batch-link-text-view-all-notifications', $user )->escaped(),
- array( 'style' => $this->getPrimaryLinkCSS() ),
- array(),
- array( 'https' )
- );
- }
-
- /**
- * {@inheritDoc}
- */
- public function decorateFooter( $address, User $user ) {
- $title = SpecialPage::getTitleFor( 'Preferences' );
- $title->setFragment( "#mw-prefsection-echo" );
- return EchoEmailMode::message( 'echo-email-footer-default-html', $user )
- ->params( $address )
- ->rawParams( $title->getFullURL( '', false, PROTO_HTTPS ) )
- ->text();
- }
-
- /**
- * {@inheritDoc}
- */
- public function decorateSingleAction( $notifFormatter, EchoEvent $event, User $user, $rank, $message ) {
- if ( $rank === 'primary' ) {
- $style = $this->getPrimaryLinkCSS();
- } else {
- $style = $this->getSecondaryLinkCSS();
- }
-
- return $notifFormatter->getLink( $event, $user, $rank, false, false, $style );
- }
-
- /**
- * {@inheritDoc}
- */
- public function decorateRevisionSnippet( $snippet ) {
- return htmlspecialchars( $snippet );
- }
-
- /**
- * {@inheritDoc}
- */
- public function getActionLinkSeparator() {
- return "&nbsp;";
- }
-
- /**
- * The style for primary link
- * @return string
- */
- protected function getPrimaryLinkCSS() {
- return 'cursor:pointer; text-align:center; text-decoration:none; padding:.45em 1.2em .45em;
- color:#D9EEF7; background:#3366BB; font-family: Arial, Helvetica, sans-serif;font-size: 13px;';
- }
-
- /**
- * The style for secondary link
- * @return string
- */
- protected function getSecondaryLinkCSS() {
- return 'text-decoration: none;font-size: 10px;font-family: Arial, Helvetica, sans-serif; color: #808184';
- }
-
- /**
- * Apply style to notification category header
- * @param $category string
- * @return string
- */
- protected function applyStyleToCategory( $category ) {
- return <<< EOF
-<tr>
- <td colspan="2" style="color: #A87B4F; font-weight: normal; font-size: 13px; padding-top: 15px;">
- $category <br />
- <hr style="background-color:#FFFFFF; color:#FFFFFF; border: 1px solid #F2F2F2;" />
- </td>
-</tr>
-EOF;
- }
-
- /**
- * Apply style to individual notification event
- * @param $notif array an array containts keys: icon, batch-body, batch-body-html
- * @return string
- */
- protected function applyStyletoEvent( $notif ) {
- // notification icon
- $icon = EchoEmailMode::getNotifIcon( $notif['icon'] );
- // notification text
- $text = $notif['batch-body-html'];
-
- return <<< EOF
-<tr>
- <td width="30">
- <img src="$icon" width="30" height="30" style="vertical-align:middle;">
- </td>
- <td style="font-family: Arial, Helvetica, sans-serif; font-size:13px; color: #58585B;">
- $text
- </td>
-</tr>
-EOF;
- }
-
-}
-
diff --git a/Echo/includes/EmailFrequency.php b/Echo/includes/EmailFrequency.php
new file mode 100644
index 00000000..1dd1ba8b
--- /dev/null
+++ b/Echo/includes/EmailFrequency.php
@@ -0,0 +1,8 @@
+<?php
+
+class EchoEmailFrequency {
+ const NEVER = -1; // Never send email notifications
+ const IMMEDIATELY = 0; // Send email notifications immediately as they come in
+ const DAILY_DIGEST = 1; // Send daily email digests
+ const WEEKLY_DIGEST = 7; // Send weekly email digests
+}
diff --git a/Echo/includes/EventLogging.php b/Echo/includes/EventLogging.php
index 753b9a92..c7f0ba52 100644
--- a/Echo/includes/EventLogging.php
+++ b/Echo/includes/EventLogging.php
@@ -7,27 +7,34 @@ class MWEchoEventLogging {
/**
* This is the only function that interacts with EventLogging
- * @param $schema string
- * @param $data array
+ *
+ * Adds common fields, and logs if logging is enabled for the given $schema.
+ *
+ * @param string $schema
+ * @param array $data
*/
- public static function actuallyLogTheEvent( $schema, $data ) {
- global $wgEchoConfig;
+ protected static function logEvent( $schema, array $data ) {
+ global $wgEchoEventLoggingSchemas, $wgEchoEventLoggingVersion;
- EventLogging::logEvent( $schema, $wgEchoConfig['eventlogging'][$schema]['revision'], $data );
+ $schemaConfig = $wgEchoEventLoggingSchemas[$schema];
+ if ( !$schemaConfig['enabled'] ) {
+ // If logging for this schema is disabled, it's a no-op.
+ return;
+ }
+
+ $data['version'] = $wgEchoEventLoggingVersion;
+
+ EventLogging::logEvent( $schema, $schemaConfig['revision'], $data );
}
/**
* Function for logging the event for Schema:Echo
- * @param $user User being notified.
- * @param $event EchoEvent to log detail about.
- * @param $deliveryMethod string containing either 'web' or 'email'
+ * @param User $user User being notified.
+ * @param EchoEvent $event Event to log detail about.
+ * @param string $deliveryMethod 'web' or 'email'
*/
public static function logSchemaEcho( User $user, EchoEvent $event, $deliveryMethod ) {
- global $wgEchoConfig, $wgEchoNotifications;
- if ( !$wgEchoConfig['eventlogging']['Echo']['enabled'] ) {
- // Only attempt event logging if Echo schema is enabled
- return;
- }
+ global $wgEchoNotifications;
// Notifications under system category should have -1 as sender id
if ( $event->getCategory() === 'system' ) {
@@ -46,21 +53,20 @@ class MWEchoEventLogging {
} else {
$group = 'neutral';
}
- $data = array (
- 'version' => $wgEchoConfig['version'],
- 'eventId' => $event->getId(),
+ $data = [
+ 'eventId' => (int)$event->getId(),
'notificationType' => $event->getType(),
'notificationGroup' => $group,
'sender' => (string)$sender,
'recipientUserId' => $user->getId(),
'recipientEditCount' => (int)$user->getEditCount()
- );
+ ];
// Add the source if it exists. (This is mostly for the Thanks extension.)
$extra = $event->getExtra();
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
@@ -72,29 +78,40 @@ class MWEchoEventLogging {
$data['revisionId'] = $rev->getId();
}
- self::actuallyLogTheEvent( 'Echo', $data );
+ self::logEvent( 'Echo', $data );
}
/**
* Function for logging the event for Schema:EchoEmail
- * @param $user User
- * @param $emailDeliveryMode string
+ * @param User $user
+ * @param string $emailDeliveryMode 'single' (default), 'daily_digest', or 'weekly_digest'
*/
public static function logSchemaEchoMail( User $user, $emailDeliveryMode = 'single' ) {
- global $wgEchoConfig;
-
- if ( !$wgEchoConfig['eventlogging']['EchoMail']['enabled'] ) {
- // Only attempt event logging if EchoMail schema is enabled
- return;
- }
-
- $data = array (
- 'version' => $wgEchoConfig['version'],
+ $data = [
'recipientUserId' => $user->getId(),
'emailDeliveryMode' => $emailDeliveryMode
- );
+ ];
+
+ self::logEvent( 'EchoMail', $data );
+ }
- self::actuallyLogTheEvent( 'EchoMail', $data );
+ /**
+ * @param User $user
+ * @param string $skinName
+ */
+ public static function logSpecialPageVisit( User $user, $skinName ) {
+ self::logEvent(
+ 'EchoInteraction',
+ [
+ 'context' => 'archive',
+ 'action' => 'special-page-visit',
+ 'userId' => (int)$user->getId(),
+ 'editCount' => (int)$user->getEditCount(),
+ 'notifWiki' => wfWikiID(),
+ // Hack: Figure out if we are in the mobile skin
+ 'mobile' => $skinName === 'minerva',
+ ]
+ );
}
}
diff --git a/Echo/includes/ForeignNotifications.php b/Echo/includes/ForeignNotifications.php
new file mode 100644
index 00000000..9b3846ac
--- /dev/null
+++ b/Echo/includes/ForeignNotifications.php
@@ -0,0 +1,254 @@
+<?php
+
+/**
+ * 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 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.
+ */
+class EchoForeignNotifications {
+ /**
+ * @var User
+ */
+ protected $user;
+
+ /**
+ * @var bool
+ */
+ protected $enabled = false;
+
+ /**
+ * @var array [(str) section => (int) count, ...]
+ */
+ protected $counts = [ EchoAttributeManager::ALERT => 0, EchoAttributeManager::MESSAGE => 0 ];
+
+ /**
+ * @var array [(str) section => (string[]) wikis, ...]
+ */
+ protected $wikis = [ EchoAttributeManager::ALERT => [], EchoAttributeManager::MESSAGE => [] ];
+
+ /**
+ * @var array [(str) section => (MWTimestamp) timestamp, ...]
+ */
+ protected $timestamps = [ EchoAttributeManager::ALERT => false, EchoAttributeManager::MESSAGE => false ];
+
+ /**
+ * @var array [(str) wiki => [ (str) section => (MWTimestamp) timestamp, ...], ...]
+ */
+ protected $wikiTimestamps = [];
+
+ /**
+ * @var bool
+ */
+ protected $populated = false;
+
+ /**
+ * @param User $user
+ * @param bool $forceEnable Ignore the user's preferences and act as if they've enabled cross-wiki notifications
+ */
+ public function __construct( User $user, $forceEnable = false ) {
+ $this->user = $user;
+ $this->enabled = $forceEnable || $this->isEnabledByUser();
+ }
+
+ /**
+ * Whether the user has enabled cross-wiki notifications.
+ * @return bool
+ */
+ public function isEnabledByUser() {
+ return (bool)$this->user->getOption( 'echo-cross-wiki-notifications' );
+ }
+
+ /**
+ * @param string $section Name of section
+ * @return int
+ */
+ public function getCount( $section = EchoAttributeManager::ALL ) {
+ $this->populate();
+
+ if ( $section === EchoAttributeManager::ALL ) {
+ $count = array_sum( $this->counts );
+ } else {
+ $count = isset( $this->counts[$section] ) ? $this->counts[$section] : 0;
+ }
+
+ return MWEchoNotifUser::capNotificationCount( $count );
+ }
+
+ /**
+ * @param string $section Name of section
+ * @return MWTimestamp|false
+ */
+ public function getTimestamp( $section = EchoAttributeManager::ALL ) {
+ $this->populate();
+
+ if ( $section === EchoAttributeManager::ALL ) {
+ $max = false;
+ /** @var MWTimestamp $timestamp */
+ foreach ( $this->timestamps as $timestamp ) {
+ // $timestamp < $max = invert 0
+ // $timestamp > $max = invert 1
+ if ( $timestamp !== false && ( $max === false || $timestamp->diff( $max )->invert === 1 ) ) {
+ $max = $timestamp;
+ }
+ }
+
+ return $max;
+ }
+
+ return isset( $this->timestamps[$section] ) ? $this->timestamps[$section] : false;
+ }
+
+ /**
+ * @param string $section Name of section
+ * @return string[]
+ */
+ public function getWikis( $section = EchoAttributeManager::ALL ) {
+ $this->populate();
+
+ if ( $section === EchoAttributeManager::ALL ) {
+ $all = [];
+ foreach ( $this->wikis as $wikis ) {
+ $all = array_merge( $all, $wikis );
+ }
+
+ return array_unique( $all );
+ }
+
+ return isset( $this->wikis[$section] ) ? $this->wikis[$section] : [];
+ }
+
+ public function getWikiTimestamp( $wiki, $section = EchoAttributeManager::ALL ) {
+ $this->populate();
+ if ( !isset( $this->wikiTimestamps[$wiki] ) ) {
+ return false;
+ }
+ if ( $section === EchoAttributeManager::ALL ) {
+ $max = false;
+ foreach ( $this->wikiTimestamps[$wiki] as $section => $ts ) {
+ // $ts < $max = invert 0
+ // $ts > $max = invert 1
+ if ( $max === false || $ts->diff( $max )->invert === 1 ) {
+ $max = $ts;
+ }
+ }
+ return $max;
+ }
+ return isset( $this->wikiTimestamps[$wiki][$section] ) ? $this->wikiTimestamps[$wiki][$section] : false;
+ }
+
+ protected function populate() {
+ if ( $this->populated ) {
+ return;
+ }
+
+ if ( !$this->enabled ) {
+ return;
+ }
+
+ $unreadWikis = EchoUnreadWikis::newFromUser( $this->user );
+ if ( !$unreadWikis ) {
+ return;
+ }
+ $unreadCounts = $unreadWikis->getUnreadCounts();
+ if ( !$unreadCounts ) {
+ return;
+ }
+
+ foreach ( $unreadCounts as $wiki => $sections ) {
+ // exclude current wiki
+ if ( $wiki === wfWikiID() ) {
+ continue;
+ }
+
+ foreach ( $sections as $section => $data ) {
+ if ( $data['count'] > 0 ) {
+ $this->counts[$section] += $data['count'];
+ $this->wikis[$section][] = $wiki;
+
+ $timestamp = new MWTimestamp( $data['ts'] );
+ $this->wikiTimestamps[$wiki][$section] = $timestamp;
+
+ // We need $this->timestamp[$section] to be the max timestamp
+ // across all wikis.
+ // $timestamp < $this->timestamps[$section] = invert 0
+ // $timestamp > $this->timestamps[$section] = invert 1
+ if (
+ $this->timestamps[$section] === false ||
+ $timestamp->diff( $this->timestamps[$section] )->invert === 1
+ ) {
+ $this->timestamps[$section] = new MWTimestamp( $data['ts'] );
+ }
+
+ }
+ }
+ }
+
+ $this->populated = true;
+ }
+
+ /**
+ * @param string[] $wikis
+ * @return array[] [(string) wiki => (array) data]
+ */
+ public static function getApiEndpoints( array $wikis ) {
+ global $wgConf;
+ $wgConf->loadFullData();
+
+ $data = [];
+ foreach ( $wikis as $wiki ) {
+ $siteFromDB = $wgConf->siteFromDB( $wiki );
+ list( $major, $minor ) = $siteFromDB;
+ $server = $wgConf->get( 'wgServer', $wiki, $major, [ 'lang' => $minor, 'site' => $major ] );
+ $scriptPath = $wgConf->get( 'wgScriptPath', $wiki, $major, [ 'lang' => $minor, 'site' => $major ] );
+ $articlePath = $wgConf->get( 'wgArticlePath', $wiki, $major, [ 'lang' => $minor, 'site' => $major ] );
+
+ $data[$wiki] = [
+ 'title' => static::getWikiTitle( $wiki, $siteFromDB ),
+ 'url' => wfExpandUrl( $server . $scriptPath . '/api.php', PROTO_INTERNAL ),
+ // We need this to link to Special:Notifications page
+ 'base' => wfExpandUrl( $server . $articlePath, PROTO_INTERNAL ),
+ ];
+ }
+
+ return $data;
+ }
+
+ /**
+ * @param string $wikiId
+ * @param array $siteFromDB $wgConf->siteFromDB( $wikiId ) result
+ * @return mixed|string
+ */
+ protected static function getWikiTitle( $wikiId, array $siteFromDB = null ) {
+ global $wgConf, $wgLang;
+
+ $msg = wfMessage( 'project-localized-name-'.$wikiId );
+ // check if WikimediaMessages localized project names are available
+ if ( $msg->exists() ) {
+ return $msg->text();
+ } else {
+ // don't fetch $site, $langCode if known already
+ if ( $siteFromDB === null ) {
+ $siteFromDB = $wgConf->siteFromDB( $wikiId );
+ }
+ list( $site, $langCode ) = $siteFromDB;
+
+ // try to fetch site name for this specific wiki, or fallback to the
+ // general project's sitename if there is no override
+ $wikiName = $wgConf->get( 'wgSitename', $wikiId ) ?: $wgConf->get( 'wgSitename', $site );
+ $langName = Language::fetchLanguageName( $langCode, $wgLang->getCode() );
+
+ if ( !$langName ) {
+ // if we can't find a language name (in language-agnostic
+ // project like mediawikiwiki), including the language name
+ // doesn't make much sense
+ return $wikiName;
+ }
+
+ // ... or use generic fallback
+ return wfMessage( 'echo-foreign-wiki-lang', $wikiName, $langName )->text();
+ }
+ }
+}
diff --git a/Echo/includes/ForeignWikiRequest.php b/Echo/includes/ForeignWikiRequest.php
new file mode 100644
index 00000000..c79ccdd7
--- /dev/null
+++ b/Echo/includes/ForeignWikiRequest.php
@@ -0,0 +1,164 @@
+<?php
+
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\Session\SessionManager;
+
+class EchoForeignWikiRequest {
+
+ /**
+ * @param User $user User object
+ * @param array $params Request parameters
+ * @param array $wikis Wikis to send the request to
+ * @param string $wikiParam Parameter name to set to the name of the wiki
+ */
+ public function __construct( User $user, array $params, array $wikis, $wikiParam = null ) {
+ $this->user = $user;
+ $this->params = $params;
+ $this->wikis = $wikis;
+ $this->wikiParam = $wikiParam;
+ }
+
+ /**
+ * Execute the request
+ * @return array [ wiki => result ]
+ */
+ public function execute() {
+ if ( !$this->canUseCentralAuth() ) {
+ return [];
+ }
+
+ $reqs = $this->getRequestParams();
+ return $this->doRequests( $reqs );
+ }
+
+ protected function getCentralId( $user ) {
+ $lookup = CentralIdLookup::factory();
+ $id = $lookup->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW );
+ return $id;
+ }
+
+ protected function canUseCentralAuth() {
+ global $wgFullyInitialised, $wgUser;
+
+ return $wgFullyInitialised &&
+ $wgUser->isSafeToLoad() &&
+ $this->user->isSafeToLoad() &&
+ SessionManager::getGlobalSession()->getProvider() instanceof CentralAuthSessionProvider &&
+ $this->getCentralId( $this->user ) !== 0;
+ }
+
+ /**
+ * Returns CentralAuth token, or null on failure.
+ *
+ * @param User $user
+ * @return string|null
+ */
+ protected function getCentralAuthToken( User $user ) {
+ $context = new RequestContext;
+ $context->setRequest( new FauxRequest( [ 'action' => 'centralauthtoken' ] ) );
+ $context->setUser( $user );
+
+ $api = new ApiMain( $context );
+
+ try {
+ $api->execute();
+
+ return $api->getResult()->getResultData( [ 'centralauthtoken', 'centralauthtoken' ] );
+ } catch ( Exception $ex ) {
+ LoggerFactory::getInstance( 'Echo' )->debug(
+ 'Exception when fetching CentralAuth token: wiki: {wiki}, userName: {userName}, userId: {userId}, centralId: {centralId}, exception: {exception}',
+ [
+ 'wiki' => wfWikiID(),
+ 'userName' => $user->getName(),
+ 'userId' => $user->getId(),
+ 'centralId' => $this->getCentralId( $user ),
+ 'exception' => $ex,
+ ]
+ );
+
+ MWExceptionHandler::logException( $ex );
+
+ return null;
+ }
+ }
+
+ /**
+ * @return array
+ */
+ protected function getRequestParams() {
+ $apis = EchoForeignNotifications::getApiEndpoints( $this->wikis );
+ if ( !$apis ) {
+ return [];
+ }
+
+ $reqs = [];
+ foreach ( $apis as $wiki => $api ) {
+ $reqs[$wiki] = [
+ 'method' => 'GET',
+ 'url' => $api['url'],
+ 'query' => $this->getQueryParams( $wiki ),
+ ];
+ }
+
+ return $reqs;
+ }
+
+ /**
+ * @param string $wiki Wiki name
+ * @return array
+ */
+ protected function getQueryParams( $wiki ) {
+ $extraParams = [];
+ if ( $this->wikiParam ) {
+ // Only request data from that specific wiki, or they'd all spawn
+ // cross-wiki api requests...
+ $extraParams[$this->wikiParam] = $wiki;
+ }
+
+ return [
+ 'centralauthtoken' => $this->getCentralAuthToken( $this->user ),
+ // once all the results are gathered & merged, they'll be output in the
+ // user requested format
+ // but this is going to be an internal request & we don't want those
+ // results in the format the user requested but in a fixed format that
+ // we can interpret here
+ 'format' => 'json',
+ ] + $extraParams + $this->params;
+ }
+
+ /**
+ * @param array $reqs API request params
+ * @return array
+ * @throws Exception
+ */
+ protected function doRequests( array $reqs ) {
+ $http = new MultiHttpClient( [] );
+ $responses = $http->runMulti( $reqs );
+
+ $results = [];
+ foreach ( $responses as $wiki => $response ) {
+ $statusCode = $response['response']['code'];
+
+ if ( $statusCode >= 200 && $statusCode <= 299 ) {
+ $parsed = json_decode( $response['response']['body'], true );
+ if ( $parsed ) {
+ $results[$wiki] = $parsed;
+ }
+ }
+
+ if ( !isset( $results[$wiki] ) ) {
+ LoggerFactory::getInstance( 'Echo' )->warning(
+ 'Failed to fetch API response from {wiki}. Error code {code}',
+ [
+ 'wiki' => $wiki,
+ 'code' => $response['response']['code'],
+ 'response' => $response['response']['body'],
+ 'request' => $reqs[$wiki],
+ ]
+ );
+ }
+ }
+
+ return $results;
+ }
+}
diff --git a/Echo/includes/NotifUser.php b/Echo/includes/NotifUser.php
index 722376d5..d55207c1 100644
--- a/Echo/includes/NotifUser.php
+++ b/Echo/includes/NotifUser.php
@@ -1,4 +1,5 @@
<?php
+use MediaWiki\MediaWikiServices;
/**
* Entity that represents a notification target user
@@ -13,7 +14,7 @@ class MWEchoNotifUser {
/**
* Object cache
- * @var BagOStuff
+ * @var WANObjectCache
*/
private $cache;
@@ -36,17 +37,41 @@ class MWEchoNotifUser {
private $targetPageMapper;
/**
+ * @var EchoForeignNotifications
+ */
+ private $foreignNotifications = null;
+
+ /**
+ * @var array
+ */
+ private $cached;
+
+ /**
+ * @var array|null
+ */
+ private $mForeignData = null;
+
+ // The max notification count shown in badge
+
+ // The max number shown in bundled message, eg, <user> and 99+ others <action>.
+ // This is really a totally separate thing, and could be its own constant.
+
+ // WARNING: If you change this, you should also change all references in the
+ // i18n messages (100 and 99) in all repositories using Echo.
+ const MAX_BADGE_COUNT = 99;
+
+ /**
* Usually client code doesn't need to initialize the object directly
* because it could be obtained from factory method newFromUser()
* @param User $user
- * @param BagOStuff $cache
+ * @param WANObjectCache $cache
* @param EchoUserNotificationGateway $userNotifGateway
* @param EchoNotificationMapper $notifMapper
* @param EchoTargetPageMapper $targetPageMapper
*/
public function __construct(
User $user,
- BagOStuff $cache,
+ WANObjectCache $cache,
EchoUserNotificationGateway $userNotifGateway,
EchoNotificationMapper $notifMapper,
EchoTargetPageMapper $targetPageMapper
@@ -60,7 +85,7 @@ class MWEchoNotifUser {
/**
* Factory method
- * @param $user User
+ * @param User $user
* @throws MWException
* @return MWEchoNotifUser
*/
@@ -68,9 +93,10 @@ class MWEchoNotifUser {
if ( $user->isAnon() ) {
throw new MWException( 'User must be logged in to view notification!' );
}
- global $wgMemc;
+
return new MWEchoNotifUser(
- $user, $wgMemc,
+ $user,
+ MediaWikiServices::getInstance()->getMainWANObjectCache(),
new EchoUserNotificationGateway( $user, MWEchoDbFactory::newFromDefault() ),
new EchoNotificationMapper(),
new EchoTargetPageMapper()
@@ -120,11 +146,11 @@ class MWEchoNotifUser {
/**
* Memcache key for talk notification
+ * @return string
*/
public function getTalkNotificationCacheKey() {
- global $wgEchoConfig;
-
- return wfMemcKey( 'echo-new-talk-notification', $this->mUser->getId(), $wgEchoConfig['version'] );
+ global $wgEchoCacheVersion;
+ return wfMemcKey( 'echo-new-talk-notification', $this->mUser->getId(), $wgEchoCacheVersion );
}
/**
@@ -132,9 +158,7 @@ class MWEchoNotifUser {
* @return bool
*/
public function notifCountHasReachedMax() {
- global $wgEchoMaxNotificationCount;
-
- if ( $this->getNotificationCount() > $wgEchoMaxNotificationCount ) {
+ if ( $this->getLocalNotificationCount() >= self::MAX_BADGE_COUNT ) {
return true;
} else {
return false;
@@ -144,7 +168,7 @@ class MWEchoNotifUser {
/**
* Get message count for this user.
*
- * @param boolean $cached Set to false to bypass the cache. (Optional. Defaults to true)
+ * @param bool $cached Set to false to bypass the cache. (Optional. Defaults to true)
* @param int $dbSource Use master or slave database to pull count (Optional. Defaults to DB_SLAVE)
* @return int
*/
@@ -155,7 +179,7 @@ class MWEchoNotifUser {
/**
* Get alert count for this user.
*
- * @param boolean $cached Set to false to bypass the cache. (Optional. Defaults to true)
+ * @param bool $cached Set to false to bypass the cache. (Optional. Defaults to true)
* @param int $dbSource Use master or slave database to pull count (Optional. Defaults to DB_SLAVE)
* @return int
*/
@@ -163,75 +187,40 @@ class MWEchoNotifUser {
return $this->getNotificationCount( $cached, $dbSource, EchoAttributeManager::ALERT );
}
- /**
- * Get the memcache key for 'has ever had messages' value
- * @return string
- */
- private function getHasMessagesKey() {
- global $wgEchoConfig;
- return wfMemcKey( 'echo', 'user', 'had', 'messages', $this->mUser->getId(), $wgEchoConfig['version'] );
- }
-
- /**
- * Check whether the user has ever had messages.
- *
- * @param boolean $cached Set to false to bypass the cache. (Optional. Defaults to true)
- * @return boolean User has received messages
- */
- public function hasMessages( $cached = true ) {
- global $wgEchoConfig;
- $section = EchoAttributeManager::MESSAGE;
-
- $memcKey = $this->getHasMessagesKey();
- if ( $cached ) {
- $data = $this->cache->get( $memcKey );
- if ( $data !== false && $data !== null ) {
- return (bool)$data;
- }
- }
- $attributeManager = EchoAttributeManager::newFromGlobalVars();
- $eventTypesToLoad = $attributeManager->getUserEnabledEventsbySections( $this->mUser, 'web', array( $section ) );
-
- $count = count( $this->notifMapper->fetchByUser( $this->mUser, 1, 0, $eventTypesToLoad ) );
-
- $result = (int)( $count > 0 );
- $this->cache->set( $memcKey, $result, 86400 );
-
- return (bool)$result;
- }
-
- /**
- * Cache the fact that the user has messages.
- * This is used after the user receives a message, making the system skip the actual test
- * of whether they have messages against the database at all.
- */
- public function cacheHasMessages() {
- $this->cache->set( $this->getHasMessagesKey(), 1, 86400 );
+ public function getLocalNotificationCount( $cached = true, $dbSource = DB_SLAVE, $section = EchoAttributeManager::ALL ) {
+ return $this->getNotificationCount( $cached, $dbSource, $section, false );
}
/**
* Retrieves number of unread notifications that a user has, would return
- * $wgEchoMaxNotificationCount + 1 at most
+ * MWEchoNotifUser::MAX_BADGE_COUNT + 1 at most.
+ *
+ * If $wgEchoCrossWikiNotifications is disabled, the $global parameter is ignored.
*
- * @param boolean $cached Set to false to bypass the cache. (Optional. Defaults to true)
+ * @param bool $cached Set to false to bypass the cache. (Optional. Defaults to true)
* @param int $dbSource Use master or slave database to pull count (Optional. Defaults to DB_SLAVE)
* @param string $section Notification section
+ * @param bool|string $global Whether to include foreign notifications. If set to 'preference', uses the user's preference.
* @return int
*/
- public function getNotificationCount( $cached = true, $dbSource = DB_SLAVE, $section = EchoAttributeManager::ALL ) {
- global $wgEchoConfig;
-
+ public function getNotificationCount( $cached = true, $dbSource = DB_SLAVE, $section = EchoAttributeManager::ALL, $global = 'preference' ) {
if ( $this->mUser->isAnon() ) {
return 0;
}
- $memcKey = wfMemcKey(
- 'echo-notification-count' . ( $section === EchoAttributeManager::ALL ? '' : ( '-' . $section ) ),
- $this->mUser->getId(),
- $wgEchoConfig['version']
- );
+ global $wgEchoCrossWikiNotifications;
+ if ( !$wgEchoCrossWikiNotifications ) {
+ // Ignore the $global parameter
+ $global = false;
+ }
+
+ if ( $global === 'preference' ) {
+ $global = $this->getForeignNotifications()->isEnabledByUser();
+ }
+
+ $memcKey = $this->getMemcKey( 'echo-notification-count' . ( $section === EchoAttributeManager::ALL ? '' : ( '-' . $section ) ), $global );
if ( $cached ) {
- $data = $this->cache->get( $memcKey );
+ $data = $this->getFromCache( $memcKey );
if ( $data !== false && $data !== null ) {
return (int)$data;
}
@@ -241,32 +230,36 @@ class MWEchoNotifUser {
if ( $section === EchoAttributeManager::ALL ) {
$eventTypesToLoad = $attributeManager->getUserEnabledEvents( $this->mUser, 'web' );
} else {
- $eventTypesToLoad = $attributeManager->getUserEnabledEventsbySections( $this->mUser, 'web', array( $section ) );
+ $eventTypesToLoad = $attributeManager->getUserEnabledEventsbySections( $this->mUser, 'web', [ $section ] );
}
- $count = $this->userNotifGateway->getNotificationCount( $dbSource, $eventTypesToLoad );
- $this->cache->set( $memcKey, $count, 86400 );
+ $count = (int)$this->userNotifGateway->getCappedNotificationCount( $dbSource, $eventTypesToLoad, self::MAX_BADGE_COUNT + 1 );
- return (int)$count;
+ if ( $global ) {
+ $count = self::capNotificationCount( $count + $this->getForeignCount( $section ) );
+ }
+
+ $this->setInCache( $memcKey, $count, 86400 );
+ return $count;
}
/**
- * Get the unread timestamp of the latest alert
+ * Get the timestamp of the latest unread alert
*
- * @param boolean $cached Set to false to bypass the cache. (Optional. Defaults to true)
+ * @param bool $cached Set to false to bypass the cache. (Optional. Defaults to true)
* @param int $dbSource Use master or slave database to pull count (Optional. Defaults to DB_SLAVE)
- * @return int
+ * @return bool|MWTimestamp Timestamp of latest unread alert, or false if there are no unread alerts.
*/
public function getLastUnreadAlertTime( $cached = true, $dbSource = DB_SLAVE ) {
return $this->getLastUnreadNotificationTime( $cached, $dbSource, EchoAttributeManager::ALERT );
}
/**
- * Get the unread timestamp of the latest message
+ * Get the timestamp of the latest unread message
*
- * @param boolean $cached Set to false to bypass the cache. (Optional. Defaults to true)
+ * @param bool $cached Set to false to bypass the cache. (Optional. Defaults to true)
* @param int $dbSource Use master or slave database to pull count (Optional. Defaults to DB_SLAVE)
- * @return int
+ * @return bool|MWTimestamp
*/
public function getLastUnreadMessageTime( $cached = true, $dbSource = DB_SLAVE ) {
return $this->getLastUnreadNotificationTime( $cached, $dbSource, EchoAttributeManager::MESSAGE );
@@ -275,56 +268,89 @@ class MWEchoNotifUser {
/**
* Returns the timestamp of the last unread notification.
*
- * @param boolean $cached Set to false to bypass the cache. (Optional. Defaults to true)
+ * If $wgEchoCrossWikiNotifications is disabled, the $global parameter is ignored.
+ *
+ * @param bool $cached Set to false to bypass the cache. (Optional. Defaults to true)
* @param int $dbSource Use master or slave database to pull count (Optional. Defaults to DB_SLAVE)
* @param string $section Notification section
- * @return bool|MWTimestamp Timestamp of last notification, or false if there is none
+ * @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( $cached = true, $dbSource = DB_SLAVE, $section = EchoAttributeManager::ALL ) {
- global $wgEchoConfig;
-
+ public function getLastUnreadNotificationTime( $cached = true, $dbSource = DB_SLAVE, $section = EchoAttributeManager::ALL, $global = 'preference' ) {
if ( $this->mUser->isAnon() ) {
return false;
}
- $memcKey = wfMemcKey(
- 'echo-notification-timestamp' . ( $section === EchoAttributeManager::ALL ? '' : ( '-' . $section ) ),
- $this->mUser->getId(),
- $wgEchoConfig['version']
- );
+ global $wgEchoCrossWikiNotifications;
+ if ( !$wgEchoCrossWikiNotifications ) {
+ // Ignore the $global parameter
+ $global = false;
+ }
+
+ if ( $global === 'preference' ) {
+ $global = $this->getForeignNotifications()->isEnabledByUser();
+ }
+
+ $memcKey = $this->getMemcKey( 'echo-notification-timestamp' . ( $section === EchoAttributeManager::ALL ? '' : ( '-' . $section ) ), $global );
// read from cache, if allowed
if ( $cached ) {
- $timestamp = $this->cache->get( $memcKey );
- if ( $timestamp !== false ) {
+ $timestamp = $this->getFromCache( $memcKey );
+ if ( $timestamp === -1 ) {
+ // -1 means the user has no notifications
+ return false;
+ } elseif ( $timestamp !== false ) {
return new MWTimestamp( $timestamp );
}
+ // else cache miss
}
+ $timestamp = false;
+
+ // Get timestamp of most recent local notification, if there is one
$attributeManager = EchoAttributeManager::newFromGlobalVars();
if ( $section === EchoAttributeManager::ALL ) {
$eventTypesToLoad = $attributeManager->getUserEnabledEvents( $this->mUser, 'web' );
} else {
- $eventTypesToLoad = $attributeManager->getUserEnabledEventsbySections( $this->mUser, 'web', array( $section ) );
+ $eventTypesToLoad = $attributeManager->getUserEnabledEventsbySections( $this->mUser, 'web', [ $section ] );
}
-
- $notifications = $this->notifMapper->fetchUnreadByUser( $this->mUser, 1, $eventTypesToLoad, $dbSource );
+ $notifications = $this->notifMapper->fetchUnreadByUser( $this->mUser, 1, null, $eventTypesToLoad, null, $dbSource );
if ( $notifications ) {
$notification = reset( $notifications );
- $timestamp = $notification->getTimestamp();
+ $timestamp = new MWTimestamp( $notification->getTimestamp() );
+ }
+
+ // Use timestamp of most recent foreign notification, if it's more recent
+ if ( $global ) {
+ $foreignTime = $this->getForeignTimestamp( $section );
+
+ if (
+ $foreignTime !== false &&
+ // $foreignTime < $timestamp = invert 0
+ // $foreignTime > $timestamp = invert 1
+ ( $timestamp === false || $foreignTime->diff( $timestamp )->invert === 1 )
+ ) {
+ $timestamp = $foreignTime;
+ }
+ }
- // store to cache & return
- $this->cache->set($memcKey, $timestamp, 86400);
- return new MWTimestamp( $timestamp );
+ if ( $timestamp === false ) {
+ // No notifications, so no timestamp
+ $returnValue = false;
+ $cacheValue = -1;
+ } else {
+ $returnValue = $timestamp;
+ $cacheValue = $timestamp->getTimestamp( TS_MW );
}
- return false;
+ $this->setInCache( $memcKey, $cacheValue, 86400 );
+ return $returnValue;
}
/**
* Mark one or more notifications read for a user.
- * @param $eventIds Array of event IDs to mark read
- * @return boolean
+ * @param array $eventIds Array of event IDs to mark read
+ * @return bool
*/
public function markRead( $eventIds ) {
$eventIds = array_filter( (array)$eventIds, 'is_numeric' );
@@ -334,20 +360,54 @@ class MWEchoNotifUser {
$res = $this->userNotifGateway->markRead( $eventIds );
if ( $res ) {
- // Delete records from echo_target_page
- $this->targetPageMapper->deleteByUserEvents( $this->mUser, $eventIds );
// Update notification count in cache
$this->resetNotificationCount( DB_MASTER );
// After this 'mark read', is there any unread edit-user-talk
// remaining? If not, we should clear the newtalk flag.
if ( $this->mUser->getNewtalk() ) {
- $unreadEditUserTalk = $this->notifMapper->fetchUnreadByUser( $this->mUser, 1, array( 'edit-user-talk' ), DB_MASTER );
+ $attributeManager = EchoAttributeManager::newFromGlobalVars();
+ $categoryMap = $attributeManager->getEventsByCategory();
+ $usertalkTypes = $categoryMap['edit-user-talk'];
+ $unreadEditUserTalk = $this->notifMapper->fetchUnreadByUser( $this->mUser, 1, null, $usertalkTypes, null, DB_MASTER );
if ( count( $unreadEditUserTalk ) === 0 ) {
$this->mUser->setNewtalk( false );
}
}
}
+
+ return $res;
+ }
+
+ /**
+ * Mark one or more notifications unread for a user.
+ * @param array $eventIds Array of event IDs to mark unread
+ * @return bool
+ */
+ public function markUnRead( $eventIds ) {
+ $eventIds = array_filter( (array)$eventIds, 'is_numeric' );
+ if ( !$eventIds || wfReadOnly() ) {
+ return false;
+ }
+
+ $res = $this->userNotifGateway->markUnRead( $eventIds );
+ if ( $res ) {
+ // Update notification count in cache
+ $this->resetNotificationCount( DB_MASTER );
+
+ // 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() ) {
+ $attributeManager = EchoAttributeManager::newFromGlobalVars();
+ $categoryMap = $attributeManager->getEventsByCategory();
+ $usertalkTypes = $categoryMap['edit-user-talk'];
+ $unreadEditUserTalk = $this->notifMapper->fetchUnreadByUser( $this->mUser, 1, null, $usertalkTypes, null, DB_MASTER );
+ if ( count( $unreadEditUserTalk ) > 0 ) {
+ $this->mUser->setNewtalk( true );
+ }
+ }
+ }
+
return $res;
}
@@ -359,9 +419,9 @@ class MWEchoNotifUser {
* across multiple tables, we would visit this later
*
* @param string[] $sections
- * @return boolean
+ * @return bool
*/
- public function markAllRead( array $sections = array( EchoAttributeManager::ALL ) ) {
+ public function markAllRead( array $sections = [ EchoAttributeManager::ALL ] ) {
if ( wfReadOnly() ) {
return false;
}
@@ -376,10 +436,10 @@ class MWEchoNotifUser {
$attributeManager = EchoAttributeManager::newFromGlobalVars();
$eventTypes = $attributeManager->getUserEnabledEventsbySections( $this->mUser, 'web', $sections );
- $notifs = $this->notifMapper->fetchUnreadByUser( $this->mUser, $wgEchoMaxUpdateCount, $eventTypes );
+ $notifs = $this->notifMapper->fetchUnreadByUser( $this->mUser, $wgEchoMaxUpdateCount, null, $eventTypes );
$eventIds = array_filter(
- array_map( function( EchoNotification $notif ) {
+ array_map( function ( EchoNotification $notif ) {
// This should not happen at all, but use 0 in
// such case so to keep the code running
if ( $notif->getEvent() ) {
@@ -393,42 +453,78 @@ class MWEchoNotifUser {
$res = $this->markRead( $eventIds );
if ( $res ) {
// Delete records from echo_target_page
- $this->targetPageMapper->deleteByUserEvents( $this->mUser, $eventIds );
+ /**
+ * Keep the 'echo_target_page' records so they can be used for moderation.
+ */
+ // $this->targetPageMapper->deleteByUserEvents( $this->mUser, $eventIds );
if ( count( $notifs ) < $wgEchoMaxUpdateCount ) {
$this->flagCacheWithNoTalkNotification();
}
}
+
return $res;
}
/**
- * Recalculates the number of notifications that a user has.
- * @param $dbSource int use master or slave database to pull count
+ * Invalidate cache and update echo_unread_wikis if x-wiki notifications is enabled
+ * NOTE: Consider calling this function from a deferred update since it may access the db
+ *
+ * @param int $dbSource Use master or replica database to pull count
*/
- public function resetNotificationCount( $dbSource = DB_SLAVE ) {
- // Reset notification count for all sections as well
- $this->getNotificationCount( false, $dbSource, EchoAttributeManager::ALL );
- $this->getNotificationCount( false, $dbSource, EchoAttributeManager::ALERT );
- $this->getNotificationCount( false, $dbSource, EchoAttributeManager::MESSAGE );
- // when notification count needs to be updated, last notification may have
- // changed too, so we need to invalidate that cache too
- $this->getLastUnreadNotificationTime( false, $dbSource, EchoAttributeManager::ALL );
- $this->getLastUnreadNotificationTime( false, $dbSource, EchoAttributeManager::ALERT );
- $this->getLastUnreadNotificationTime( false, $dbSource, EchoAttributeManager::MESSAGE );
- $this->mUser->invalidateCache();
+ public function resetNotificationCount( $dbSource = DB_REPLICA ) {
+ global $wgEchoCrossWikiNotifications;
+ if ( $wgEchoCrossWikiNotifications ) {
+ // Schedule an update to the echo_unread_wikis table
+ $uw = EchoUnreadWikis::newFromUser( $this->mUser );
+ if ( $uw ) {
+ $alertCount = $this->getNotificationCount( false, $dbSource, EchoAttributeManager::ALERT, false );
+ $msgCount = $this->getNotificationCount( false, $dbSource, EchoAttributeManager::MESSAGE, false );
+ $alertUnread = $this->getLastUnreadNotificationTime( false, $dbSource, EchoAttributeManager::ALERT, false );
+ $msgUnread = $this->getLastUnreadNotificationTime( false, $dbSource, EchoAttributeManager::MESSAGE, false );
+ $uw->updateCount( wfWikiID(), $alertCount, $alertUnread, $msgCount, $msgUnread );
+ }
+ }
+
+ $this->invalidateCache();
}
/**
- * Retrieves formatted number of unread notifications that a user has.
- * @param boolean $cached Set to false to bypass the cache. (Optional. Defaults to true)
- * @param int $dbSource Use master or slave database to pull count (Optional. Defaults to DB_SLAVE)
- * @param string $section
- * @return string
+ * Get the timestamp of the last time the global notification counts/timestamps were updated, if available.
+ *
+ * If the timestamp of the last update is not known, this will return the current timestamp.
+ * If the user is not attached, this will return false.
+ *
+ * @return string|false MW timestamp of the last update, or false if the user is not attached
*/
- public function getFormattedNotificationCount( $cached = true, $dbSource = DB_SLAVE, $section = EchoAttributeManager::ALL ) {
- return EchoNotificationController::formatNotificationCount(
- $this->getNotificationCount( $cached, $dbSource, $section )
- );
+ public function getGlobalUpdateTime() {
+ $key = $this->getGlobalMemcKey( 'echo-notification-updated' );
+ if ( $key === false ) {
+ return false;
+ }
+ return wfTimestamp( TS_MW, $this->cache->getCheckKeyTime( $key ) );
+ }
+
+ /**
+ * Invalidate user caches related to notification counts/timestamps.
+ *
+ * This bumps the local user's touched timestamp as well as the timestamp returned by getGlobalUpdateTime().
+ */
+ protected function invalidateCache() {
+ // Update the user touched timestamp for the local user
+ $this->mUser->invalidateCache();
+
+ $this->deleteFromCache( $this->getLocalKeys() );
+
+ global $wgEchoCrossWikiNotifications;
+ if ( $wgEchoCrossWikiNotifications ) {
+ $this->deleteFromCache( $this->getGlobalKeys() );
+
+ // Update the global touched timestamp
+ $key = $this->getGlobalMemcKey( 'echo-notification-updated' );
+ if ( $key ) {
+ $this->cache->touchCheckKey( $key );
+ }
+ }
}
/**
@@ -441,8 +537,259 @@ class MWEchoNotifUser {
if ( $wgAllowHTMLEmail ) {
return $this->mUser->getOption( 'echo-email-format' );
} else {
- return EchoHooks::EMAIL_FORMAT_PLAIN_TEXT;
+ return EchoEmailFormat::PLAIN_TEXT;
+ }
+ }
+
+ /**
+ * Get a cache entry from the cache, using a preloaded instance cache.
+ * @param string|false $memcKey Cache key returned by getMemcKey()
+ * @return mixed Cache value
+ */
+ protected function getFromCache( $memcKey ) {
+ // getMemcKey() can return false
+ if ( $memcKey === false ) {
+ return false;
+ }
+
+ // Populate the instance cache
+ if ( $this->cached === null ) {
+ $keys = $this->getPreloadKeys();
+ $this->cached = $this->cache->getMulti( $keys );
+ // also keep track of cache values that couldn't be found (getMulti
+ // omits them...)
+ $this->cached += array_fill_keys( $keys, false );
+ }
+
+ if ( isset( $this->cached[$memcKey] ) ) {
+ return $this->cached[$memcKey];
+ }
+
+ return $this->cache->get( $memcKey );
+ }
+
+ /**
+ * Set a cache entry both in the cache and in the instance cache.
+ * Use this to write to keys that were loaded with getFromCache().
+ * @param string|false $memcKey Cache key returned by getMemcKey()
+ * @param mixed $value Cache value to set
+ * @param int $expiry Expiry, see BagOStuff::set()
+ */
+ protected function setInCache( $memcKey, $value, $expiry ) {
+ // getMemcKey() can return false
+ if ( $memcKey === false ) {
+ return;
+ }
+
+ // Update the instance cache if it's already been populated
+ if ( $this->cached !== null ) {
+ $this->cached[$memcKey] = $value;
+ }
+
+ $this->cache->set( $memcKey, $value, $expiry );
+ }
+
+ protected function deleteFromCache( $keys ) {
+ foreach ( $keys as $key ) {
+ // Update the instance cache if it's already been populated
+ if ( $this->cached !== null ) {
+ unset( $this->cached[$key] );
+ }
+ $this->cache->delete( $key );
+ }
+ }
+
+ /**
+ * Array of memcached keys to load at once.
+ *
+ * @return array
+ */
+ protected function getPreloadKeys() {
+ return array_merge(
+ $this->getLocalKeys(),
+ $this->getGlobalKeys()
+ );
+ }
+
+ protected function getLocalKeys() {
+ return array_filter( array_map( [ $this, 'getMemcKey' ], $this->getKeySeeds() ) );
+ }
+
+ protected function getGlobalKeys() {
+ return array_filter( array_map( [ $this, 'getGlobalMemcKey' ], $this->getKeySeeds() ) );
+ }
+
+ protected function getKeySeeds() {
+ return [
+ 'echo-notification-timestamp',
+ 'echo-notification-timestamp-' . EchoAttributeManager::MESSAGE,
+ 'echo-notification-timestamp-' . EchoAttributeManager::ALERT,
+ 'echo-notification-count',
+ 'echo-notification-count-' . EchoAttributeManager::MESSAGE,
+ 'echo-notification-count-' . EchoAttributeManager::ALERT,
+ ];
+ }
+
+ /**
+ * Build a memcached key.
+ * @param string $key Key, typically prefixed with echo-notification-
+ * @param bool $global If true, return a global memc key; if false, return one local to this wiki
+ * @return string|false Memcached key, or false if one could not be generated
+ */
+ protected function getMemcKey( $key, $global = false ) {
+ global $wgEchoCacheVersion;
+ if ( !$global ) {
+ return wfMemcKey( $key, $this->mUser->getId(), $wgEchoCacheVersion );
+ }
+
+ $lookup = CentralIdLookup::factory();
+ $globalId = $lookup->centralIdFromLocalUser( $this->mUser, CentralIdLookup::AUDIENCE_RAW );
+ if ( !$globalId ) {
+ return false;
+ }
+ return wfGlobalCacheKey( $key, $globalId, $wgEchoCacheVersion );
+ }
+
+ protected function getGlobalMemcKey( $key ) {
+ return $this->getMemcKey( $key, true );
+ }
+
+ /**
+ * Lazy-construct an EchoForeignNotifications instance. This instance is force-enabled, so it
+ * returns information about cross-wiki notifications even if the user has them disabled.
+ * @return EchoForeignNotifications
+ */
+ protected function getForeignNotifications() {
+ if ( !$this->foreignNotifications ) {
+ $this->foreignNotifications = new EchoForeignNotifications( $this->mUser, true );
+ }
+ return $this->foreignNotifications;
+ }
+
+ /**
+ * 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;
+ }
+
+ 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 );
+ }
+
+ 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 = false;
+ $foreignData = $this->getForeignData();
+ foreach ( $foreignData as $data ) {
+ if ( $section === EchoAttributeManager::ALL ) {
+ foreach ( $data as $subData ) {
+ if ( isset( $subData['timestamp'] ) ) {
+ $wikiTime = new MWTimestamp( $data[$section]['timestamp'] );
+ // $wikiTime > $foreignTime = invert 1
+ if ( $foreignTime === false || $wikiTime->diff( $foreignTime )->invert === 1 ) {
+ $foreignTime = $wikiTime;
+ }
+ }
+ }
+ } elseif ( isset( $data[$section]['timestamp'] ) ) {
+ $wikiTime = new MWTimestamp( $data[$section]['timestamp'] );
+ // $wikiTime > $foreignTime = invert 1
+ if ( $foreignTime === false || $wikiTime->diff( $foreignTime )->invert === 1 ) {
+ $foreignTime = $wikiTime;
+ }
+ }
+ }
+ } else {
+ $foreignTime = $this->getForeignNotifications()->getTimestamp( $section );
}
+ return $foreignTime;
}
+ /**
+ * Helper function to produce the capped number of notifications
+ * based on the value of MWEchoNotifUser::MAX_BADGE_COUNT
+ *
+ * @param int $number Raw notification count to cap
+ * @return int Capped notification count
+ */
+ public static function capNotificationCount( $number ) {
+ return min( $number, self::MAX_BADGE_COUNT + 1 );
+ }
}
diff --git a/Echo/includes/Notifier.php b/Echo/includes/Notifier.php
new file mode 100644
index 00000000..6a05dcab
--- /dev/null
+++ b/Echo/includes/Notifier.php
@@ -0,0 +1,135 @@
+<?php
+
+// @todo Fill in
+class EchoNotifier {
+ /**
+ * Record an EchoNotification for an EchoEvent
+ * Currently used for web-based notifications.
+ *
+ * @param User $user User to notify.
+ * @param EchoEvent $event EchoEvent to notify about.
+ */
+ public static function notifyWithNotification( $user, $event ) {
+ // Only create the notification if the user wants to receive that type
+ // of notification and they are eligible to receive it. See bug 47664.
+ $attributeManager = EchoAttributeManager::newFromGlobalVars();
+ $userWebNotifications = $attributeManager->getUserEnabledEvents( $user, 'web' );
+ if ( !in_array( $event->getType(), $userWebNotifications ) ) {
+ return;
+ }
+
+ EchoNotification::create( [ 'user' => $user, 'event' => $event ] );
+
+ MWEchoEventLogging::logSchemaEcho( $user, $event, 'web' );
+ }
+
+ /**
+ * Send a Notification to a user by email
+ *
+ * @param User $user User to notify.
+ * @param EchoEvent $event EchoEvent to notify about.
+ * @return bool
+ */
+ public static function notifyWithEmail( $user, $event ) {
+ global $wgEnableEmail, $wgBlockDisablesLogin;
+
+ if (
+ // Email is globally disabled
+ !$wgEnableEmail ||
+ // User does not have a valid and confirmed email address
+ !$user->isEmailConfirmed() ||
+ // User has disabled Echo emails
+ $user->getOption( 'echo-email-frequency' ) < 0 ||
+ // User is blocked and cannot log in (T199993)
+ ( $wgBlockDisablesLogin && $user->isBlocked() )
+ ) {
+ return false;
+ }
+
+ // Final check on whether to send email for this user & event
+ if ( !Hooks::run( 'EchoAbortEmailNotification', [ $user, $event ] ) ) {
+ return false;
+ }
+
+ $attributeManager = EchoAttributeManager::newFromGlobalVars();
+ $userEmailNotifications = $attributeManager->getUserEnabledEvents( $user, 'email' );
+ // See if the user wants to receive emails for this category or the user is eligible to receive this email
+ if ( in_array( $event->getType(), $userEmailNotifications ) ) {
+ global $wgEchoEnableEmailBatch, $wgEchoNotifications, $wgNotificationSender, $wgNotificationReplyName;
+
+ $priority = $attributeManager->getNotificationPriority( $event->getType() );
+
+ $bundleString = $bundleHash = '';
+
+ // We should have bundling for email digest as long as either web or email bundling is on, for example, talk page
+ // email bundling is off, but if a user decides to receive email digest, we should bundle those messages
+ if ( !empty( $wgEchoNotifications[$event->getType()]['bundle']['web'] ) || !empty( $wgEchoNotifications[$event->getType()]['bundle']['email'] ) ) {
+ Hooks::run( 'EchoGetBundleRules', [ $event, &$bundleString ] );
+ }
+ if ( $bundleString ) {
+ $bundleHash = md5( $bundleString );
+ }
+
+ MWEchoEventLogging::logSchemaEcho( $user, $event, 'email' );
+
+ // email digest notification ( weekly or daily )
+ if ( $wgEchoEnableEmailBatch && $user->getOption( 'echo-email-frequency' ) > 0 ) {
+ // always create a unique event hash for those events don't support bundling
+ // this is mainly for group by
+ if ( !$bundleHash ) {
+ $bundleHash = md5( $event->getType() . '-' . $event->getId() );
+ }
+ MWEchoEmailBatch::addToQueue( $user->getId(), $event->getId(), $priority, $bundleHash );
+
+ return true;
+ }
+
+ // instant email notification
+ $toAddress = MailAddress::newFromUser( $user );
+ $fromAddress = new MailAddress( $wgNotificationSender, EchoHooks::getNotificationSenderName() );
+ $replyAddress = new MailAddress( $wgNotificationSender, $wgNotificationReplyName );
+ // Since we are sending a single email, should set the bundle hash to null
+ // if it is set with a value from somewhere else
+ $event->setBundleHash( null );
+ $email = self::generateEmail( $event, $user );
+ if ( !$email ) {
+ return false;
+ }
+ $subject = $email['subject'];
+ $body = $email['body'];
+ $options = [ 'replyTo' => $replyAddress ];
+
+ UserMailer::send( $toAddress, $fromAddress, $subject, $body, $options );
+ MWEchoEventLogging::logSchemaEchoMail( $user, 'single' );
+ }
+
+ return true;
+ }
+
+ /**
+ * @param EchoEvent $event
+ * @param User $user
+ * @return bool|array An array of 'subject' and 'body', or false if things went wrong
+ */
+ private static function generateEmail( EchoEvent $event, User $user ) {
+ $emailFormat = MWEchoNotifUser::newFromUser( $user )->getEmailFormat();
+ $lang = wfGetLangObj( $user->getOption( 'language' ) );
+ $formatter = new EchoPlainTextEmailFormatter( $user, $lang );
+ $content = $formatter->format( $event );
+ if ( !$content ) {
+ return false;
+ }
+
+ if ( $emailFormat === EchoEmailFormat::HTML ) {
+ $htmlEmailFormatter = new EchoHtmlEmailFormatter( $user, $lang );
+ $htmlContent = $htmlEmailFormatter->format( $event );
+ $multipartBody = [
+ 'text' => $content['body'],
+ 'html' => $htmlContent['body']
+ ];
+ $content['body'] = $multipartBody;
+ }
+
+ return $content;
+ }
+}
diff --git a/Echo/includes/ResourceLoaderEchoImageModule.php b/Echo/includes/ResourceLoaderEchoImageModule.php
new file mode 100644
index 00000000..c0b103a4
--- /dev/null
+++ b/Echo/includes/ResourceLoaderEchoImageModule.php
@@ -0,0 +1,62 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A sibling of secret special sauce.
+ * @see ResourceLoaderOOUIImageModule for familial resemblence
+ */
+class ResourceLoaderEchoImageModule extends ResourceLoaderImageModule {
+ protected function loadFromDefinition() {
+ if ( $this->definition === null ) {
+ return;
+ }
+
+ // Check to make sure icons are set
+ if ( !isset( $this->definition['icons'] ) ) {
+ throw new MWException( 'Icons must be set.' );
+ }
+
+ $images = [];
+ foreach ( $this->definition['icons'] as $iconName => $definition ) {
+ // FIXME: We also have a 'site' icon which is "magical"
+ // and uses witchcraft and should be handled specifically
+ if ( isset( $definition[ 'path' ] ) ) {
+ if ( is_array( $definition[ 'path' ] ) ) {
+ $paths = [];
+ foreach ( $definition[ 'path' ] as $dir => $p ) {
+ // Has both rtl and ltr definitions
+ $paths[ $dir ] = $p;
+ }
+ } else {
+ $paths = $definition[ 'path' ];
+ }
+
+ if ( !empty( $paths ) ) {
+ $images[ $iconName ][ 'file' ] = $paths;
+ }
+ }
+ }
+
+ $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 b0134a68..3d521112 100644
--- a/Echo/includes/SeenTime.php
+++ b/Echo/includes/SeenTime.php
@@ -10,7 +10,7 @@ class EchoSeenTime {
* Allowed notification types
* @var array
*/
- private static $allowedTypes = array( 'alert', 'message' );
+ private static $allowedTypes = [ 'alert', 'message' ];
/**
* @var User
@@ -18,16 +18,10 @@ class EchoSeenTime {
private $user;
/**
- * @var BagOStuff
- */
- private $cache;
-
- /**
* @param User $user A logged in user
*/
private function __construct( User $user ) {
$this->user = $user;
- $this->cache = ObjectCache::getInstance( 'db-replicated' );
}
/**
@@ -39,42 +33,90 @@ class EchoSeenTime {
}
/**
- * @param int $flags BagOStuff::READ_LATEST to use the master
- * @return string|bool false if no stored time
+ * Hold onto a cache for our operations. Static so it can reuse the same
+ * in-process cache in different instances.
+ *
+ * @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()
+ );
+ }
+
+ return $c;
+ }
+
+ /**
+ * @param string $type Type of seen time to get
+ * @param int $format Format to return time in, defaults to TS_MW
+ * @return string|bool Timestamp in specified format, or false if no stored time
*/
- public function getTime( $type = 'all', $flags = 0 ) {
- $vals = array();
+ public function getTime( $type = 'all', $format = TS_MW ) {
+ $vals = [];
if ( $type === 'all' ) {
foreach ( self::$allowedTypes as $allowed ) {
- $vals[] = $this->getTime( $allowed );
+ // Use TS_MW, then convert later, so max works properly for
+ // all formats.
+ $vals[] = $this->getTime( $allowed, TS_MW );
}
- return max( $vals );
+
+ return wfTimestamp( $format, min( $vals ) );
}
- if ( $this->validateType( $type ) ) {
- $key = wfMemcKey( 'echo', 'seen', $type, 'time', $this->user->getId() );
- $cas = 0; // Unused, but we have to pass something by reference
- $data = $this->cache->get( $key, $cas, $flags );
- if ( $data === false ) {
- // Check if the user still has it set in their preferences
- $data = $this->user->getOption( 'echo-seen-time', false );
- }
+ if ( !$this->validateType( $type ) ) {
+ return false;
}
- return $data;
+ $data = self::cache()->get( $this->getMemcKey( $type ) );
+
+ if ( $data === false ) {
+ // Check if the user still has it set in their preferences
+ $data = $this->user->getOption( 'echo-seen-time', false );
+ }
+
+ 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 );
}
+ /**
+ * Sets the seen time
+ *
+ * @param string $time Time, in TS_MW format
+ * @param string $type Type of seen time to set
+ */
public function setTime( $time, $type = 'all' ) {
if ( $type === 'all' ) {
foreach ( self::$allowedTypes as $allowed ) {
$this->setTime( $time, $allowed );
}
- } else {
- if ( $this->validateType( $type ) ) {
- $key = wfMemcKey( 'echo', 'seen', $type, 'time', $this->user->getId() );
- return $this->cache->set( $key, $time );
- }
+ return;
+ }
+
+ if ( !$this->validateType( $type ) ) {
+ return;
}
+
+ // Write to the in-memory cache immediately, and defer writing to
+ // the real cache
+ $key = $this->getMemcKey( $type );
+ $cache = self::cache();
+ $cache->set( $key, $time, 0, BagOStuff::WRITE_CACHE_ONLY );
+ DeferredUpdates::addCallableUpdate( function () use ( $key, $time, $cache ) {
+ $cache->set( $key, $time );
+ } );
}
/**
@@ -86,4 +128,27 @@ class EchoSeenTime {
private function validateType( $type ) {
return in_array( $type, self::$allowedTypes );
}
+
+ /**
+ * Build a memcached key.
+ *
+ * @param string $type Given notification type
+ * @return string Memcached key
+ */
+ protected function getMemcKey( $type = 'all' ) {
+ $localKey = wfMemcKey( 'echo', 'seen', $type, 'time', $this->user->getId() );
+
+ if ( !$this->user->getOption( 'echo-cross-wiki-notifications' ) ) {
+ return $localKey;
+ }
+
+ $lookup = CentralIdLookup::factory();
+ $globalId = $lookup->centralIdFromLocalUser( $this->user, CentralIdLookup::AUDIENCE_RAW );
+
+ if ( !$globalId ) {
+ return $localKey;
+ }
+
+ return wfGlobalCacheKey( 'echo', 'seen', $type, 'time', $globalId );
+ }
}
diff --git a/Echo/includes/UnreadWikis.php b/Echo/includes/UnreadWikis.php
new file mode 100644
index 00000000..e5151187
--- /dev/null
+++ b/Echo/includes/UnreadWikis.php
@@ -0,0 +1,144 @@
+<?php
+
+/**
+ * Manages what wikis a user has unread notifications on
+ */
+class EchoUnreadWikis {
+ /**
+ * @var string
+ */
+ const DEFAULT_TS = '00000000000000';
+
+ /**
+ * @var int
+ */
+ private $id;
+
+ /**
+ * @var MWEchoDbFactory
+ */
+ private $dbFactory;
+
+ /**
+ * @param int $id Central user id
+ */
+ public function __construct( $id ) {
+ $this->id = $id;
+ $this->dbFactory = MWEchoDbFactory::newFromDefault();
+ }
+
+ /**
+ * Use the user id provided by the CentralIdLookup
+ *
+ * @param User $user
+ * @return EchoUnreadWikis|bool
+ */
+ public static function newFromUser( User $user ) {
+ $lookup = CentralIdLookup::factory();
+ $id = $lookup->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW );
+ if ( !$id ) {
+ return false;
+ }
+
+ return new self( $id );
+ }
+
+ /**
+ * @param int $index DB_* constant
+ * @return bool|DatabaseBase
+ */
+ private function getDB( $index ) {
+ return $this->dbFactory->getSharedDb( $index );
+ }
+
+ /**
+ * @return array
+ */
+ public function getUnreadCounts() {
+ $dbr = $this->getDB( DB_SLAVE );
+ if ( $dbr === false ) {
+ return [];
+ }
+
+ $rows = $dbr->select(
+ 'echo_unread_wikis',
+ [
+ 'euw_wiki',
+ 'euw_alerts', 'euw_alerts_ts',
+ 'euw_messages', 'euw_messages_ts',
+ ],
+ [ 'euw_user' => $this->id ],
+ __METHOD__
+ );
+
+ $wikis = [];
+ foreach ( $rows as $row ) {
+ if ( !$row->euw_alerts && !$row->euw_messages ) {
+ // This shouldn't happen, but lets be safe...
+ continue;
+ }
+ $wikis[$row->euw_wiki] = [
+ EchoAttributeManager::ALERT => [
+ 'count' => $row->euw_alerts,
+ 'ts' => $row->euw_alerts_ts,
+ ],
+ EchoAttributeManager::MESSAGE => [
+ 'count' => $row->euw_messages,
+ 'ts' => $row->euw_messages_ts,
+ ],
+ ];
+ }
+
+ return $wikis;
+ }
+
+ /**
+ * @param string $wiki Wiki code
+ * @param int $alertCount Number of alerts
+ * @param MWTimestamp|bool $alertTime Timestamp of most recent unread alert, or
+ * false meaning no timestamp because there are no unread alerts.
+ * @param int $msgCount Number of messages
+ * @param MWTimestamp|bool $msgTime Timestamp of most recent message, or
+ * false meaning no timestamp because there are no unread messages.
+ */
+ public function updateCount( $wiki, $alertCount, $alertTime, $msgCount, $msgTime ) {
+ $dbw = $this->getDB( DB_MASTER );
+ if ( $dbw === false ) {
+ return;
+ }
+
+ $conditions = [
+ 'euw_user' => $this->id,
+ 'euw_wiki' => $wiki,
+ ];
+
+ if ( $alertCount || $msgCount ) {
+ $values = [
+ 'euw_alerts' => $alertCount,
+ 'euw_alerts_ts' => $alertCount
+ ? $alertTime->getTimestamp( TS_MW )
+ : static::DEFAULT_TS,
+ 'euw_messages' => $msgCount,
+ 'euw_messages_ts' => $msgCount
+ ? $msgTime->getTimestamp( TS_MW )
+ : static::DEFAULT_TS,
+ ];
+
+ // when there is unread alert(s) and/or message(s), upsert the row
+ $dbw->upsert(
+ 'echo_unread_wikis',
+ $conditions + $values,
+ [ 'euw_user', 'euw_wiki' ],
+ $values,
+ __METHOD__
+ );
+ } else {
+ // No unread notifications, delete the row
+ $dbw->delete(
+ 'echo_unread_wikis',
+ $conditions,
+ __METHOD__
+ );
+ }
+ }
+}
diff --git a/Echo/includes/UserLocator.php b/Echo/includes/UserLocator.php
index 45f2b905..afe9a3d1 100644
--- a/Echo/includes/UserLocator.php
+++ b/Echo/includes/UserLocator.php
@@ -8,30 +8,31 @@ class EchoUserLocator {
* heavily watched pages when this is used.
*
* @param EchoEvent $event
+ * @param int $batchSize
* @return User[]
*/
public static function locateUsersWatchingTitle( EchoEvent $event, $batchSize = 500 ) {
$title = $event->getTitle();
if ( !$title ) {
- return array();
+ return [];
}
- $it = new EchoBatchRowIterator(
+ $it = new BatchRowIterator(
wfGetDB( DB_SLAVE, 'watchlist' ),
/* $table = */ 'watchlist',
- /* $primaryKeys = */ array( 'wl_user' ),
+ /* $primaryKeys = */ [ 'wl_user' ],
$batchSize
);
- $it->addConditions( array(
+ $it->addConditions( [
'wl_namespace' => $title->getNamespace(),
'wl_title' => $title->getDBkey(),
- ) );
+ ] );
// flatten the result into a stream of rows
$it = new RecursiveIteratorIterator( $it );
// add callback to convert user id to user objects
- $it = new EchoCallbackIterator( $it, function( $row ) {
+ $it = new EchoCallbackIterator( $it, function ( $row ) {
return User::newFromId( $row->wl_user );
} );
@@ -48,14 +49,14 @@ class EchoUserLocator {
public static function locateTalkPageOwner( EchoEvent $event ) {
$title = $event->getTitle();
if ( !$title || $title->getNamespace() !== NS_USER_TALK ) {
- return array();
+ return [];
}
$user = User::newFromName( $title->getDBkey() );
if ( $user && !$user->isAnon() ) {
- return array( $user->getId() => $user );
+ return [ $user->getId() => $user ];
} else {
- return array();
+ return [];
}
}
@@ -68,9 +69,9 @@ class EchoUserLocator {
public static function locateEventAgent( EchoEvent $event ) {
$agent = $event->getAgent();
if ( $agent && !$agent->isAnon() ) {
- return array( $agent->getId() => $agent );
+ return [ $agent->getId() => $agent ];
} else {
- return array();
+ return [];
}
}
@@ -78,7 +79,7 @@ class EchoUserLocator {
* Return the user that created the first revision of the
* associated title.
*
- * @param EchoEvent $evnet
+ * @param EchoEvent $event
* @return User[]
*/
public static function locateArticleCreator( EchoEvent $event ) {
@@ -86,30 +87,30 @@ class EchoUserLocator {
$title = $event->getTitle();
if ( !$title || $title->getArticleID() <= 0 ) {
- return array();
+ return [];
}
// why?
if ( !$agent ) {
- return array();
+ return [];
}
$dbr = wfGetDB( DB_SLAVE );
$res = $dbr->selectRow(
- array( 'revision' ),
- array( 'rev_user' ),
- array( 'rev_page' => $title->getArticleID() ),
+ [ 'revision' ],
+ [ 'rev_user' ],
+ [ 'rev_page' => $title->getArticleID() ],
__METHOD__,
- array( 'LIMIT' => 1, 'ORDER BY' => 'rev_timestamp, rev_id' )
+ [ 'LIMIT' => 1, 'ORDER BY' => 'rev_timestamp, rev_id' ]
);
if ( !$res || !$res->rev_user ) {
- return array();
+ return [];
}
$user = User::newFromId( $res->rev_user );
if ( $user ) {
- return array( $user->getId() => $user );
+ return [ $user->getId() => $user ];
} else {
- return array();
+ return [];
}
}
@@ -128,13 +129,13 @@ class EchoUserLocator {
* @return User[]
*/
public static function locateFromEventExtra( EchoEvent $event, array $keys ) {
- $users = array();
+ $users = [];
foreach ( $keys as $key ) {
$userIds = $event->getExtraParam( $key );
if ( !$userIds ) {
continue;
} elseif ( !is_array( $userIds ) ) {
- $userIds = array( $userIds );
+ $userIds = [ $userIds ];
}
foreach ( $userIds as $userId ) {
// we shouldn't receive User instances, but allow
diff --git a/Echo/includes/api/ApiCrossWikiBase.php b/Echo/includes/api/ApiCrossWikiBase.php
new file mode 100644
index 00000000..b9184da2
--- /dev/null
+++ b/Echo/includes/api/ApiCrossWikiBase.php
@@ -0,0 +1,126 @@
+<?php
+
+use MediaWiki\Logger\LoggerFactory;
+
+abstract class ApiCrossWikiBase extends ApiQueryBase {
+ /**
+ * @var EchoForeignNotifications
+ */
+ protected $foreignNotifications;
+
+ /**
+ * @param ApiQuery $queryModule
+ * @param string $moduleName
+ * @param string $paramPrefix
+ */
+ public function __construct( ApiQuery $queryModule, $moduleName, $paramPrefix = '' ) {
+ parent::__construct( $queryModule, $moduleName, $paramPrefix );
+
+ $this->foreignNotifications = new EchoForeignNotifications( $this->getUser() );
+ }
+
+ /**
+ * This will turn the current API call (with all of it's params) and execute
+ * it on all foreign wikis, returning an array of results per wiki.
+ *
+ * @param array $wikis List of wikis to query. Defaults to the result of getRequestedForeignWikis().
+ * @param array $paramOverrides Request parameter overrides
+ * @return array
+ * @throws Exception
+ */
+ protected function getFromForeign( $wikis = null, array $paramOverrides = [] ) {
+ $foreignReq = new EchoForeignWikiRequest(
+ $this->getUser(),
+ $paramOverrides + $this->getForeignQueryParams(),
+ $wikis !== null ? $wikis : $this->getRequestedForeignWikis(),
+ $this->getModulePrefix() . 'wikis'
+ );
+ return $foreignReq->execute();
+ }
+
+ /**
+ * Get the query parameters to use for the foreign API requests.
+ * Subclasses should override this if they need to customize the
+ * parameters.
+ * @return array Query parameters
+ */
+ protected function getForeignQueryParams() {
+ return $this->getRequest()->getValues();
+ }
+
+ /**
+ * @return bool
+ */
+ protected function allowCrossWikiNotifications() {
+ global $wgEchoCrossWikiNotifications;
+ return $wgEchoCrossWikiNotifications;
+ }
+
+ /**
+ * This is basically equivalent to $params['wikis'], but some added checks:
+ * - `*` will expand to "all wikis with unread notifications"
+ * - if `$wgEchoCrossWikiNotifications` is off, foreign wikis will be excluded
+ *
+ * @return array
+ */
+ protected function getRequestedWikis() {
+ $params = $this->extractRequestParams();
+
+ // 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() ];
+
+ if ( array_search( '*', $wikis ) !== false ) {
+ // expand `*` to all foreign wikis with unread notifications + local
+ $wikis = array_merge(
+ [ wfWikiID() ],
+ $this->getForeignWikisWithUnreadNotifications()
+ );
+ }
+
+ if ( !$this->allowCrossWikiNotifications() ) {
+ // exclude foreign wikis if x-wiki is not enabled
+ $wikis = array_intersect_key( [ wfWikiID() ], $wikis );
+ }
+
+ return $wikis;
+ }
+
+ /**
+ * @return array Wiki names
+ */
+ protected function getRequestedForeignWikis() {
+ return array_diff( $this->getRequestedWikis(), [ wfWikiID() ] );
+ }
+
+ /**
+ * @return array Wiki names
+ */
+ protected function getForeignWikisWithUnreadNotifications() {
+ return $this->foreignNotifications->getWikis();
+ }
+
+ /**
+ * @return array
+ */
+ public function getAllowedParams() {
+ global $wgConf;
+
+ $params = [];
+
+ if ( $this->allowCrossWikiNotifications() ) {
+ $params += [
+ // fetch notifications from multiple wikis
+ 'wikis' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DFLT => wfWikiID(),
+ // `*` will let you immediately fetch from all wikis that have
+ // unread notifications, without having to look them up first
+ ApiBase::PARAM_TYPE => array_unique( array_merge( $wgConf->wikis, [ wfWikiID(), '*' ] ) ),
+ ],
+ ];
+ }
+
+ return $params;
+ }
+}
diff --git a/Echo/includes/api/ApiEchoArticleReminder.php b/Echo/includes/api/ApiEchoArticleReminder.php
new file mode 100644
index 00000000..5ed26794
--- /dev/null
+++ b/Echo/includes/api/ApiEchoArticleReminder.php
@@ -0,0 +1,112 @@
+<?php
+
+class ApiEchoArticleReminder extends ApiBase {
+
+ public function execute() {
+ $this->getMain()->setCacheMode( 'private' );
+ $user = $this->getUser();
+ if ( $user->isAnon() ) {
+ $this->dieWithError( 'apierror-mustbeloggedin-generic', 'login-required' );
+ }
+
+ $params = $this->extractRequestParams();
+ $result = [];
+ $userTimestamp = new MWTimestamp( $params['timestamp'] );
+ $nowTimestamp = new MWTimestamp();
+ // We need $params['timestamp'] to be a future timestamp:
+ // $userTimestamp < $nowTimestamp = invert 0
+ // $userTimestamp > $nowTimestamp = invert 1
+ if ( $userTimestamp->diff( $nowTimestamp )->invert === 0 ) {
+ $this->dieWithError( [ 'apierror-badparameter', 'timestamp' ], 'timestamp-not-in-future', null, 400 );
+ }
+
+ $eventCreation = EchoEvent::create( [
+ 'type' => 'article-reminder',
+ 'agent' => $user,
+ 'title' => $this->getTitleFromTitleOrPageId( $params ),
+ 'extra' => [
+ 'notifyAgent' => true,
+ 'comment' => $params['comment'],
+ ],
+ ] );
+
+ if ( !$eventCreation ) {
+ $this->dieWithError( 'apierror-echo-event-creation-failed', null, null, 500 );
+ }
+
+ /* Temp - removing the delay just for now:
+ $job = new JobSpecification(
+ 'articleReminder',
+ [
+ 'userId' => $user->getId(),
+ 'timestamp' => $params['timestamp'],
+ 'comment' => $params['comment'],
+ ],
+ [ 'removeDuplicates' => true ],
+ Title::newFromID( $params['pageid'] )
+ );
+ JobQueueGroup::singleton()->push( $job );*/
+ $result += [
+ 'result' => 'success'
+ ];
+ $this->getResult()->addValue( 'query', $this->getModuleName(), $result );
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'pageid' => [
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ 'title' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ 'comment' => [
+ ApiBase::PARAM_TYPE => 'string',
+ ],
+ 'timestamp' => [
+ ApiBase::PARAM_REQUIRED => true,
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ],
+ 'token' => [
+ ApiBase::PARAM_REQUIRED => true,
+ ],
+ ];
+ }
+
+ public function needsToken() {
+ return 'csrf';
+ }
+
+ public function getTokenSalt() {
+ return '';
+ }
+
+ public function mustBePosted() {
+ return true;
+ }
+
+ public function isWriteMode() {
+ return true;
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ $todayDate = new DateTime();
+ $oneDay = new DateInterval( 'P1D' );
+ $tomorrowDate = $todayDate->add( $oneDay );
+ $tomorrowDateTimestamp = new MWTimestamp( $tomorrowDate );
+ $tomorrowTimestampStr = $tomorrowDateTimestamp->getTimestamp( TS_ISO_8601 );
+ return [
+ "action=echoarticlereminder&pageid=1&timestamp=$tomorrowTimestampStr&comment=example"
+ => 'apihelp-echoarticlereminder-example-1',
+ "action=echoarticlereminder&title=Main_Page&timestamp=$tomorrowTimestampStr"
+ => 'apihelp-echoarticlereminder-example-2',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Echo_(Notifications)/API';
+ }
+}
diff --git a/Echo/includes/api/ApiEchoMarkRead.php b/Echo/includes/api/ApiEchoMarkRead.php
index 13e069e1..551c183e 100644
--- a/Echo/includes/api/ApiEchoMarkRead.php
+++ b/Echo/includes/api/ApiEchoMarkRead.php
@@ -8,7 +8,7 @@ class ApiEchoMarkRead extends ApiBase {
$user = $this->getUser();
if ( $user->isAnon() ) {
- $this->dieUsage( 'Login is required', 'login-required' );
+ $this->dieWithError( 'apierror-mustbeloggedin-generic', 'login-required' );
}
$notifUser = MWEchoNotifUser::newFromUser( $user );
@@ -16,7 +16,7 @@ class ApiEchoMarkRead extends ApiBase {
$params = $this->extractRequestParams();
// There is no need to trigger markRead if all notifications are read
- if ( $notifUser->getNotificationCount() > 0 ) {
+ if ( $notifUser->getLocalNotificationCount() > 0 ) {
if ( count( $params['list'] ) ) {
// Make sure there is a limit to the update
$notifUser->markRead( array_slice( $params['list'], 0, ApiBase::LIMIT_SML2 ) );
@@ -29,9 +29,15 @@ class ApiEchoMarkRead extends ApiBase {
}
}
- $result = array(
+ // Mark as unread
+ if ( count( $params['unreadlist'] ) > 0 ) {
+ // Make sure there is a limit to the update
+ $notifUser->markUnRead( array_slice( $params['unreadlist'], 0, ApiBase::LIMIT_SML2 ) );
+ }
+
+ $result = [
'result' => 'success'
- );
+ ];
$rawCount = 0;
foreach ( EchoAttributeManager::$sections as $section ) {
$rawSectionCount = $notifUser->getNotificationCount( /* $tryCache = */true, DB_SLAVE, $section );
@@ -40,42 +46,33 @@ class ApiEchoMarkRead extends ApiBase {
$rawCount += $rawSectionCount;
}
- $result += array(
+ $result += [
'rawcount' => $rawCount,
'count' => EchoNotificationController::formatNotificationCount( $rawCount ),
- );
+ ];
$this->getResult()->addValue( 'query', $this->getModuleName(), $result );
}
public function getAllowedParams() {
- return array(
- 'list' => array(
+ return [
+ 'list' => [
ApiBase::PARAM_ISMULTI => true,
- ),
- 'all' => array(
+ ],
+ 'unreadlist' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'all' => [
ApiBase::PARAM_REQUIRED => false,
ApiBase::PARAM_TYPE => 'boolean'
- ),
- 'sections' => array(
+ ],
+ 'sections' => [
ApiBase::PARAM_TYPE => EchoAttributeManager::$sections,
ApiBase::PARAM_ISMULTI => true,
- ),
- 'token' => array(
+ ],
+ 'token' => [
ApiBase::PARAM_REQUIRED => true,
- ),
- );
- }
-
- /**
- * @deprecated since MediaWiki core 1.25
- */
- public function getParamDescription() {
- return array(
- 'list' => 'A list of notification IDs to mark as read',
- 'all' => "If set to true, marks all of a user's notifications as read",
- 'sections' => 'A list of sections to mark as read',
- 'token' => 'edit token',
- );
+ ],
+ ];
}
public function needsToken() {
@@ -95,32 +92,17 @@ class ApiEchoMarkRead extends ApiBase {
}
/**
- * @deprecated since MediaWiki core 1.25
- */
- public function getDescription() {
- return 'Mark notifications as read for the current user';
- }
-
- /**
- * @deprecated since MediaWiki core 1.25
- */
- public function getExamples() {
- return array(
- 'api.php?action=echomarkread&list=8',
- 'api.php?action=echomarkread&all=true'
- );
- }
-
- /**
* @see ApiBase::getExamplesMessages()
*/
protected function getExamplesMessages() {
- return array(
+ return [
'action=echomarkread&list=8'
=> 'apihelp-echomarkread-example-1',
'action=echomarkread&all=true'
=> 'apihelp-echomarkread-example-2',
- );
+ 'action=echomarkread&unreadlist=1'
+ => 'apihelp-echomarkread-example-3',
+ ];
}
public function getHelpUrls() {
diff --git a/Echo/includes/api/ApiEchoMarkSeen.php b/Echo/includes/api/ApiEchoMarkSeen.php
index ce4e4422..d06852ac 100644
--- a/Echo/includes/api/ApiEchoMarkSeen.php
+++ b/Echo/includes/api/ApiEchoMarkSeen.php
@@ -8,7 +8,7 @@ class ApiEchoMarkSeen extends ApiBase {
$user = $this->getUser();
if ( $user->isAnon() ) {
- $this->dieUsage( 'Login is required', 'login-required' );
+ $this->dieWithError( 'apierror-mustbeloggedin-generic', 'login-required' );
}
$params = $this->extractRequestParams();
@@ -16,31 +16,36 @@ class ApiEchoMarkSeen extends ApiBase {
$seenTime = EchoSeenTime::newFromUser( $user );
$seenTime->setTime( $timestamp, $params['type'] );
- $this->getResult()->addValue( 'query', $this->getModuleName(), array(
+ if ( $params['timestampFormat'] === 'ISO_8601' ) {
+ $outputTimestamp = wfTimestamp( TS_ISO_8601, $timestamp );
+ } else {
+ // MW
+ $this->addDeprecation( 'apiwarn-echo-deprecation-timestampformat', 'action=echomarkseen&timestampFormat=MW' );
+
+ $outputTimestamp = $timestamp;
+ }
+
+ $this->getResult()->addValue( 'query', $this->getModuleName(), [
'result' => 'success',
- 'timestamp' => $timestamp,
- ) );
+ 'timestamp' => $outputTimestamp,
+ ] );
}
public function getAllowedParams() {
- return array(
- 'token' => array(
+ return [
+ 'token' => [
ApiBase::PARAM_REQUIRED => true,
- ),
- 'type' => array(
+ ],
+ 'type' => [
ApiBase::PARAM_REQUIRED => true,
- ApiBase::PARAM_TYPE => array( 'alert', 'message', 'all' ),
- )
- );
- }
-
- /**
- * @deprecated since MediaWiki core 1.25
- */
- public function getParamDescription() {
- return array(
- 'token' => 'edit token',
- );
+ ApiBase::PARAM_TYPE => [ 'alert', 'message', 'all' ],
+ ],
+ 'timestampFormat' => [
+ // Not using the TS constants, since clients can't.
+ ApiBase::PARAM_DFLT => 'MW',
+ ApiBase::PARAM_TYPE => [ 'ISO_8601', 'MW' ],
+ ],
+ ];
}
public function needsToken() {
@@ -60,28 +65,12 @@ class ApiEchoMarkSeen extends ApiBase {
}
/**
- * @deprecated since MediaWiki core 1.25
- */
- public function getDescription() {
- return 'Mark notifications as seen for the current user';
- }
-
- /**
- * @deprecated since MediaWiki core 1.25
- */
- public function getExamples() {
- return array(
- 'api.php?action=echomarkseen',
- );
- }
-
- /**
* @see ApiBase::getExamplesMessages()
*/
protected function getExamplesMessages() {
- return array(
+ return [
'action=echomarkseen&type=all' => 'apihelp-echomarkseen-example-1',
- );
+ ];
}
public function getHelpUrls() {
diff --git a/Echo/includes/api/ApiEchoNotifications.php b/Echo/includes/api/ApiEchoNotifications.php
index 278e5cf4..121499c3 100644
--- a/Echo/includes/api/ApiEchoNotifications.php
+++ b/Echo/includes/api/ApiEchoNotifications.php
@@ -1,6 +1,10 @@
<?php
-class ApiEchoNotifications extends ApiQueryBase {
+class ApiEchoNotifications extends ApiCrossWikiBase {
+ /**
+ * @var bool
+ */
+ protected $crossWikiSummary = false;
public function __construct( $query, $moduleName ) {
parent::__construct( $query, $moduleName, 'not' );
@@ -10,88 +14,153 @@ class ApiEchoNotifications extends ApiQueryBase {
// To avoid API warning, register the parameter used to bust browser cache
$this->getMain()->getVal( '_' );
- $user = $this->getUser();
- if ( $user->isAnon() ) {
- $this->dieUsage( 'Login is required', 'login-required' );
+ if ( $this->getUser()->isAnon() ) {
+ $this->dieWithError( 'apierror-mustbeloggedin-generic', 'login-required' );
}
$params = $this->extractRequestParams();
+
+ /* @deprecated */
+ if ( $params['format'] === 'flyout' ) {
+ $this->addDeprecation( 'apiwarn-echo-deprecation-flyout', 'action=query&meta=notifications&notformat=flyout' );
+ } elseif ( $params['format'] === 'html' ) {
+ $this->addDeprecation( 'apiwarn-echo-deprecation-html', 'action=query&meta=notifications&notformat=html' );
+ }
+
+ if ( $this->allowCrossWikiNotifications() ) {
+ $this->crossWikiSummary = $params['crosswikisummary'];
+ }
+
+ $results = [];
+ if ( in_array( wfWikiID(), $this->getRequestedWikis() ) ) {
+ $results[wfWikiID()] = $this->getLocalNotifications( $params );
+ }
+
+ if ( $this->getRequestedForeignWikis() ) {
+ $foreignResults = $this->getFromForeign();
+ foreach ( $foreignResults as $wiki => $result ) {
+ if ( isset( $result['query']['notifications'] ) ) {
+ $results[$wiki] = $result['query']['notifications'];
+ }
+ }
+ }
+
+ // after getting local & foreign results, merge them all together
+ $result = $this->mergeResults( $results, $params );
+ if ( $params['groupbysection'] ) {
+ foreach ( $params['sections'] as $section ) {
+ if ( in_array( 'list', $params['prop'] ) ) {
+ $this->getResult()->setIndexedTagName( $result[$section]['list'], 'notification' );
+ }
+ }
+ } else {
+ if ( in_array( 'list', $params['prop'] ) ) {
+ $this->getResult()->setIndexedTagName( $result['list'], 'notification' );
+ }
+ }
+ $this->getResult()->addValue( 'query', $this->getModuleName(), $result );
+ }
+
+ /**
+ * @param array $params
+ * @return array
+ */
+ protected function getLocalNotifications( array $params ) {
+ $user = $this->getUser();
$prop = $params['prop'];
+ $titles = null;
+ if ( $params['titles'] ) {
+ $titles = array_values( array_filter( array_map( 'Title::newFromText', $params['titles'] ) ) );
+ if ( in_array( '[]', $params['titles'] ) ) {
+ $titles[] = null;
+ }
+ }
- $result = array();
+ $result = [];
if ( in_array( 'list', $prop ) ) {
// Group notification results by section
if ( $params['groupbysection'] ) {
- wfProfileIn( __METHOD__ . '-group-by-section' );
foreach ( $params['sections'] as $section ) {
$result[$section] = $this->getSectionPropList(
- $user, $section, $params['limit'],
- $params[$section . 'continue'], $params['format'], $params[$section . 'unreadfirst']
+ $user, $section, $params['filter'], $params['limit'],
+ $params[$section . 'continue'], $params['format'],
+ $titles, $params[$section . 'unreadfirst'], $params['bundle']
);
- $this->getResult()->setIndexedTagName( $result[$section]['list'], 'notification' );
- // 'index' is built on top of 'list'
- if ( in_array( 'index', $prop ) ) {
- $result[$section]['index'] = $this->getPropIndex( $result[$section]['list'] );
- $this->getResult()->setIndexedTagName( $result[$section]['index'], 'id' );
+
+ if ( $this->crossWikiSummary ) {
+ // insert fake notification for foreign notifications
+ $foreignNotification = $this->makeForeignNotification( $user, $params['format'], $section );
+ if ( $foreignNotification ) {
+ array_unshift( $result[$section]['list'], $foreignNotification );
+ }
}
}
- wfProfileOut( __METHOD__ . '-group-by-section' );
} else {
- wfProfileIn( __METHOD__ . '-group-by-none' );
$attributeManager = EchoAttributeManager::newFromGlobalVars();
$result = $this->getPropList(
$user,
$attributeManager->getUserEnabledEventsbySections( $user, 'web', $params['sections'] ),
- $params['limit'], $params['continue'], $params['format']
+ $params['filter'], $params['limit'], $params['continue'], $params['format'],
+ $titles, $params['unreadfirst'], $params['bundle']
);
- $this->getResult()->setIndexedTagName( $result['list'], 'notification' );
- // 'index' is built on top of 'list'
- if ( in_array( 'index', $prop ) ) {
- $result['index'] = $this->getPropIndex( $result['list'] );
- $this->getResult()->setIndexedTagName( $result['index'], 'id' );
+
+ // 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;
+ if ( $this->crossWikiSummary ) {
+ $foreignNotification = $this->makeForeignNotification( $user, $params['format'], $section );
+ if ( $foreignNotification ) {
+ array_unshift( $result['list'], $foreignNotification );
+ }
}
- wfProfileOut( __METHOD__ . '-group-by-none' );
}
}
if ( in_array( 'count', $prop ) ) {
- wfProfileIn( __METHOD__ . '-count' );
$result = array_merge_recursive(
$result,
- $this->getPropcount( $user, $params['sections'], $params['groupbysection'] )
+ $this->getPropCount( $user, $params['sections'], $params['groupbysection'] )
);
- wfProfileOut( __METHOD__ . '-count' );
}
- $this->getResult()->setIndexedTagName( $result, 'notification' );
- $this->getResult()->addValue( 'query', $this->getModuleName(), $result );
+ if ( in_array( 'seenTime', $prop ) ) {
+ $result = array_merge_recursive(
+ $result,
+ $this->getPropSeenTime( $user, $params['sections'], $params['groupbysection'] )
+ );
+ }
+
+ return $result;
}
/**
* 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 int $limit
* @param string $continue
* @param string $format
- * @param boolean $unreadFirst
+ * @param Title[] $titles
+ * @param bool $unreadFirst
+ * @param bool $bundle
* @return array
*/
- protected function getSectionPropList( User $user, $section, $limit, $continue, $format, $unreadFirst = false ) {
- $notifUser = MWEchoNotifUser::newFromUser( $user );
+ protected function getSectionPropList( User $user, $section, $filter, $limit, $continue, $format, array $titles = null, $unreadFirst = false, $bundle = false ) {
$attributeManager = EchoAttributeManager::newFromGlobalVars();
- $sectionEvents = $attributeManager->getUserEnabledEventsbySections( $user, 'web', array( $section ) );
+ $sectionEvents = $attributeManager->getUserEnabledEventsbySections( $user, 'web', [ $section ] );
if ( !$sectionEvents ) {
- $result = array(
- 'list' => array(),
+ $result = [
+ 'list' => [],
'continue' => null
- );
+ ];
} else {
$result = $this->getPropList(
- $user, $sectionEvents, $limit, $continue, $format, $unreadFirst
+ $user, $sectionEvents, $filter, $limit, $continue, $format, $titles, $unreadFirst, $bundle
);
}
+
return $result;
}
@@ -101,60 +170,114 @@ 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 int $limit
* @param string $continue
* @param string $format
- * @param boolean $unreadFirst
+ * @param Title[] $titles
+ * @param bool $unreadFirst
+ * @param bool $bundle
* @return array
*/
- protected function getPropList( User $user, array $eventTypes, $limit, $continue, $format, $unreadFirst = false ) {
- $result = array(
- 'list' => array(),
+ protected function getPropList( User $user, array $eventTypes, $filter, $limit, $continue, $format, array $titles = null, $unreadFirst = false, $bundle = false ) {
+ $result = [
+ 'list' => [],
'continue' => null
- );
+ ];
$notifMapper = new EchoNotificationMapper();
- // Prefer unread notifications. We don't care about next offset in this case
- if ( $unreadFirst ) {
- wfProfileIn( __METHOD__ . '-fetch-data-unread-first' );
- $notifs = $notifMapper->fetchUnreadByUser( $user, $limit, $eventTypes );
- // If there are less unread notifications than we requested,
- // then fill the result with some read notifications
- $count = count( $notifs );
- if ( $count < $limit ) {
- // Query planner should be smart enough that passing a short list of ids to exclude
- // will only visit at most that number of extra rows.
- $mixedNotifs = $notifMapper->fetchByUser(
- $user,
- $limit - $count,
- null,
- $eventTypes,
- array_keys( $notifs )
- );
- foreach ( $mixedNotifs as $notif ) {
- $notifs[$notif->getEvent()->getId()] = $notif;
+ // check if we want both read & unread...
+ if ( in_array( 'read', $filter ) && in_array( '!read', $filter ) ) {
+ // Prefer unread notifications. We don't care about next offset in this case
+ if ( $unreadFirst ) {
+ // query for unread notifications past 'continue' (offset)
+ $notifs = $notifMapper->fetchUnreadByUser( $user, $limit + 1, $continue, $eventTypes, $titles );
+
+ /*
+ * 'continue' has a timestamp & id (to start with, in case
+ * there would be multiple events with that same timestamp)
+ * Unread notifications should always load first, but may be
+ * older than read ones, but we can work with current
+ * 'continue' format:
+ * * if there's no continue, first load unread notifications
+ * * if there's a continue, fetch unread notifications first
+ * * if there are no unread ones, continue must've been
+ * about read notifications: fetch 'em
+ * * if there are unread ones but first one doesn't match
+ * continue id, it must've been about read notifications:
+ * discard unread & fetch read
+ */
+ if ( $notifs && $continue ) {
+ /** @var EchoNotification $first */
+ $first = reset( $notifs );
+ $continueId = intval( trim( strrchr( $continue, '|' ), '|' ) );
+ if ( $first->getEvent()->getID() !== $continueId ) {
+ // notification doesn't match continue id, it must've been
+ // about read notifications: discard all unread ones
+ $notifs = [];
+ }
+ }
+
+ // If there are less unread notifications than we requested,
+ // then fill the result with some read notifications
+ $count = count( $notifs );
+ // we need 1 more than $limit, so we can respond 'continue'
+ if ( $count <= $limit ) {
+ // Query planner should be smart enough that passing a short list of ids to exclude
+ // will only visit at most that number of extra rows.
+ $mixedNotifs = $notifMapper->fetchByUser(
+ $user,
+ $limit - $count + 1,
+ // if there were unread notifications, 'continue' was for
+ // unread notifications and we should start fetching read
+ // notifications from start
+ $count > 0 ? null : $continue,
+ $eventTypes,
+ array_keys( $notifs ),
+ $titles
+ );
+ foreach ( $mixedNotifs as $notif ) {
+ $notifs[$notif->getEvent()->getId()] = $notif;
+ }
}
+ } else {
+ $notifs = $notifMapper->fetchByUser( $user, $limit + 1, $continue, $eventTypes, [], $titles );
}
- wfProfileOut( __METHOD__ . '-fetch-data-unread-first' );
- } else {
- wfProfileIn( __METHOD__ . '-fetch-data' );
- $notifs = $notifMapper->fetchByUser( $user, $limit + 1, $continue, $eventTypes );
- wfProfileOut( __METHOD__ . '-fetch-data' );
+ } elseif ( in_array( 'read', $filter ) ) {
+ $notifs = $notifMapper->fetchReadByUser( $user, $limit + 1, $continue, $eventTypes, $titles );
+ } else { // = if ( in_array( '!read', $filter ) ) {
+ $notifs = $notifMapper->fetchUnreadByUser( $user, $limit + 1, $continue, $eventTypes, $titles );
+ }
+
+ // get $overfetchedItem before bundling and rendering so that it is not affected by filtering
+ /** @var EchoNotification $overfetchedItem */
+ $overfetchedItem = count( $notifs ) > $limit ? array_pop( $notifs ) : null;
+
+ if ( $bundle ) {
+ $bundler = new Bundler();
+ $notifs = $bundler->bundle( $notifs );
}
- wfProfileIn( __METHOD__ . '-formatting' );
- foreach ( $notifs as $notif ) {
- $result['list'][$notif->getEvent()->getID()] = EchoDataOutputFormatter::formatOutput( $notif, $format, $user );
+ while ( count( $notifs ) ) {
+ /** @var EchoNotification $notif */
+ $notif = array_shift( $notifs );
+ $output = EchoDataOutputFormatter::formatOutput( $notif, $format, $user, $this->getLanguage() );
+ if ( $output !== false ) {
+ $result['list'][] = $output;
+ } elseif ( $bundle && $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 );
+ }
}
- wfProfileOut( __METHOD__ . '-formatting' );
// Generate offset if necessary
- if ( !$unreadFirst ) {
- if ( count( $result['list'] ) > $limit ) {
- $lastItem = array_pop( $result['list'] );
- $result['continue'] = $lastItem['timestamp']['utcunix'] . '|' . $lastItem['id'];
- }
+ if ( $overfetchedItem ) {
+ // @todo: what to do with this when fetching from multiple wikis?
+ $timestamp = wfTimestamp( TS_UNIX, $overfetchedItem->getTimestamp() );
+ $id = $overfetchedItem->getEvent()->getId();
+ $result['continue'] = $timestamp . '|' . $id;
}
return $result;
@@ -164,140 +287,359 @@ class ApiEchoNotifications extends ApiQueryBase {
* Internal helper method for getting property 'count' data
* @param User $user
* @param string[] $sections
- * @param boolean $groupBySection
+ * @param bool $groupBySection
* @return array
*/
protected function getPropCount( User $user, array $sections, $groupBySection ) {
- $result = array();
+ $result = [];
$notifUser = MWEchoNotifUser::newFromUser( $user );
- // Always get total count
- $rawCount = $notifUser->getNotificationCount();
- $result['rawcount'] = $rawCount;
- $result['count'] = EchoNotificationController::formatNotificationCount( $rawCount );
+ $global = $this->crossWikiSummary ? 'preference' : false;
- if ( $groupBySection ) {
- foreach ( $sections as $section ) {
- $rawCount = $notifUser->getNotificationCount( /* $tryCache = */true, DB_SLAVE, $section );
+ $totalRawCount = 0;
+ foreach ( $sections as $section ) {
+ $rawCount = $notifUser->getNotificationCount( /* $tryCache = */true, DB_SLAVE, $section, $global );
+ if ( $groupBySection ) {
$result[$section]['rawcount'] = $rawCount;
$result[$section]['count'] = EchoNotificationController::formatNotificationCount( $rawCount );
}
+ $totalRawCount += $rawCount;
}
+ $result['rawcount'] = $totalRawCount;
+ $result['count'] = EchoNotificationController::formatNotificationCount( $totalRawCount );
+
return $result;
}
/**
- * Internal helper method for getting property 'index' data
- * @param array $list
+ * Internal helper method for getting property 'seenTime' data
+ * @param User $user
+ * @param string[] $sections
+ * @param bool $groupBySection
* @return array
*/
- protected function getPropIndex( $list ) {
- $result = array();
- foreach ( array_keys( $list ) as $key ) {
- // Don't include the XML tag name ('_element' key)
- if ( $key != '_element' ) {
- $result[] = $key;
+ protected function getPropSeenTime( User $user, array $sections, $groupBySection ) {
+ $result = [];
+ $seenTimeHelper = EchoSeenTime::newFromUser( $user );
+
+ if ( $groupBySection ) {
+ foreach ( $sections as $section ) {
+ $result[$section]['seenTime'] = $seenTimeHelper->getTime( $section, TS_ISO_8601 );
+ }
+ } else {
+ $result['seenTime'] = [];
+ foreach ( $sections as $section ) {
+ $result['seenTime'][$section] = $seenTimeHelper->getTime( $section, TS_ISO_8601 );
}
}
+
return $result;
}
+ /**
+ * Build and format a "fake" notification to represent foreign notifications.
+ * @param User $user
+ * @param string $format
+ * @param string $section
+ * @return array|false A formatted notification, or false if there are no foreign notifications
+ */
+ protected function makeForeignNotification( User $user, $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->foreignNotifications->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->foreignNotifications->getWikis( $section );
+ $count = $this->foreignNotifications->getCount( $section );
+ $maxTimestamp = $this->foreignNotifications->getTimestamp( $section );
+ $timestampsByWiki = [];
+ foreach ( $wikis as $wiki ) {
+ $timestampsByWiki[$wiki] = $this->foreignNotifications->getWikiTimestamp( $wiki, $section );
+ }
+ }
+
+ if ( $count === 0 || count( $wikis ) === 0 ) {
+ return false;
+ }
+
+ // 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 );
+ } );
+
+ $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,
+ 'count' => $count
+ ] );
+ $row->event_deleted = 0;
+
+ $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 );
+ $output = EchoDataOutputFormatter::formatOutput( $notif, $format, $user, $this->getLanguage() );
+
+ // Add cross-wiki-specific data
+ $output['section'] = $section ?: 'all';
+ $output['count'] = $count;
+ $output['sources'] = EchoForeignNotifications::getApiEndpoints( $wikis );
+ // Add timestamp information
+ foreach ( $output['sources'] as $wiki => &$data ) {
+ $data['ts'] = $timestampsByWiki[$wiki]->getTimestamp( TS_ISO_8601 );
+ }
+ return $output;
+ }
+
+ protected function getForeignQueryParams() {
+ $params = parent::getForeignQueryParams();
+
+ // don't request cross-wiki notification summaries
+ unset( $params['notcrosswikisummary'] );
+
+ return $params;
+ }
+
+ /**
+ * @param array $results
+ * @param array $params
+ * @return mixed
+ */
+ protected function mergeResults( array $results, array $params ) {
+ $master = array_shift( $results );
+ if ( !$master ) {
+ $master = [];
+ }
+
+ if ( in_array( 'list', $params['prop'] ) ) {
+ $master = $this->mergeList( $master, $results, $params['groupbysection'] );
+ }
+
+ if ( in_array( 'count', $params['prop'] ) && !$this->crossWikiSummary ) {
+ // if crosswiki data was requested, the count in $master
+ // is accurate already
+ // otherwise, we'll want to combine counts for all wikis
+ $master = $this->mergeCount( $master, $results, $params['groupbysection'] );
+ }
+
+ return $master;
+ }
+
+ /**
+ * @param array $master
+ * @param array $results
+ * @param bool $groupBySection
+ * @return array
+ */
+ protected function mergeList( array $master, array $results, $groupBySection ) {
+ // sort all notifications by timestamp: most recent first
+ $sort = function ( $a, $b ) {
+ return $a['timestamp']['utcunix'] - $b['timestamp']['utcunix'];
+ };
+
+ if ( $groupBySection ) {
+ foreach ( EchoAttributeManager::$sections as $section ) {
+ if ( !isset( $master[$section]['list'] ) ) {
+ $master[$section]['list'] = [];
+ }
+ foreach ( $results as $result ) {
+ $master[$section]['list'] = array_merge( $master[$section]['list'], $result[$section]['list'] );
+ }
+ usort( $master[$section]['list'], $sort );
+ }
+ } else {
+ if ( !isset( $master['list'] ) || !is_array( $master['list'] ) ) {
+ $master['list'] = [];
+ }
+ foreach ( $results as $result ) {
+ $master['list'] = array_merge( $master['list'], $result['list'] );
+ }
+ usort( $master['list'], $sort );
+ }
+
+ return $master;
+ }
+
+ /**
+ * @param array $master
+ * @param array $results
+ * @param bool $groupBySection
+ * @return array
+ */
+ protected function mergeCount( array $master, array $results, $groupBySection ) {
+ if ( $groupBySection ) {
+ foreach ( EchoAttributeManager::$sections as $section ) {
+ if ( !isset( $master[$section]['rawcount'] ) ) {
+ $master[$section]['rawcount'] = 0;
+ }
+ foreach ( $results as $result ) {
+ $master[$section]['rawcount'] += $result[$section]['rawcount'];
+ }
+ $master[$section]['count'] = EchoNotificationController::formatNotificationCount( $master[$section]['rawcount'] );
+ }
+ }
+
+ if ( !isset( $master['rawcount'] ) ) {
+ $master['rawcount'] = 0;
+ }
+ foreach ( $results as $result ) {
+ // regardless of groupbysection, totals are always included
+ $master['rawcount'] += $result['rawcount'];
+ }
+ $master['count'] = EchoNotificationController::formatNotificationCount( $master['rawcount'] );
+
+ return $master;
+ }
+
public function getAllowedParams() {
$sections = EchoAttributeManager::$sections;
- $params = array(
- 'prop' => array(
+
+ $params = parent::getAllowedParams();
+ $params += [
+ 'filter' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_DFLT => 'read|!read',
+ ApiBase::PARAM_TYPE => [
+ 'read',
+ '!read',
+ ],
+ ],
+ 'prop' => [
ApiBase::PARAM_ISMULTI => true,
- ApiBase::PARAM_TYPE => array(
+ ApiBase::PARAM_TYPE => [
'list',
'count',
- 'index',
- ),
+ 'seenTime',
+ ],
ApiBase::PARAM_DFLT => 'list',
- ),
- 'sections' => array(
+ ],
+ 'sections' => [
ApiBase::PARAM_DFLT => implode( '|', $sections ),
ApiBase::PARAM_TYPE => $sections,
ApiBase::PARAM_ISMULTI => true,
- ),
- 'groupbysection' => array(
+ ],
+ 'groupbysection' => [
ApiBase::PARAM_TYPE => 'boolean',
ApiBase::PARAM_DFLT => false,
- ),
- 'format' => array(
- ApiBase::PARAM_TYPE => array(
- 'text',
- 'flyout',
- 'html',
- ),
- ),
- 'limit' => array(
+ ],
+ 'format' => [
+ ApiBase::PARAM_TYPE => [
+ 'model',
+ 'special',
+ 'flyout', /* @deprecated */
+ 'html', /* @deprecated */
+ ],
+ ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
+ ],
+ 'limit' => [
ApiBase::PARAM_TYPE => 'limit',
ApiBase::PARAM_DFLT => 20,
ApiBase::PARAM_MIN => 1,
ApiBase::PARAM_MAX => ApiBase::LIMIT_SML1,
ApiBase::PARAM_MAX2 => ApiBase::LIMIT_SML2,
- ),
- 'index' => false,
- 'continue' => array(
- /** @todo Once support for MediaWiki < 1.25 is dropped, just use ApiBase::PARAM_HELP_MSG directly */
- constant( 'ApiBase::PARAM_HELP_MSG' ) ?: '' => 'api-help-param-continue',
- ),
- );
+ ],
+ 'continue' => [
+ ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
+ ],
+ 'unreadfirst' => [
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_DFLT => false,
+ ],
+ 'titles' => [
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ 'bundle' => [
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_DFLT => false,
+ ],
+ ];
foreach ( $sections as $section ) {
$params[$section . 'continue'] = null;
- $params[$section . 'unreadfirst'] = array(
+ $params[$section . 'unreadfirst'] = [
ApiBase::PARAM_TYPE => 'boolean',
ApiBase::PARAM_DFLT => false,
- );
+ ];
}
- return $params;
- }
-
- /**
- * @deprecated since MediaWiki core 1.25
- */
- public function getParamDescription() {
- return array(
- 'prop' => 'Details to request.',
- 'sections' => 'The notification sections to query (i.e. some combination of \'alert\' and \'message\').',
- 'groupbysection' => 'Whether to group the result by section, each section is fetched separately if set',
- 'format' => 'If specified, notifications will be returned formatted this way.',
- 'index' => 'If specified, a list of notification IDs, in order, will be returned.',
- 'limit' => 'The maximum number of notifications to return.',
- 'continue' => 'When more results are available, use this to continue, this is used only when groupbysection is not set.',
- 'alertcontinue' => 'When more alert results are available, use this to continue.',
- 'messagecontinue' => 'When more message results are available, use this to continue.',
- 'alertunreadfirst' => 'Whether to show unread message notifications first',
- 'messageunreadfirst' => 'Whether to show unread alert notifications first'
- );
- }
- /**
- * @deprecated since MediaWiki core 1.25
- */
- public function getDescription() {
- return 'Get notifications waiting for the current user';
- }
+ if ( $this->allowCrossWikiNotifications() ) {
+ $params += [
+ // create "x notifications from y wikis" notification bundle &
+ // include unread counts from other wikis in prop=count results
+ 'crosswikisummary' => [
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_DFLT => false,
+ ],
+ ];
+ }
- /**
- * @deprecated since MediaWiki core 1.25
- */
- public function getExamples() {
- return array(
- 'api.php?action=query&meta=notifications',
- 'api.php?action=query&meta=notifications&notprop=count&notsections=alert|message&notgroupbysection=1',
- );
+ return $params;
}
/**
* @see ApiBase::getExamplesMessages()
+ * @return array
*/
protected function getExamplesMessages() {
- return array(
+ return [
'action=query&meta=notifications'
=> 'apihelp-query+notifications-example-1',
'action=query&meta=notifications&notprop=count&notsections=alert|message&notgroupbysection=1'
=> 'apihelp-query+notifications-example-2',
- );
+ ];
}
public function getHelpUrls() {
diff --git a/Echo/includes/api/ApiEchoUnreadNotificationPages.php b/Echo/includes/api/ApiEchoUnreadNotificationPages.php
new file mode 100644
index 00000000..53c1f761
--- /dev/null
+++ b/Echo/includes/api/ApiEchoUnreadNotificationPages.php
@@ -0,0 +1,212 @@
+<?php
+
+class ApiEchoUnreadNotificationPages extends ApiCrossWikiBase {
+ /**
+ * @var bool
+ */
+ protected $crossWikiSummary = false;
+
+ /**
+ * @param ApiQuery $query
+ * @param string $moduleName
+ */
+ public function __construct( $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'unp' );
+ }
+
+ /**
+ * @throws ApiUsageException
+ */
+ public function execute() {
+ // To avoid API warning, register the parameter used to bust browser cache
+ $this->getMain()->getVal( '_' );
+
+ if ( $this->getUser()->isAnon() ) {
+ $this->dieWithError( 'apierror-mustbeloggedin-generic', 'login-required' );
+ }
+
+ $params = $this->extractRequestParams();
+
+ $result = [];
+ if ( in_array( wfWikiId(), $this->getRequestedWikis() ) ) {
+ $result[wfWikiID()] = $this->getFromLocal( $params['limit'], $params['grouppages'] );
+ }
+
+ if ( $this->getRequestedForeignWikis() ) {
+ $result += $this->getUnreadNotificationPagesFromForeign();
+ }
+
+ $apis = $this->foreignNotifications->getApiEndpoints( $this->getRequestedWikis() );
+ foreach ( $result as $wiki => $data ) {
+ $result[$wiki]['source'] = $apis[$wiki];
+ $result[$wiki]['pages'] = $data['pages'] ?: [];
+ }
+
+ $this->getResult()->addValue( 'query', $this->getModuleName(), $result );
+ }
+
+ /**
+ * @param int $limit
+ * @param bool $groupPages
+ * @return array
+ */
+ protected function getFromLocal( $limit, $groupPages ) {
+ $attributeManager = EchoAttributeManager::newFromGlobalVars();
+ $enabledTypes = $attributeManager->getUserEnabledEvents( $this->getUser(), 'web' );
+
+ $dbr = MWEchoDbFactory::newFromDefault()->getEchoDb( DB_SLAVE );
+ // If $groupPages is true, we need to fetch all pages and apply the ORDER BY and LIMIT ourselves
+ // after grouping.
+ $extraOptions = $groupPages ? [] : [ 'ORDER BY' => 'count DESC', 'LIMIT' => $limit ];
+ $rows = $dbr->select(
+ [ 'echo_event', 'echo_notification' ],
+ [ 'event_page_id', 'count' => 'COUNT(*)' ],
+ [
+ 'notification_user' => $this->getUser()->getId(),
+ 'notification_read_timestamp' => null,
+ 'event_deleted' => 0,
+ 'event_type' => $enabledTypes,
+ ],
+ __METHOD__,
+ [
+ 'GROUP BY' => 'event_page_id',
+ ] + $extraOptions,
+ [ 'echo_notification' => [ 'INNER JOIN', 'notification_event = event_id' ] ]
+ );
+
+ if ( $rows === false ) {
+ return [];
+ }
+
+ $nullCount = 0;
+ $pageCounts = [];
+ foreach ( $rows as $row ) {
+ if ( $row->event_page_id !== null ) {
+ $pageCounts[$row->event_page_id] = intval( $row->count );
+ } else {
+ $nullCount = intval( $row->count );
+ }
+ }
+
+ $titles = Title::newFromIDs( array_keys( $pageCounts ) );
+
+ $groupCounts = [];
+ foreach ( $titles as $title ) {
+ if ( $groupPages ) {
+ // If $title is a talk page, add its count to its subject page's count
+ $pageName = $title->getSubjectPage()->getPrefixedText();
+ } else {
+ $pageName = $title->getPrefixedText();
+ }
+
+ $count = $pageCounts[$title->getArticleId()];
+ if ( isset( $groupCounts[$pageName] ) ) {
+ $groupCounts[$pageName] += $count;
+ } else {
+ $groupCounts[$pageName] = $count;
+ }
+ }
+
+ $userPageName = $this->getUser()->getUserPage()->getPrefixedText();
+ if ( $nullCount > 0 && $groupPages ) {
+ // Add the count for NULL (not associated with any page) to the count for the user page
+ if ( isset( $groupCounts[$userPageName] ) ) {
+ $groupCounts[$userPageName] += $nullCount;
+ } else {
+ $groupCounts[$userPageName] = $nullCount;
+ }
+ }
+
+ arsort( $groupCounts );
+ if ( $groupPages ) {
+ $groupCounts = array_slice( $groupCounts, 0, $limit );
+ }
+
+ $result = [];
+ foreach ( $groupCounts as $pageName => $count ) {
+ if ( $groupPages ) {
+ $title = Title::newFromText( $pageName );
+ $pages = [ $title->getSubjectPage()->getPrefixedText() ];
+ if ( $title->canHaveTalkPage() ) {
+ $pages[] = $title->getTalkPage()->getPrefixedText();
+ }
+ if ( $pageName === $userPageName ) {
+ $pages[] = null;
+ }
+ $pageDescription = [
+ 'ns' => $title->getNamespace(),
+ 'title' => $title->getPrefixedText(),
+ 'unprefixed' => $title->getText(),
+ 'pages' => $pages,
+ ];
+ } else {
+ $pageDescription = [ 'title' => $pageName ];
+ }
+ $result[] = $pageDescription + [
+ 'count' => $count,
+ ];
+ }
+ if ( !$groupPages && $nullCount > 0 ) {
+ $result[] = [
+ 'title' => null,
+ 'count' => $nullCount,
+ ];
+ }
+
+ return [
+ 'pages' => $result,
+ 'totalCount' => MWEchoNotifUser::newFromUser( $this->getUser() )->getLocalNotificationCount(),
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ protected function getUnreadNotificationPagesFromForeign() {
+ $result = [];
+ foreach ( $this->getFromForeign() as $wiki => $data ) {
+ $result[$wiki] = $data['query'][$this->getModuleName()][$wiki];
+ }
+
+ return $result;
+ }
+
+ /**
+ * @return array
+ */
+ public function getAllowedParams() {
+ global $wgEchoMaxUpdateCount;
+
+ return parent::getAllowedParams() + [
+ 'grouppages' => [
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_DFLT => false,
+ ],
+ 'limit' => [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => $wgEchoMaxUpdateCount,
+ ApiBase::PARAM_MAX2 => $wgEchoMaxUpdateCount,
+ ],
+ // there is no `offset` or `continue` value: the set of possible
+ // notifications is small enough to allow fetching all of them at
+ // once, and any sort of fetching would be unreliable because
+ // they're sorted based on count of notifications, which could
+ // change in between requests
+ ];
+ }
+
+ /**
+ * @see ApiBase::getExamplesMessages()
+ */
+ protected function getExamplesMessages() {
+ return [
+ 'action=query&meta=unreadnotificationpages' => 'apihelp-query+unreadnotificationpages-example-1',
+ ];
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/Echo_(Notifications)/API';
+ }
+}
diff --git a/Echo/includes/cache/LocalCache.php b/Echo/includes/cache/LocalCache.php
index 6d357340..d951e307 100644
--- a/Echo/includes/cache/LocalCache.php
+++ b/Echo/includes/cache/LocalCache.php
@@ -15,7 +15,7 @@ abstract class EchoLocalCache {
/**
* Target object cache
- * @var MapCacheLRU
+ * @var HashBagOStuff
*/
protected $targets;
@@ -23,7 +23,7 @@ abstract class EchoLocalCache {
* Lookup ids that have not been resolved for a target
* @param int[]
*/
- protected $lookups = array();
+ protected $lookups = [];
/**
* Resolve ids in lookups to targets
@@ -34,13 +34,14 @@ abstract class EchoLocalCache {
* Use Factory method like EchoTitleLocalCache::create()
*/
protected function __construct() {
- $this->targets = new MapCacheLRU( self::TARGET_MAX_NUM );
+ $this->targets = new HashBagOStuff( [ 'maxKeys' => self::TARGET_MAX_NUM ] );
}
/**
* Add a key to the lookup and the key is used to resolve cache target
*
* @param int $key
+ * @param null $target
*/
public function add( $key, $target = null ) {
if (
@@ -70,6 +71,7 @@ abstract class EchoLocalCache {
return $target;
}
}
+
return null;
}
@@ -78,7 +80,7 @@ abstract class EchoLocalCache {
*/
public function clearAll() {
$this->targets->clear();
- $this->lookups = array();
+ $this->lookups = [];
}
/**
@@ -89,7 +91,7 @@ abstract class EchoLocalCache {
}
/**
- * @return array
+ * @return BagOStuff
*/
public function getTargets() {
return $this->targets;
diff --git a/Echo/includes/cache/RevisionLocalCache.php b/Echo/includes/cache/RevisionLocalCache.php
index 3e1f81c3..9831de49 100644
--- a/Echo/includes/cache/RevisionLocalCache.php
+++ b/Echo/includes/cache/RevisionLocalCache.php
@@ -18,11 +18,12 @@ class EchoRevisionLocalCache extends EchoLocalCache {
if ( !self::$instance ) {
self::$instance = new EchoRevisionLocalCache();
}
+
return self::$instance;
}
/**
- * {@inheritDoc}
+ * @inheritDoc
*/
protected function resolve() {
if ( $this->lookups ) {
@@ -34,21 +35,21 @@ class EchoRevisionLocalCache extends EchoLocalCache {
Revision::selectUserFields()
);
$res = $dbr->select(
- array( 'revision', 'page', 'user' ),
+ [ 'revision', 'page', 'user' ],
$fields,
- array( 'rev_id' => $this->lookups ),
+ [ 'rev_id' => $this->lookups ],
__METHOD__,
- array(),
- array(
+ [],
+ [
'page' => Revision::pageJoinCond(),
'user' => Revision::userJoinCond()
- )
+ ]
);
if ( $res ) {
foreach ( $res as $row ) {
$this->targets->set( $row->rev_id, new Revision( $row ) );
}
- $this->lookups = array();
+ $this->lookups = [];
}
}
}
diff --git a/Echo/includes/cache/TitleLocalCache.php b/Echo/includes/cache/TitleLocalCache.php
index 42e2be94..6c43790b 100644
--- a/Echo/includes/cache/TitleLocalCache.php
+++ b/Echo/includes/cache/TitleLocalCache.php
@@ -18,11 +18,12 @@ class EchoTitleLocalCache extends EchoLocalCache {
if ( !self::$instance ) {
self::$instance = new EchoTitleLocalCache();
}
+
return self::$instance;
}
/**
- * {@inheritDoc}
+ * @inheritDoc
*/
protected function resolve() {
if ( $this->lookups ) {
@@ -30,7 +31,7 @@ class EchoTitleLocalCache extends EchoLocalCache {
foreach ( $titles as $title ) {
$this->targets->set( $title->getArticleId(), $title );
}
- $this->lookups = array();
+ $this->lookups = [];
}
}
diff --git a/Echo/includes/controller/ModerationController.php b/Echo/includes/controller/ModerationController.php
new file mode 100644
index 00000000..27241b49
--- /dev/null
+++ b/Echo/includes/controller/ModerationController.php
@@ -0,0 +1,41 @@
+<?php
+use MediaWiki\MediaWikiServices;
+
+/**
+ * This class represents the controller for moderating notifications
+ */
+class EchoModerationController {
+
+ /**
+ * Moderate or unmoderate events
+ *
+ * @param int[] $eventIds
+ * @param bool $moderate Whether to moderate or unmoderate the events
+ * @throws MWException
+ */
+ public static function moderate( $eventIds, $moderate ) {
+ if ( !$eventIds ) {
+ return;
+ }
+
+ $eventMapper = new EchoEventMapper();
+ $notificationMapper = new EchoNotificationMapper();
+
+ $affectedUserIds = $notificationMapper->fetchUsersWithNotificationsForEvents( $eventIds );
+ $eventMapper->toggleDeleted( $eventIds, $moderate );
+
+ DeferredUpdates::addCallableUpdate( function () use ( $affectedUserIds ) {
+ // 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' );
+ // Recompute the notification count for the
+ // users whose notifications have been moderated.
+ foreach ( $affectedUserIds as $userId ) {
+ $user = User::newFromId( $userId );
+ MWEchoNotifUser::newFromUser( $user )->resetNotificationCount( DB_REPLICA );
+ }
+ } );
+ }
+}
diff --git a/Echo/includes/controller/NotificationController.php b/Echo/includes/controller/NotificationController.php
index cc4df9e1..788a60fb 100644
--- a/Echo/includes/controller/NotificationController.php
+++ b/Echo/includes/controller/NotificationController.php
@@ -1,59 +1,74 @@
<?php
-use MediaWiki\Logger\LoggerFactory;
/**
* This class represents the controller for notifications
*/
class EchoNotificationController {
/**
- * Echo event agent per wiki blacklist
+ * Echo maximum number of users to cache
*
- * @var string[]
+ * @var int $maxRecipientCacheSize
*/
- static protected $blacklist;
+ static protected $maxRecipientCacheSize = 200;
/**
- * Echo event agent per user whitelist, this overwrites $blacklist
+ * Echo event agent per user blacklist
*
- * @param string[]
+ * @var MapCacheLRU
*/
- static protected $userWhitelist;
+ static protected $blacklistByUser;
/**
- * Queue's that failed formatting and marks them as read at end of request.
+ * Echo event agent per wiki blacklist
*
- * @var EchoDeferredMarkAsReadUpdate|null
+ * @var EchoContainmentList|null
*/
- static protected $markAsRead;
+ static protected $wikiBlacklist;
/**
- * Format the notification count with Language::formatNum(). In addition, for large count,
- * return abbreviated version, e.g. 99+
+ * Echo event agent per user whitelist, this overwrites $blacklistByUser
*
- * @param int $count
- * @return string
+ * @var MapCacheLRU
*/
- public static function formatNotificationCount( $count ) {
- global $wgLang, $wgEchoMaxNotificationCount;
+ static protected $whitelistByUser;
- if ( $count > $wgEchoMaxNotificationCount ) {
- $count = wfMessage(
- 'echo-notification-count',
- $wgLang->formatNum( $wgEchoMaxNotificationCount )
- )->escaped();
+ /**
+ * Returns the count passed in, or MWEchoNotifUser::MAX_BADGE_COUNT + 1,
+ * whichever is less.
+ *
+ * @param int $count
+ * @return int Notification count, with ceiling applied
+ */
+ public static function getCappedNotificationCount( $count ) {
+ if ( $count <= MWEchoNotifUser::MAX_BADGE_COUNT ) {
+ return $count;
} else {
- $count = $wgLang->formatNum( $count );
+ return MWEchoNotifUser::MAX_BADGE_COUNT + 1;
}
+ }
- return $count;
+ /**
+ * Format the notification count as a string. This should only be used for an
+ * isolated string count, e.g. as displayed in personal tools or returned by the API.
+ *
+ * If using it in sentence context, pass the value from getCappedNotificationCount
+ * into a message and use PLURAL. Example: notification-bundle-header-page-linked
+ *
+ * @param int $count Notification count
+ * @return string Formatted count, after applying cap then formatting to string
+ */
+ public static function formatNotificationCount( $count ) {
+ $cappedCount = self::getCappedNotificationCount( $count );
+
+ return wfMessage( 'echo-badge-count' )->numParams( $cappedCount )->text();
}
/**
* Processes notifications for a newly-created EchoEvent
*
* @param EchoEvent $event
- * @param boolean $defer Defer to job queue or not
+ * @param bool $defer Defer to job queue or not
*/
public static function notify( $event, $defer = true ) {
// Defer to job queue if defer to job queue is requested and
@@ -61,10 +76,11 @@ class EchoNotificationController {
if ( $defer && $event->getUseJobQueue() ) {
// defer job insertion till end of request when all primary db transactions
// have been committed
- DeferredUpdates::addCallableUpdate( function() use ( $event ) {
+ DeferredUpdates::addCallableUpdate( function () use ( $event ) {
// can't use self::, php 5.3 doesn't inherit class scope
EchoNotificationController::enqueueEvent( $event );
} );
+
return;
}
@@ -76,7 +92,7 @@ class EchoNotificationController {
$type = $event->getType();
$notifyTypes = self::getEventNotifyTypes( $type );
- $userIds = array();
+ $userIds = [];
$userIdsCount = 0;
foreach ( self::getUsersToNotifyForEvent( $event ) as $user ) {
$userIds[$user->getId()] = $user->getId();
@@ -89,11 +105,11 @@ class EchoNotificationController {
$rev = Revision::newFromID( $extra['revid'], Revision::READ_LATEST );
if ( $rev->isMinor() ) {
- $notifyTypes = array_diff( $notifyTypes, array( 'email' ) );
+ $notifyTypes = array_diff( $notifyTypes, [ 'email' ] );
}
}
}
- Hooks::run( 'EchoGetNotificationTypes', array( $user, $event, &$userNotifyTypes ) );
+ Hooks::run( 'EchoGetNotificationTypes', [ $user, $event, &$userNotifyTypes ] );
// types such as web, email, etc
foreach ( $userNotifyTypes as $type ) {
@@ -104,7 +120,7 @@ class EchoNotificationController {
// Process 1000 users per NotificationDeleteJob
if ( $userIdsCount > 1000 ) {
self::enqueueDeleteJob( $userIds, $event );
- $userIds = array();
+ $userIds = [];
$userIdsCount = 0;
}
}
@@ -129,30 +145,44 @@ class EchoNotificationController {
$job = new EchoNotificationDeleteJob(
$event->getTitle() ?: Title::newMainPage(),
- array(
+ [
'userIds' => $userIds
- )
+ ]
);
JobQueueGroup::singleton()->push( $job );
}
/**
- * @param string $type Event type
- * @return string[] List of notification types to send for
+ * Get the notify types for this event, eg, web/email
+ *
+ * @param string $eventType Event type
+ * @return string[] List of notify types that apply for
* this event type
*/
- public static function getEventNotifyTypes( $type ) {
- // Get the notification types for this event, eg, web/email
- global $wgEchoDefaultNotificationTypes;
+ 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 = $wgEchoDefaultNotificationTypes['all'];
- if ( isset( $wgEchoDefaultNotificationTypes[$type] ) ) {
+ $notifyTypes = $wgDefaultNotifyTypeAvailability;
+
+ if ( $isTypeSpecificConsidered && isset( $wgEchoNotifications[$eventType]['notify-type-availability'] ) ) {
$notifyTypes = array_merge(
$notifyTypes,
- $wgEchoDefaultNotificationTypes[$type]
+ $wgEchoNotifications[$eventType]['notify-type-availability']
);
}
+ // Category settings for availability are considered in EchoNotifier
return array_keys( array_filter( $notifyTypes ) );
}
@@ -164,45 +194,82 @@ class EchoNotificationController {
public static function enqueueEvent( EchoEvent $event ) {
$job = new EchoNotificationJob(
$event->getTitle() ?: Title::newMainPage(),
- array(
+ [
'event' => $event,
'masterPos' => MWEchoDbFactory::newFromDefault()
->getMasterPosition(),
- )
+ ]
);
JobQueueGroup::singleton()->push( $job );
}
-
/**
* Implements blacklist per active wiki expected to be initialized
* from InitializeSettings.php
*
- * @param EchoEvent $event The event to test for exclusion via global blacklist
- * @return boolean True when the event agent is in the global blacklist
+ * @param EchoEvent $event The event to test for exclusion
+ * @param User $user recipient of the notification for per-user blacklists
+ * @return bool True when the event agent is blacklisted
*/
- protected static function isBlacklisted( EchoEvent $event ) {
+ public static function isBlacklistedByUser( EchoEvent $event, User $user ) {
+ global $wgEchoAgentBlacklist, $wgEchoPerUserBlacklist;
+
+ $clusterCache = ObjectCache::getLocalClusterInstance();
+
if ( !$event->getAgent() ) {
return false;
}
- if ( self::$blacklist === null ) {
- global $wgEchoAgentBlacklist, $wgEchoOnWikiBlacklist,
- $wgMemc;
-
- self::$blacklist = new EchoContainmentSet;
- self::$blacklist->addArray( $wgEchoAgentBlacklist );
- if ( $wgEchoOnWikiBlacklist !== null ) {
- self::$blacklist->addOnWiki(
- NS_MEDIAWIKI,
- $wgEchoOnWikiBlacklist,
- $wgMemc,
- wfMemcKey( "echo_on_wiki_blacklist")
- );
+ // Ensure we have a list of blacklists
+ if ( self::$blacklistByUser === null ) {
+ self::$blacklistByUser = new MapCacheLRU( self::$maxRecipientCacheSize );
+ }
+
+ // Ensure we have a blacklist for the user
+ if ( !self::$blacklistByUser->has( $user->getId() ) ) {
+ $blacklist = new EchoContainmentSet( $user );
+
+ // Add the config setting
+ $blacklist->addArray( $wgEchoAgentBlacklist );
+
+ // Add wiki-wide blacklist
+ $wikiBlacklist = self::getWikiBlacklist();
+ if ( $wikiBlacklist !== null ) {
+ $blacklist->add( $wikiBlacklist );
}
+
+ // Add to blacklist from user preference
+ if ( $wgEchoPerUserBlacklist ) {
+ $blacklist->addFromUserOption( 'echo-notifications-blacklist' );
+ }
+
+ // Add user's blacklist to dictionary if user wasn't already there
+ self::$blacklistByUser->set( $user->getId(), $blacklist );
+ } else {
+ // Just get the user's blacklist if it's already there
+ $blacklist = self::$blacklistByUser->get( $user->getId() );
}
+ return $blacklist->contains( $event->getAgent()->getName() );
+ }
- return self::$blacklist->contains( $event->getAgent()->getName() );
+ /**
+ * @return EchoContainmentList|null
+ */
+ protected static function getWikiBlacklist() {
+ $clusterCache = ObjectCache::getLocalClusterInstance();
+ global $wgEchoOnWikiBlacklist;
+ if ( !$wgEchoOnWikiBlacklist ) {
+ return null;
+ }
+ if ( self::$wikiBlacklist === null ) {
+ self::$wikiBlacklist = new EchoCachedList(
+ $clusterCache,
+ $clusterCache->makeKey( "echo_on_wiki_blacklist" ),
+ new EchoOnWikiList( NS_MEDIAWIKI, $wgEchoOnWikiBlacklist )
+ );
+ }
+
+ return self::$wikiBlacklist;
}
/**
@@ -210,11 +277,11 @@ class EchoNotificationController {
*
* @param EchoEvent $event The event to test for inclusion in whitelist
* @param User $user The user that owns the whitelist
- * @return boolean True when the event agent is in the user whitelist
+ * @return bool True when the event agent is in the user whitelist
*/
public static function isWhitelistedByUser( EchoEvent $event, User $user ) {
- global $wgEchoPerUserWhitelistFormat, $wgMemc;
-
+ $clusterCache = ObjectCache::getLocalClusterInstance();
+ global $wgEchoPerUserWhitelistFormat;
if ( $wgEchoPerUserWhitelistFormat === null || !$event->getAgent() ) {
return false;
@@ -225,18 +292,26 @@ class EchoNotificationController {
return false; // anonymous user
}
- if ( !isset( self::$userWhitelist[$userId] ) ) {
- self::$userWhitelist[$userId] = new EchoContainmentSet;
- self::$userWhitelist[$userId]->addOnWiki(
+ // Ensure we have a list of whitelists
+ if ( self::$whitelistByUser === null ) {
+ self::$whitelistByUser = new MapCacheLRU( self::$maxRecipientCacheSize );
+ }
+
+ // Ensure we have a whitelist for the user
+ if ( !self::$whitelistByUser->has( $userId ) ) {
+ $whitelist = new EchoContainmentSet( $user );
+ self::$whitelistByUser->set( $userId, $whitelist );
+ $whitelist->addOnWiki(
NS_USER,
sprintf( $wgEchoPerUserWhitelistFormat, $user->getName() ),
- $wgMemc,
- wfMemcKey( "echo_on_wiki_whitelist_" . $userId )
+ $clusterCache,
+ $clusterCache->makeKey( "echo_on_wiki_whitelist_" . $userId )
);
+ } else {
+ // Just get the user's whitelist
+ $whitelist = self::$whitelistByUser->get( $userId );
}
-
- return self::$userWhitelist[$userId]
- ->contains( $event->getAgent()->getName() );
+ return $whitelist->contains( $event->getAgent()->getName() );
}
/**
@@ -259,34 +334,35 @@ class EchoNotificationController {
throw new MWException( "Cannot notify anonymous user: {$user->getName()}" );
}
- call_user_func_array( $wgEchoNotifiers[$type], array( $user, $event ) );
+ call_user_func_array( $wgEchoNotifiers[$type], [ $user, $event ] );
}
/**
* Returns an array each element of which is the result of a
- * user-locator attached to the event type.
+ * user-locator|user-filters attached to the event type.
*
* @param EchoEvent $event
+ * @param string $locator Either EchoAttributeManager::ATTR_LOCATORS or EchoAttributeManager::ATTR_FILTERS
* @return array
*/
- public static function evaluateUserLocators( EchoEvent $event ) {
+ public static function evaluateUserCallable( EchoEvent $event, $locator = EchoAttributeManager::ATTR_LOCATORS ) {
$attributeManager = EchoAttributeManager::newFromGlobalVars();
$type = $event->getType();
- $result = array();
- foreach ( $attributeManager->getUserLocators( $type ) as $callable ) {
+ $result = [];
+ foreach ( $attributeManager->getUserCallable( $type, $locator ) as $callable ) {
// locator options can be set per-event by using an array with
// name as first parameter.
if ( is_array( $callable ) ) {
$options = $callable;
- $spliced = array_splice( $options, 0, 1, array( $event ) );
+ $spliced = array_splice( $options, 0, 1, [ $event ] );
$callable = reset( $spliced );
} else {
- $options = array( $event );
+ $options = [ $event ];
}
if ( is_callable( $callable ) ) {
$result[] = call_user_func_array( $callable, $options );
} else {
- wfDebugLog( __CLASS__, __FUNCTION__ . ": Invalid user-locator returned for $type" );
+ wfDebugLog( __CLASS__, __FUNCTION__ . ": Invalid $locator returned for $type" );
}
}
@@ -301,31 +377,48 @@ class EchoNotificationController {
*/
public static function getUsersToNotifyForEvent( EchoEvent $event ) {
$notify = new EchoFilteredSequentialIterator;
- foreach ( self::evaluateUserLocators( $event ) as $users ) {
+ foreach ( self::evaluateUserCallable( $event, EchoAttributeManager::ATTR_LOCATORS ) as $users ) {
$notify->add( $users );
}
// Hook for injecting more users.
// @deprecated
- $users = array();
- Hooks::run( 'EchoGetDefaultNotifiedUsers', array( $event, &$users ) );
+ $users = [];
+ Hooks::run( 'EchoGetDefaultNotifiedUsers', [ $event, &$users ] );
if ( $users ) {
$notify->add( $users );
}
+ // Exclude certain users
+ foreach ( self::evaluateUserCallable( $event, EchoAttributeManager::ATTR_FILTERS ) as $users ) {
+ // the result of the callback can be both an iterator or array
+ $users = is_array( $users ) ? $users : iterator_to_array( $users );
+ $notify->addFilter( function ( User $user ) use ( $users ) {
+ // we need to check if $user is in $users, but they're not
+ // guaranteed to be the same object, so I'll compare ids.
+ $userId = $user->getId();
+ $userIds = array_map( function ( User $user ) {
+ return $user->getId();
+ }, $users );
+ return !in_array( $userId, $userIds );
+ } );
+ }
+
// Filter non-User, anon and duplicate users
- $seen = array();
- $notify->addFilter( function( $user ) use( &$seen ) {
+ $seen = [];
+ $notify->addFilter( function ( $user ) use ( &$seen ) {
if ( !$user instanceof User ) {
wfDebugLog( __METHOD__, 'Expected all User instances, received:' .
( is_object( $user ) ? get_class( $user ) : gettype( $user ) )
);
+
return false;
}
if ( $user->isAnon() || isset( $seen[$user->getId()] ) ) {
return false;
}
$seen[$user->getId()] = true;
+
return true;
} );
@@ -333,86 +426,33 @@ class EchoNotificationController {
$extra = $event->getExtra();
if ( ( !isset( $extra['notifyAgent'] ) || !$extra['notifyAgent'] ) && $event->getAgent() ) {
$agentId = $event->getAgent()->getId();
- $notify->addFilter( function( $user ) use( $agentId ) {
+ $notify->addFilter( function ( $user ) use ( $agentId ) {
return $user->getId() != $agentId;
} );
}
- // Apply per-wiki event blacklist and per-user whitelists
- // of that blacklist.
- if ( self::isBlacklisted( $event ) ) {
- $notify->addFilter( function( $user ) use( $event ) {
- // don't use self:: - PHP5.3 closures don't inherit class scope
- return EchoNotificationController::isWhitelistedByUser( $event, $user );
- } );
- }
-
- return $notify->getIterator();
- }
-
- /**
- * Formats a notification
- *
- * @param EchoEvent $event The event for a notification.
- * @param User $user The user to format the notification for.
- * @param string $format The format to show the notification in: text, html, or email
- * @param string $type The type of notification being distributed (e.g. email, web)
- * @return string|array The formatted notification, or an array of subject
- * and body (for emails), or an error message
- */
- public static function formatNotification( EchoEvent $event, User $user, $format = 'text', $type = 'web' ) {
- $eventType = $event->getType();
-
- $res = '';
- try {
- $formatter = EchoNotificationFormatter::factory( $eventType );
- $formatter->setOutputFormat( $format );
- } catch ( InvalidArgumentException $e ) {
- self::failFormatting( $event, $user );
- return '';
- }
- set_error_handler( array( __CLASS__, 'formatterErrorHandler' ), -1 );
- try {
- $res = $formatter->format( $event, $user, $type );
- } catch ( Exception $e ) {
- $context = array(
- 'id' => $event->getId(),
- 'eventType' => $eventType,
- 'format' => $format,
- 'type' => $type,
- 'user' => $user ? $user->getName() : 'no user',
- 'exceptionName' => get_class( $e ),
- 'exceptionMessage' => $e->getMessage(),
- );
- LoggerFactory::getInstance( 'Echo' )->error( 'Error formatting notification', $context );
- MWExceptionHandler::logException( $e );
- }
- restore_error_handler();
-
- if ( $res === '' ) {
- self::failFormatting( $event, $user );
- }
+ // Apply blacklists and whitelists.
+ $notify->addFilter( function ( $user ) use ( $event ) {
+ $title = $event->getTitle();
+
+ if ( self::isBlacklistedByUser( $event, $user ) &&
+ (
+ $title === null ||
+ !(
+ // Still notify for posts anywhere in
+ // user's talk space
+ $title->getRootText() === $user->getName() &&
+ $title->getNamespace() === NS_USER_TALK
+ )
+ )
+ ) {
+ return self::isWhitelistedByUser( $event, $user );
+ }
- return $res;
- }
+ return true;
+ } );
- /**
- * Event has failed to format for the given user. Mark it as read so
- * we do not continue to notify them about this broken event.
- *
- * @param EchoEvent $event
- * @param User $user
- */
- protected static function failFormatting( EchoEvent $event, $user ) {
- // FIXME: The only issue is that the badge count won't be up to date
- // till you refresh the page. Probably we could do this in the browser
- // so that if the formatting is empty and the notif is unread, put it
- // in the auto-mark-read API
- if ( self::$markAsRead === null ) {
- self::$markAsRead = new EchoDeferredMarkAsReadUpdate();
- DeferredUpdates::addUpdate( self::$markAsRead );
- }
- self::$markAsRead->add( $event, $user );
+ return $notify->getIterator();
}
/**
@@ -420,6 +460,12 @@ class EchoNotificationController {
*
* Converts E_RECOVERABLE_ERROR, such as passing null to a method expecting
* a non-null object, into exceptions.
+ * @param int $errno
+ * @param string $errstr
+ * @param string $errfile
+ * @param int $errline
+ * @return bool
+ * @throw EchoCatchableFatalErrorException
*/
public static function formatterErrorHandler( $errno, $errstr, $errfile, $errline ) {
if ( $errno !== E_RECOVERABLE_ERROR ) {
diff --git a/Echo/includes/formatters/ArticleReminderPresentationModel.php b/Echo/includes/formatters/ArticleReminderPresentationModel.php
new file mode 100644
index 00000000..06fa7646
--- /dev/null
+++ b/Echo/includes/formatters/ArticleReminderPresentationModel.php
@@ -0,0 +1,30 @@
+<?php
+/**
+ * Presenter for 'article-reminder' notification
+ *
+ * @author Ela Opper
+ *
+ * @license MIT
+ */
+class EchoArticleReminderPresentationModel extends EchoEventPresentationModel {
+ public function canRender() {
+ return (bool)$this->event->getTitle();
+ }
+
+ public function getIconType() {
+ return 'article-reminder';
+ }
+
+ public function getHeaderMessage() {
+ $msg = $this->getMessageWithAgent( 'notification-header-article-reminder' );
+ $msg->params( $this->getTruncatedTitleText( $this->event->getTitle(), true ) );
+ return $msg;
+ }
+
+ public function getPrimaryLink() {
+ return [
+ 'url' => $this->event->getTitle()->getLocalURL(),
+ 'label' => $this->msg( 'notification-link-article-reminder' )->text(),
+ ];
+ }
+}
diff --git a/Echo/includes/formatters/BasicFormatter.php b/Echo/includes/formatters/BasicFormatter.php
deleted file mode 100644
index 651a92ac..00000000
--- a/Echo/includes/formatters/BasicFormatter.php
+++ /dev/null
@@ -1,925 +0,0 @@
-<?php
-
-/**
- * @Todo - Consider having $event/$user as class properties since the formatter is
- * always tied to these two entities, in this case, we won't have to pass it around
- * in all the internal method
- * @Todo - Instance variable $distributionType has been added, the local distribution
- * type variable $type passed along all the protected/private method should be removed
- * from all formatters
- */
-class EchoBasicFormatter extends EchoNotificationFormatter {
-
- /**
- * Notification title data for archive page
- * @param array
- */
- protected $title;
-
- /**
- * Notification title data for flyout
- * @param array
- */
- protected $flyoutTitle;
-
- /**
- * Notification title data for bundling ( flyout and archive page )
- */
- protected $bundleTitle;
-
- /**
- * Notification email data
- * @param array
- */
- protected $email;
-
- /**
- * Notification icon for each type
- * @param string
- */
- protected $icon;
-
- /**
- * @todo make this private
- * The language to format a message, default language
- * is the current language
- * @param mixed Language code or Language object
- */
- protected $language;
-
- /**
- * Data for constructing bundle message, data in this array
- * should be used in function processParams()
- * @var array
- */
- protected $bundleData = array (
- 'use-bundle' => false,
- 'raw-data-count' => 1
- );
-
- /**
- * Max number of raw bundle data to query for each bundle event
- */
- protected static $maxRawBundleData = 250;
-
- /**
- * @param array
- */
- public function __construct( $params ) {
- parent::__construct( $params );
-
- if ( !isset( $params['title-message'] ) ) {
- // Required, no default value set
- throw new InvalidArgumentException( "'title-message' parameter not set" );
- }
-
- // Set up default params if any are missing
- $params = $this->setDefaultParams( $params );
-
- // Title for archive page
- $this->title = array(
- 'message' => $params['title-message'],
- 'params' => $params['title-params']
- );
-
- // Title for the flyout
- $this->flyoutTitle = array(
- 'message' => $params['flyout-message'],
- 'params' => $params['flyout-params']
- );
-
- // Bundle title for both archive page and flyout
- $this->bundleTitle = array(
- 'message' => $params['bundle-message'],
- 'params' => $params['bundle-params']
- );
-
- // Notification payload data, eg, summary
- $this->payload = $params['payload'];
-
- // Notification email subject and body
- $this->email = array(
- 'subject' => array(
- 'message' => $params['email-subject-message'],
- 'params' => $params['email-subject-params']
- ),
- 'batch-body' => array(
- 'message' => $params['email-body-batch-message'],
- 'params' => $params['email-body-batch-params']
- ),
- 'batch-bundle-body' => array(
- 'message' => $params['email-body-batch-bundle-message'],
- 'params' => $params['email-body-batch-bundle-params']
- )
- );
-
- // Notification icon for the event type
- $this->icon = $params['icon'];
- }
-
- /**
- * Internal function for setting notification default params
- * @param $params array
- * @return array
- */
- protected function setDefaultParams( $params ) {
- $params += array(
- 'title-params' => array(),
- 'bundle-message' => '',
- 'bundle-params' => array(),
- 'payload' => array(),
- 'email-subject-message' => 'echo-email-subject-default',
- 'email-subject-params' => array(),
- 'email-body-batch-message' => 'echo-email-batch-body-default',
- 'email-body-batch-params' => array(),
- 'email-body-batch-bundle-message' => '',
- 'email-body-batch-bundle-params' => array(),
- 'icon' => 'placeholder'
- );
-
- // default flyout-message to title-message if not defined
- $params += array ( 'flyout-message' => $params['title-message'], 'flyout-params' => $params['title-params'] );
-
- return $params;
- }
-
- /**
- * Apply some custom change before formatting, child class overwriting this method
- * should always invoke a call to the parent method unless child class wants to overwrite
- * the default completely
- *
- * @param $event EchoEvent that the notification is for.
- * @param $user User to format the notification for.
- * @param $type string deprecated
- */
- protected function applyChangeBeforeFormatting( EchoEvent $event, User $user, $type ) {
- // Use the bundle message if use-bundle is true and there is a bundle message
- $this->generateBundleData( $event, $user, $type );
- if ( $this->bundleData['use-bundle'] && $this->bundleTitle['message'] ) {
- $this->title = $this->flyoutTitle = $this->bundleTitle;
- }
- }
-
- /**
- * Formats a notification
- *
- * @param $event EchoEvent that the notification is for.
- * @param $user User to format the notification for.
- * @param $type string The type of notification being distributed (e.g. email, web)
- * @return array|string
- */
- public function format( $event, $user, $type ) {
- $this->setDistributionType( $type );
- $this->applyChangeBeforeFormatting( $event, $user, $type );
-
- if ( $this->outputFormat === 'email' ) {
- return $this->formatEmail( $event, $user, $type );
- }
-
- if ( $this->outputFormat === 'text' ) {
- return $this->formatNotificationTitle( $event, $user )->text();
- }
-
- $iconUrl = $this->getIconUrl( $this->icon, $this->getLanguage()->getDir() );
-
- // Assume html as the format for the notification
- $output = Html::element(
- 'img',
- array(
- 'class' => "mw-echo-icon",
- 'src' => $iconUrl,
- )
- );
-
- // Build the notification title
- $content = Xml::tags(
- 'div',
- array( 'class' => 'mw-echo-title' ),
- $this->formatNotificationTitle( $event, $user )->parse()
- ) . "\n";
-
- // Build the notification payload
- $payload = '';
- foreach ( $this->payload as $payloadComponent ) {
- $payload .= $this->formatPayload( $payloadComponent, $event, $user );
- }
-
- if ( $payload !== '' ) {
- $content .= Xml::tags( 'div', array( 'class' => 'mw-echo-payload' ), $payload ) . "\n";
- }
-
- // Add footer (timestamp and secondary link)
- $content .= $this->formatFooter( $event, $user );
-
- // Add the primary link (hidden)
- if ( $this->outputFormat === 'flyout' ) {
- $content .= $this->getLink( $event, $user, 'primary' );
- }
-
- $output .= Xml::tags( 'div', array( 'class' => 'mw-echo-content' ), $content ) . "\n";
-
- // The state div is used to visually indicate read or unread status. This is
- // handled in a separate element than the notification element so that things
- // like the close box won't inherit the greyed out opacity (which can't be reset).
- $output = Xml::tags( 'div', array( 'class' => 'mw-echo-state' ), $output ) . "\n";
-
- return $output;
- }
-
- /**
- * @param $event EchoEvent
- * @param $user User
- * @return string
- */
- protected function formatNotificationTitle( $event, $user ) {
- if ( $this->outputFormat === 'flyout' ) {
- return $this->formatFragment( $this->flyoutTitle, $event, $user );
- } else {
- return $this->formatFragment( $this->title, $event, $user );
- }
- }
-
- /**
- * Create text version and/or html version for email notification
- *
- * @param $event EchoEvent
- * @param $user User
- * @param $type string deprecated
- * @return array
- */
- protected function formatEmail( $event, $user, $type ) {
- // Email should be always sent in user language
- $this->language = $user->getOption( 'language' );
-
- // Email digest
- if ( $this->distributionType === 'emaildigest' ) {
- return $this->formatEmailDigest( $event, $user );
- }
-
- // Echo single email
- $emailSingle = new EchoEmailSingle( $this, $event, $user );
- $textEmailFormatter = new EchoTextEmailFormatter( $emailSingle );
- // Update the distribution type to emailsubject when formatting
- // email subject
- // @FIXME - Find a better way to do this
- $distributionType = $this->distributionType;
- $this->setDistributionType( 'emailsubject' );
- $subject = $this->formatFragment( $this->email['subject'], $event, $user )->text();
- $this->setDistributionType( $distributionType );
-
- $content = array(
- // Single email subject, there is no need to to escape it for either html
- // or text email since it's always treated as plain text by mail client
- 'subject' => $subject,
- // Single email text body
- 'body' => $textEmailFormatter->formatEmail(),
- );
- $format = MWEchoNotifUser::newFromUser( $user )->getEmailFormat();
- if ( $format == EchoHooks::EMAIL_FORMAT_HTML ) {
- $htmlEmailFormatter = new EchoHTMLEmailFormatter( $emailSingle );
- $outputFormat = $this->outputFormat;
- $this->setOutputFormat( 'htmlemail' );
- // Add single email html body if user prefers html format
- $content['body'] = array (
- 'text' => $content['body'],
- 'html' => $htmlEmailFormatter->formatEmail()
- );
- $this->setOutputFormat( $outputFormat );
- }
-
- return $content;
- }
-
- /**
- * Format text and/or html verion of email digest fragment for this event
- * @param $event EchoEvent
- * @param $user User
- * @return array
- */
- protected function formatEmailDigest( $event, $user ) {
- if ( $this->bundleData['use-bundle'] && $this->email['batch-bundle-body'] ) {
- $key = $this->email['batch-bundle-body'];
- } else {
- $key = $this->email['batch-body'];
- }
-
- // Email digest text body
- $content = array( 'batch-body' => $this->formatFragment( $key, $event, $user )->text() );
- $format = MWEchoNotifUser::newFromUser( $user )->getEmailFormat();
- if ( $format == EchoHooks::EMAIL_FORMAT_HTML ) {
- $outputFormat = $this->outputFormat;
- $this->setOutputFormat( 'htmlemail' );
- $content['batch-body-html'] = $this->formatFragment( $key, $event, $user )->parse();
- $content['icon'] = $this->icon;
- $this->setOutputFormat( $outputFormat );
- }
- return $content;
- }
-
- /**
- * Get Message object in the desired language, use this method instead
- * of wfMessage() if a message would be used in either web or email
- * @param $msgStr string message string
- * @return Message
- */
- public function getMessage( $msgStr ) {
- return wfMessage( $msgStr )->inLanguage( $this->getLanguage() );
- }
-
- /**
- * @return Language
- */
- public function getLanguage() {
- global $wgLang;
- // @todo we should always set this
- if ( $this->language ) {
- return wfGetLangObj( $this->language );
- }
-
- return $wgLang;
- }
-
- /**
- * Creates a notification fragment based on a message and parameters
- *
- * @param $details array An i18n message and parameters to pass to the message
- * @param $event EchoEvent that the notification is for.
- * @param $user User to format the notification for.
- * @return Message
- */
- public function formatFragment( $details, $event, $user ) {
- $message = $this->getMessage( $details['message'] );
- $this->processParams( $details['params'], $event, $message, $user );
-
- return $message;
- }
-
- /**
- * Formats the payload of a notification, child method overwriting this method should
- * always call this method in default case so they can use the payload defined in this
- * function as well
- * @param $payload string
- * @param $event EchoEvent
- * @param $user User
- * @return string
- */
- protected function formatPayload( $payload, $event, $user ) {
- switch ( $payload ) {
- case 'summary':
- $revisionSnippet = $this->getRevisionSnippet( $event, $user );
- if ( $revisionSnippet ) {
- return Xml::tags(
- 'div',
- array( 'class' => 'mw-echo-edit-summary' ),
- Xml::tags(
- 'span', array( 'class' => 'comment' ),
- htmlspecialchars( $revisionSnippet )
- )
- );
- } else {
- return '';
- }
- break;
- case 'comment-text':
- return $this->formatCommentText( $event, $user );
- break;
- default:
- return '';
- }
- }
-
- /**
- * Extract the comment left by a user on a talk page from the event.
- * @param $event EchoEvent The event to format the comment of
- * @param $user User The user to format content for
- * @return string Up to the first 200 characters of the comment
- */
- protected function formatCommentText( EchoEvent $event, $user ) {
- if ( !$event->userCan( Revision::DELETED_TEXT, $user ) ) {
- return $this->getMessage( 'echo-rev-deleted-text-view' )->text();
- }
- $extra = $event->getExtra();
- if ( !isset( $extra['content'] ) ) {
- return '';
- }
- $content = EchoDiscussionParser::stripHeader( $extra['content'] );
- $content = EchoDiscussionParser::stripSignature( $content );
- $content = EchoDiscussionParser::stripIndents( $content );
- return EchoDiscussionParser::getTextSnippet( $content, $this->getLanguage(), 200 );
- }
-
- /**
- * Extract the subject anchor (linkable portion of the edited page) from
- * the event.
- *
- * @param $event EchoEvent The event to format the subject anchor of
- * @return string The anchor on page, or an empty string
- */
- protected function formatSubjectAnchor( EchoEvent $event ) {
- global $wgParser, $wgUser;
-
- if ( !$event->userCan( Revision::DELETED_TEXT, $wgUser ) ) {
- return $this->getMessage( 'echo-rev-deleted-text-view' )->text();
- }
- $extra = $event->getExtra();
- if ( empty( $extra['section-title'] ) ) {
- return '';
- }
-
- // Strip out #, keeping # in the i18n message makes it look more clear
- return substr( $wgParser->guessLegacySectionNameFromWikiText( $extra['section-title'] ), 1 );
- }
-
- /**
- * Build the footer for the notification (timestamp and secondary link)
- * @param EchoEvent $event
- * @param User $user The user to format the notification for.
- * @return String HTML
- */
- protected function formatFooter( $event, $user ) {
- // Default footer is timestamp
- $footer = $this->formatTimestamp( $event->getTimestamp() );
- $secondaryLink = $this->getLink( $event, $user, 'secondary' );
- if ( $secondaryLink ) {
- $footer = $this->getLanguage()->pipeList( array( $footer, $secondaryLink ) );
- }
- return Xml::tags( 'div', array( 'class' => 'mw-echo-notification-footer' ), $footer ) . "\n";
- }
-
- /**
- * Generate links based on output format and passed properties
- * $event EchoEvent
- * $message Message
- * $props array
- */
- protected function setTitleLink( $event, $message, $props = array() ) {
- $title = $event->getTitle();
- if ( !$title ) {
- $message->params( $this->getMessage( 'echo-no-title' )->text() );
- return;
- }
-
- if ( !isset( $props['fragment'] ) ) {
- $props['fragment'] = $this->formatSubjectAnchor( $event );
- }
-
- $link = $this->buildLinkParam( $title, $props );
- $message->params( $link );
- }
-
- /**
- * Build a link, to be used as message parameter, based on output format and
- * passed properties. Return value of this function can be used as parameter
- * for Message::params()
- * $title Title
- * $props array
- */
- protected function buildLinkParam( $title, $props = array() ) {
- $param = array();
- if ( isset( $props['param'] ) ) {
- $param = (array)$props['param'];
- }
-
- if ( isset( $props['fragment'] ) ) {
- $fragment = $props['fragment'];
- $title->setFragment( "#$fragment" );
- }
-
- if ( in_array( $this->outputFormat, array( 'html', 'flyout', 'htmlemail' ) ) ) {
- $attribs = array();
- if ( isset( $props['attribs'] ) ) {
- $attribs = (array)$props['attribs'];
- }
-
- if ( isset( $props['linkText'] ) ) {
- $linkText = $props['linkText'];
- } else {
- $linkText = htmlspecialchars( $title->getPrefixedText() );
- }
-
- $options = array();
- if ( $this->outputFormat === 'htmlemail' ) {
- $options = array( 'https' );
- }
-
- return array( Message::rawParam( Linker::link( $title, $linkText, $attribs, $param, $options ) ) );
- } elseif ( $this->outputFormat === 'email' ) {
- $url = $title->getFullURL( $param, false, PROTO_HTTPS );
- return $this->sanitizeEmailLink( $url );
- } else {
- return $title->getFullURL( $param );
- }
- }
-
- /**
- * Plain text email in some mail client is misinterpreting the ending
- * punctuation, this function would encode the last character
- *
- * @param $url string
- *
- * @return string
- */
- public function sanitizeEmailLink( $url ) {
- // $url should contain all ascii characters now, it's safe to use substr()
- $lastChar = substr( $url, -1 );
- if ( $lastChar && !ctype_alnum( $lastChar ) ) {
- $lastChar = str_replace(
- array( '.', '-', '(', ';', '!', ':', ',' ),
- array( '%2E', '%2D', '%28', '%3B', '%21', '%3A', '%2C' ),
- $lastChar
- );
- $url = substr( $url, 0, -1 ) . $lastChar;
- }
- return $url;
- }
-
- /**
- * Get raw bundle data for an event so it can be manipulated
- * @param EchoEvent
- * @param User
- * @param string deprecated
- * @return EchoEvent[]|bool
- */
- protected function getRawBundleData( $event, $user, $type ) {
- // We should keep bundling for events as long as it has bundle hash
- // even for events with bundling switched to off, this is mainly for
- // historical data
- if ( !$event->getBundleHash() ) {
- return false;
- }
-
- $eventMapper = new EchoEventMapper();
- $events = $eventMapper->fetchByUserBundleHash(
- $user, $event->getBundleHash(), $this->distributionType, 'DESC', self::$maxRawBundleData
- );
-
- if ( $events ) {
- $this->bundleData['raw-data-count'] += count( $events );
- // Distribution types other than web include the base event
- // in the result already, decrement it by one
- if ( $this->distributionType !== 'web' ) {
- $this->bundleData['raw-data-count']--;
- }
- }
-
- return $events;
- }
-
- /**
- * Construct the bundle data for an event, by default, the group iterator
- * is agent, eg, by user A and x others. custom formatter can overwrite
- * this function to use a differnt group iterator such as title, namespace
- *
- * @param EchoEvent
- * @param User
- * @param string deprecated
- * @throws MWException
- */
- protected function generateBundleData( $event, $user, $type ) {
- $data = $this->getRawBundleData( $event, $user, $type );
-
- // Default the last raw data to false, which means there is no
- // bundle data other than the base
- $this->bundleData['last-raw-data'] = false;
-
- if ( !$data ) {
- return;
- }
-
- $agents = array();
- $agent = $event->getAgent();
- if ( $agent ) {
- if ( $agent->isAnon() ) {
- $agents[$agent->getName()] = $agent->getName();
- } else {
- $agents[$agent->getId()] = $agent->getId();
- }
- } else {
- throw new MWException( "Agent is required for bundling notification!" );
- }
-
- // Initialize with 1 for the agent of current event
- $count = 1;
- foreach ( $data as $evt ) {
- if ( $evt->getAgent() ) {
- if ( $evt->getAgent()->isAnon() ) {
- $key = $evt->getAgent()->getName();
- } else {
- $key = $evt->getAgent()->getId();
- }
- if ( !isset( $agents[$key] ) ) {
- $agents[$key] = $key;
- $count++;
- }
- }
- $this->bundleData['last-raw-data'] = $evt;
- }
-
- $this->bundleData['agent-other-count'] = $count - 1;
- if ( $count > 1 ) {
- $this->bundleData['use-bundle'] = true;
- }
-
- // If there is more raw data than we requested, that means we have not
- // retrieved the very last raw record, set the key back to null
- if ( count( $data ) >= self::$maxRawBundleData ) {
- $this->bundleData['last-raw-data'] = null;
- }
- }
-
- /**
- * @return array
- */
- public function getBundleData() {
- return $this->bundleData;
- }
-
- /**
- * Convert the parameters into real values and pass them into the message
- *
- * @param $params array
- * @param $event EchoEvent
- * @param $message Message
- * @param $user User
- */
- protected function processParams( $params, $event, $message, $user ) {
- foreach ( $params as $param ) {
- $this->processParam( $event, $param, $message, $user );
- }
- }
-
- /**
- * Process a parameter that should be escaped for display except for use
- * cases like plain text email and email subject
- *
- * @param $message Message
- * @param $paramContent string
- */
- protected function processParamEscaped( $message, $paramContent ) {
- // Plain text email and email subject do not need to be escaped
- if ( $this->outputFormat !== 'email' && $this->distributionType !== 'emailsubject' ) {
- $paramContent = htmlspecialchars( $paramContent );
- }
-
- $message->rawParams( $paramContent );
- }
-
- /**
- * Get the URL for the primary or secondary link for an event
- *
- * @param EchoEvent $event
- * @param User $user The user receiving the notification
- * @param String $rank 'primary' or 'secondary' (default is 'primary')
- * @param boolean $local True to return a local (relative) URL, false to
- * return a full URL (for email for example) (default is true)
- * @param boolean $urlOnly True to return only the URL without the <a> tag,
- * false to return a full anchor link (default is false)
- * @param String $style A style attribute to apply to the anchor, e.g.
- * 'border: 1px solid green; text-decoration: none;' (optional)
- * @return String URL for link, or HTML for anchor tag, or empty string
- */
- public function getLink( $event, $user, $rank = 'primary', $local = true, $urlOnly = false, $style = '' ) {
- $destination = $event->getLinkDestination( $rank );
- if ( !$destination ) {
- return '';
- }
-
- // Get link parameters based on the destination
- list( $target, $query ) = $this->getLinkParams( $event, $user, $destination );
- // Note that $target can be a Title object or a raw url
- if ( !$target ) {
- return '';
- }
- if ( $urlOnly ) {
- if ( is_string( $target ) ) {
- // A raw url was passed back
- return $target;
- }
- if ( $local ) {
- return $target->getLinkURL( $query );
- } else {
- return $target->getFullURL( $query, false, PROTO_HTTPS );
- }
- } else {
- $message = $this->getMessage( $event->getLinkMessage( $rank ) )->text();
- $attribs = array( 'class' => "mw-echo-notification-{$rank}-link" );
- if ( $style ) {
- $attribs['style'] = $style;
- }
- $options = array();
- // If local is false, return an absolute url using HTTP protocol
- if ( !$local ) {
- $options[] = 'https';
- }
- if ( is_string( $target ) ) {
- $attribs['href'] = wfAppendQuery( $target, $query );
- return Html::element( 'a', $attribs, $message );
- } else {
- return Linker::link( $target, $message, $attribs, $query, $options );
- }
-
- }
- }
-
- /**
- * Helper function for getLink()
- *
- * @param EchoEvent $event
- * @param User $user The user receiving the notification
- * @param String $destination The destination type for the link, e.g. 'agent'
- * @return Array including target and query parameters. Note that target can
- * be either a Title or a full url
- */
- protected function getLinkParams( $event, $user, $destination ) {
- $target = null;
- $query = array();
- $title = $event->getTitle();
- // Set up link parameters based on the destination
- switch ( $destination ) {
- case 'agent':
- if ( $event->getAgent() ) {
- $target = $event->getAgent()->getUserPage();
- }
- break;
- case 'title':
- $target = $title;
- break;
- case 'section':
- $target = $title;
- if ( $target ) {
- $fragment = $this->formatSubjectAnchor( $event );
- if ( $fragment ) {
- $target->setFragment( "#$fragment" );
- }
- }
- break;
- case 'diff':
- $eventData = $event->getExtra();
- if ( isset( $eventData['revid'] ) && $title ) {
- $target = $title;
- // Explicitly set fragment to empty string for diff links, $title is
- // passed around by reference, it may end up using fragment set from
- // other parameters
- $target->setFragment( '#' );
- $query = array(
- 'oldid' => 'prev',
- 'diff' => $eventData['revid'],
- );
-
- $data = $this->getBundleLastRawData( $event, $user );
- if ( $data ) {
- $extra = $data->getExtra();
- if ( isset( $extra['revid'] ) ) {
- $oldId = $target->getPreviousRevisionID( $extra['revid'] );
- // The diff engine doesn't provide a way to diff against a null revision.
- // In this case, just fall back old id to the first revision
- if ( !$oldId ) {
- $oldId = $extra['revid'];
- }
- if ( $oldId < $eventData['revid'] ) {
- $query['oldid'] = $oldId;
- }
- }
- }
- }
- break;
- }
- return array( $target, $query );
- }
-
- /**
- * Get the last echo event in a set of bundling data. When bundling notifications,
- * we mostly only need the very first notification, which is the bundle base.
- * In some cases, like talk notification diff, Flow notificaiton first unread post,
- * we need data from the very last notification.
- *
- * @param EchoEvent
- * @param User
- * @return EchoEvent|boolean false for none
- */
- protected function getBundleLastRawData( EchoEvent $event, User $user ) {
- if ( $event->getBundleHash() ) {
- // First try cache data from preivous query
- if ( isset( $this->bundleData['last-raw-data'] ) ) {
- $data = $this->bundleData['last-raw-data'];
- // Then try to query the storage
- } else {
- $eventMapper = new EchoEventMapper();
- $data = $eventMapper->fetchByUserBundleHash(
- $user, $event->getBundleHash(), $this->distributionType, 'ASC', 1
- );
- if ( $data ) {
- $data = reset( $data );
- }
- }
-
- if ( $data ) {
- return $data;
- }
- }
-
- return false;
- }
-
- /**
- * Get the style for standard links in html email
- * @return string
- */
- public function getHTMLLinkStyle() {
- return 'text-decoration: none; color: #3A68B0;';
- }
-
- /**
- * Helper function for processParams()
- *
- * @param $event EchoEvent
- * @param $param string
- * @param $message Message
- * @param $user User
- * @throws MWException
- */
- protected function processParam( $event, $param, $message, $user ) {
- if ( $param === 'agent' ) {
- $agent = $event->getAgent();
- if ( !$agent ) {
- $message->params( $this->getMessage( 'echo-no-agent' )->text() );
- } elseif ( !$event->userCan( Revision::DELETED_USER, $user ) ) {
- $message->params( $this->getMessage( 'rev-deleted-user' )->text() );
- } else {
- if ( $this->outputFormat === 'htmlemail' ) {
- $message->rawParams(
- Linker::link(
- $agent->getUserPage(),
- $agent->getName(),
- array( 'style' => $this->getHTMLLinkStyle() ),
- array(),
- array( 'https' )
- )
- );
- } else {
- $message->params( $agent->getName() );
- }
- }
- // example: {7} others, {99+} others
- } elseif ( $param === 'agent-other-display' ) {
- global $wgEchoMaxNotificationCount;
-
- if ( $this->bundleData['agent-other-count'] > $wgEchoMaxNotificationCount ) {
- $message->params(
- $this->getMessage( 'echo-notification-count' )
- ->numParams( $wgEchoMaxNotificationCount )
- ->text()
- );
- } else {
- $message->numParams( $this->bundleData['agent-other-count'] );
- }
- // the number used for plural support
- } elseif ( $param === 'agent-other-count' ) {
- $message->params( $this->bundleData['agent-other-count'] );
- } elseif ( $param === 'user' ) {
- $message->params( $user->getName() );
- } elseif ( $param === 'title' ) {
- $title = $event->getTitle();
- if ( !$title ) {
- $message->params( $this->getMessage( 'echo-no-title' )->text() );
- } else {
- if ( $this->outputFormat === 'htmlemail' ) {
- $props = array (
- 'attribs' => array( 'style' => $this->getHTMLLinkStyle() )
- );
- $this->setTitleLink( $event, $message, $props );
- } else {
- $message->params( $this->formatTitle( $title ) );
- }
- }
- } elseif ( $param === 'titlelink' ) {
- $this->setTitleLink( $event, $message );
- } elseif ( $param === 'text-notification' ) {
- $oldOutputFormat = $this->outputFormat;
- $this->setOutputFormat( 'text' );
- // $type is ignored in this class
- $textNotification = $this->format( $event, $user, '' );
- $this->setOutputFormat( $oldOutputFormat );
-
- $message->params( $textNotification );
- } else {
- throw new MWException( "Unrecognised parameter $param" );
- }
- }
-
- /**
- * Getter method
- *
- * @param $key string
- *
- * @throws MWException
- * @return mixed
- */
- public function getValue( $key ) {
- if ( !property_exists( $this, $key ) ) {
- throw new MWException( "Call to non-existing property $key in " . get_class( $this ) );
- }
- return $this->$key;
- }
-
-}
diff --git a/Echo/includes/formatters/CommentFormatter.php b/Echo/includes/formatters/CommentFormatter.php
deleted file mode 100644
index c789d524..00000000
--- a/Echo/includes/formatters/CommentFormatter.php
+++ /dev/null
@@ -1,34 +0,0 @@
-<?php
-
-class EchoCommentFormatter extends EchoEditFormatter {
- public function __construct( $params ) {
- parent::__construct( $params );
- }
-
- /**
- * @param EchoEvent $event
- * @param $param
- * @param Message $message
- * @param User $user
- */
- protected function processParam( $event, $param, $message, $user ) {
- if ( $param === 'content-page' ) {
- if ( $event->getTitle() ) {
- $message->params( $event->getTitle()->getSubjectPage()->getPrefixedText() );
- } else {
- $message->params( '' );
- }
- } elseif ( $param === 'subject-link' ) {
- $this->setTitleLink( $event, $message );
- // The title text without namespace
- } elseif ( $param === 'main-title-text' ) {
- if ( !$event->getTitle() ) {
- $message->params( $this->getMessage( 'echo-no-title' )->text() );
- } else {
- $message->params( $event->getTitle()->getText() );
- }
- } else {
- parent::processParam( $event, $param, $message, $user );
- }
- }
-}
diff --git a/Echo/includes/formatters/EchoEventDigestFormatter.php b/Echo/includes/formatters/EchoEventDigestFormatter.php
new file mode 100644
index 00000000..1fb1f765
--- /dev/null
+++ b/Echo/includes/formatters/EchoEventDigestFormatter.php
@@ -0,0 +1,53 @@
+<?php
+
+/**
+ * Abstract class for formatters that process multiple events.
+ *
+ * The formatter does not maintain any state except for the
+ * arguments passed in the constructor (user and language)
+ */
+abstract class EchoEventDigestFormatter {
+ public function __construct( User $user, Language $language ) {
+ $this->user = $user;
+ $this->language = $language;
+ }
+
+ /**
+ * Equivalent to IContextSource::msg for the current
+ * language
+ *
+ * @return Message
+ */
+ protected function msg( /* ,,, */ ) {
+ /**
+ * @var Message $msg
+ */
+ $msg = call_user_func_array( 'wfMessage', func_get_args() );
+ $msg->inLanguage( $this->language );
+
+ return $msg;
+ }
+
+ /**
+ * @param EchoEvent[] $events
+ * @param string $distributionType 'web' or 'email'
+ * @return array|bool|string Output format depends on implementation, false if it cannot be formatted
+ */
+ final public function format( array $events, $distributionType ) {
+ $models = [];
+ foreach ( $events as $event ) {
+ $model = EchoEventPresentationModel::factory( $event, $this->language, $this->user, $distributionType );
+ if ( $model->canRender() ) {
+ $models[] = $model;
+ }
+ }
+
+ return $models ? $this->formatModels( $models ) : false;
+ }
+
+ /**
+ * @param EchoEventPresentationModel[] $models
+ * @return string|array
+ */
+ abstract protected function formatModels( array $models );
+}
diff --git a/Echo/includes/formatters/EchoEventFormatter.php b/Echo/includes/formatters/EchoEventFormatter.php
new file mode 100644
index 00000000..c3d30413
--- /dev/null
+++ b/Echo/includes/formatters/EchoEventFormatter.php
@@ -0,0 +1,71 @@
+<?php
+use MediaWiki\Logger\LoggerFactory;
+
+/**
+ * Abstract class that each "formatter" should implement.
+ *
+ * A formatter is an output type, example formatters would be:
+ * * Special:Notifications
+ * * HTML email
+ * * plaintext email
+ *
+ * The formatter does not maintain any state except for the
+ * arguments passed in the constructor (user and language)
+ */
+abstract class EchoEventFormatter {
+ public function __construct( User $user, Language $language ) {
+ $this->user = $user;
+ $this->language = $language;
+ }
+
+ /**
+ * Equivalent to IContextSource::msg for the current
+ * language
+ *
+ * @return Message
+ */
+ protected function msg( /* ,,, */ ) {
+ /**
+ * @var Message $msg
+ */
+ $msg = call_user_func_array( 'wfMessage', func_get_args() );
+ $msg->inLanguage( $this->language );
+
+ return $msg;
+ }
+
+ /**
+ * @param EchoEvent $event
+ * @return string|array|bool Output format depends on implementation, false if it cannot be formatted
+ */
+ final public function format( EchoEvent $event ) {
+ // Deleted events should have been filtered out before getting there.
+ // This is just to be sure.
+ if ( $event->isDeleted() ) {
+ return false;
+ }
+
+ if ( !EchoEventPresentationModel::supportsPresentationModel( $event->getType() ) ) {
+ LoggerFactory::getInstance( 'Echo' )->warning(
+ "Ignoring event type \"{type}\" since it does not support Echo presentation model.",
+ [
+ 'type' => $event->getType(),
+ ]
+ );
+ return false;
+ }
+
+ $model = EchoEventPresentationModel::factory( $event, $this->language, $this->user );
+ if ( !$model->canRender() ) {
+ return false;
+ }
+
+ return $this->formatModel( $model );
+ }
+
+ /**
+ * @param EchoEventPresentationModel $model
+ * @return string|array
+ */
+ abstract protected function formatModel( EchoEventPresentationModel $model );
+}
diff --git a/Echo/includes/formatters/EchoFlyoutFormatter.php b/Echo/includes/formatters/EchoFlyoutFormatter.php
new file mode 100644
index 00000000..f0235089
--- /dev/null
+++ b/Echo/includes/formatters/EchoFlyoutFormatter.php
@@ -0,0 +1,78 @@
+<?php
+
+/**
+ * A formatter for the notification flyout popup
+ *
+ * Ideally we wouldn't need this and we'd just pass the
+ * presentation model to the client, but we need to continue
+ * sending HTML for backwards compatibility.
+ */
+class EchoFlyoutFormatter extends EchoEventFormatter {
+ protected function formatModel( EchoEventPresentationModel $model ) {
+ $icon = Html::element(
+ 'img',
+ [
+ 'class' => 'mw-echo-icon',
+ 'src' => $this->getIconURL( $model ),
+ ]
+ );
+
+ $html = Xml::tags(
+ 'div',
+ [ 'class' => 'mw-echo-title' ],
+ $model->getHeaderMessage()->parse()
+ ) . "\n";
+
+ $body = $model->getBodyMessage();
+ if ( $body ) {
+ $html .= Xml::tags(
+ 'div',
+ [ 'class' => 'mw-echo-payload' ],
+ $body->parse()
+ ) . "\n";
+ }
+
+ $ts = $this->language->getHumanTimestamp(
+ new MWTimestamp( $model->getTimestamp() ),
+ null,
+ $this->user
+ );
+
+ $footerItems = [ $ts ];
+ $secondaryLinks = array_filter( $model->getSecondaryLinks() );
+ foreach ( $secondaryLinks as $link ) {
+ $footerItems[] = Html::element( 'a', [ 'href' => $link['url'] ], $link['label'] );
+ }
+ $html .= Xml::tags(
+ 'div',
+ [ 'class' => 'mw-echo-notification-footer' ],
+ $this->language->pipeList( $footerItems )
+ ) . "\n";
+
+ // Add the primary link afterwards, if it has one
+ $primaryLink = $model->getPrimaryLinkWithMarkAsRead();
+ if ( $primaryLink !== false ) {
+ $html .= Html::element(
+ 'a',
+ [ 'class' => 'mw-echo-notification-primary-link', 'href' => $primaryLink['url'] ],
+ $primaryLink['label']
+ ) . "\n";
+ }
+
+ // Wrap everything in mw-echo-content class
+ $html = Xml::tags( 'div', [ 'class' => 'mw-echo-content' ], $html );
+
+ // And then add the icon in front and wrap with mw-echo-state class.
+ $html = Xml::tags( 'div', [ 'class' => 'mw-echo-state' ], $icon . $html );
+
+ return $html;
+ }
+
+ private function getIconURL( EchoEventPresentationModel $model ) {
+ return EchoIcon::getUrl(
+ $model->getIconType(),
+ $this->language->getDir()
+ );
+ }
+
+}
diff --git a/Echo/includes/formatters/EchoForeignPresentationModel.php b/Echo/includes/formatters/EchoForeignPresentationModel.php
new file mode 100644
index 00000000..0d040dfc
--- /dev/null
+++ b/Echo/includes/formatters/EchoForeignPresentationModel.php
@@ -0,0 +1,49 @@
+<?php
+
+class EchoForeignPresentationModel extends EchoEventPresentationModel {
+ public function getIconType() {
+ return 'global';
+ }
+
+ public function getPrimaryLink() {
+ return false;
+ }
+
+ protected function getHeaderMessageKey() {
+ $data = $this->event->getExtra();
+ $section = $data['section'] == 'message' ? 'notice' : $data['section'];
+
+ // notification-header-foreign-alert
+ // notification-header-foreign-notice
+ return "notification-header-{$this->type}-{$section}";
+ }
+
+ public function getHeaderMessage() {
+ $msg = parent::getHeaderMessage();
+
+ $data = $this->event->getExtra();
+ $firstWiki = reset( $data['wikis'] );
+ $names = $this->getWikiNames( [ $firstWiki ] );
+ $msg->params( $names[0] );
+ $msg->numParams( count( $data['wikis'] ) - 1 );
+ $msg->numParams( count( $data['wikis'] ) );
+
+ return $msg;
+ }
+
+ public function getBodyMessage() {
+ $data = $this->event->getExtra();
+ $msg = wfMessage( "notification-body-{$this->type}" );
+ $msg->params( $this->language->listToText( $this->getWikiNames( $data['wikis'] ) ) );
+ return $msg;
+ }
+
+ protected function getWikiNames( array $wikis ) {
+ $data = EchoForeignNotifications::getApiEndpoints( $wikis );
+ $names = [];
+ foreach ( $wikis as $wiki ) {
+ $names[] = $data[$wiki]['title'];
+ }
+ return $names;
+ }
+}
diff --git a/Echo/includes/formatters/EchoHtmlDigestEmailFormatter.php b/Echo/includes/formatters/EchoHtmlDigestEmailFormatter.php
new file mode 100644
index 00000000..f2f96cc3
--- /dev/null
+++ b/Echo/includes/formatters/EchoHtmlDigestEmailFormatter.php
@@ -0,0 +1,226 @@
+<?php
+
+class EchoHtmlDigestEmailFormatter extends EchoEventDigestFormatter {
+
+ /**
+ * @var string 'daily' or 'weekly'
+ */
+ protected $digestMode;
+
+ public function __construct( User $user, Language $language, $digestMode ) {
+ parent::__construct( $user, $language );
+ $this->digestMode = $digestMode;
+ }
+
+ /**
+ * @param EchoEventPresentationModel[] $models
+ * @return array of the following format:
+ * [ 'body' => formatted email body,
+ * 'subject' => formatted email subject ]
+ */
+ protected function formatModels( array $models ) {
+ // echo-email-batch-body-intro-daily
+ // echo-email-batch-body-intro-weekly
+ $intro = $this->msg( 'echo-email-batch-body-intro-' . $this->digestMode )
+ ->params( $this->user->getName() )
+ ->parse();
+ $intro = nl2br( $intro );
+
+ $eventsByCategory = $this->groupByCategory( $models );
+ ksort( $eventsByCategory );
+ $digestList = $this->renderDigestList( $eventsByCategory );
+
+ $htmlFormatter = new EchoHtmlEmailFormatter( $this->user, $this->language );
+
+ $body = $this->renderBody(
+ $this->language,
+ $intro,
+ $digestList,
+ $this->renderAction(),
+ $htmlFormatter->getFooter()
+ );
+
+ // echo-email-batch-subject-daily
+ // echo-email-batch-subject-weekly
+ $subject = $this->msg( 'echo-email-batch-subject-' . $this->digestMode )
+ ->numParams( count( $models ), count( $models ) )
+ ->text();
+
+ return [
+ 'subject' => $subject,
+ 'body' => $body,
+ ];
+ }
+
+ private function renderBody( Language $language, $intro, $digestList, $action, $footer ) {
+ $alignStart = $language->alignStart();
+ $langCode = $language->getCode();
+ $langDir = $language->getDir();
+
+ return <<< EOF
+<html><head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <style>
+ @media only screen and (max-width: 480px){
+ table[id="email-container"]{max-width:600px !important; width:100% !important;}
+ }
+ </style>
+</head><body>
+<table cellspacing="0" cellpadding="0" border="0" width="100%" align="center" lang="$langCode" dir="$langDir">
+<tr>
+ <td bgcolor="#EAECF0"><center>
+ <br /><br />
+ <table cellspacing="0" cellpadding="0" border="0" width="600" id="email-container">
+ <tr>
+ <td bgcolor="#FFFFFF" width="5%">&nbsp;</td>
+ <td bgcolor="#FFFFFF" width="6%">&nbsp;</td>
+ <td bgcolor="#FFFFFF" width="79%" style="line-height:40px;">&nbsp;</td>
+ <td bgcolor="#FFFFFF" width="10%">&nbsp;</td>
+ </tr>
+ <tr>
+ <td bgcolor="#FFFFFF" rowspan="2">&nbsp;</td>
+ <td bgcolor="#FFFFFF" rowspan="2">&nbsp;</td>
+ <td bgcolor="#FFFFFF" align="center" style="font-family: Arial, Helvetica, sans-serif; font-size:13px; line-height:20px; color:#72777D; text-align: center;">$intro</td>
+ <td bgcolor="#FFFFFF" rowspan="2">&nbsp;</td>
+ </tr>
+ <tr>
+ <td bgcolor="#FFFFFF" align="$alignStart" style="font-family: Arial, Helvetica, sans-serif; line-height: 20px; font-weight: 600;">
+ <table cellspacing="0" cellpadding="0" border="0" width="100%">
+ <tr>
+ <td bgcolor="#FFFFFF" align="$alignStart" style="font-family: Arial, Helvetica, sans-serif; font-size:13px; color: #54595D; padding-top: 25px;">
+ $digestList
+ </td>
+ </tr>
+ </table>
+ <br /><br />
+ </td>
+ </tr>
+ <tr>
+ <td bgcolor="#FFFFFF">&nbsp;</td>
+ <td bgcolor="#FFFFFF">&nbsp;</td>
+ <td bgcolor="#FFFFFF" style="line-height:60px;" align="center">$action</td>
+ <td bgcolor="#FFFFFF">&nbsp;</td>
+ </tr>
+ <tr>
+ <td bgcolor="#FFFFFF">&nbsp;</td>
+ <td bgcolor="#FFFFFF">&nbsp;</td>
+ <td bgcolor="#FFFFFF" style="line-height:40px;">&nbsp;</td>
+ <td bgcolor="#FFFFFF">&nbsp;</td>
+ </tr>
+ <tr>
+ <td>&nbsp;</td>
+ <td>&nbsp;</td>
+ <td align="$alignStart" style="font-family: Arial, Helvetica, sans-serif; font-size:10px; line-height:13px; color:#72777D; padding: 10px 20px;"><br />
+ $footer
+ <br /><br />
+ </td>
+ <td>&nbsp;</td>
+ </tr>
+ <tr>
+ <td colspan="4">&nbsp;</td>
+ </tr>
+ </table>
+ <br><br></center>
+ </td>
+</tr>
+</table>
+</body></html>
+EOF;
+ }
+
+ /**
+ * @param string $type Notification type
+ * @param int $count Number of notifications in this type's section
+ * @return string Formatted category section title
+ */
+ private function getCategoryTitle( $type, $count ) {
+ return $this->msg( "echo-category-title-$type" )
+ ->numParams( $count )
+ ->parse();
+ }
+
+ /**
+ * @param EchoEventPresentationModel[] $models
+ * @return array [ 'category name' => EchoEventPresentationModel[] ]
+ */
+ private function groupByCategory( $models ) {
+ $eventsByCategory = [];
+ foreach ( $models as $model ) {
+ $eventsByCategory[$model->getCategory()][] = $model;
+ }
+ return $eventsByCategory;
+ }
+
+ /**
+ * Apply style to notification category header
+ * @param string $category Can contain HTML. Is included as-is in HTML template, is not escaped.
+ * @return string
+ */
+ protected function applyStyleToCategory( $category ) {
+ return <<< EOF
+<tr>
+ <td colspan="2" style="color: #72777D; font-weight: normal; font-size: 13px; padding-top: 15px;">
+ $category <br />
+ <hr style="background-color:#FFFFFF; color:#FFFFFF; border: 1px solid #EAECF0;" />
+ </td>
+</tr>
+EOF;
+ }
+
+ /**
+ * Apply style to individual notification event
+ * @param EchoEventPresentationModel $model
+ * @return string
+ */
+ protected function applyStyleToEvent( EchoEventPresentationModel $model ) {
+ $iconUrl = wfExpandUrl(
+ EchoIcon::getRasterizedUrl( $model->getIconType(), $this->language->getCode() ),
+ PROTO_CANONICAL
+ );
+
+ $imgSrc = Sanitizer::encodeAttribute( $iconUrl );
+
+ // notification text
+ $text = $model->getHeaderMessage()->parse();
+
+ return <<< EOF
+<tr>
+ <td width="30">
+ <img src="$imgSrc" width="30" height="30" style="vertical-align:middle;">
+ </td>
+ <td style="font-family: Arial, Helvetica, sans-serif; font-size:13px; color: #54595D;">
+ $text
+ </td>
+</tr>
+EOF;
+ }
+
+ private function renderDigestList( $eventsByCategory ) {
+ $result = [];
+ // build the html section for each category
+ foreach ( $eventsByCategory as $category => $models ) {
+ $output = $this->applyStyleToCategory(
+ $this->getCategoryTitle( $category, count( $models ) )
+ );
+ foreach ( $models as $model ) {
+ $output .= "\n" . $this->applyStyleToEvent( $model );
+ }
+ $result[] = '<table border="0" width="100%">' . $output . '</table>';
+ }
+
+ return trim( implode( "\n", $result ) );
+ }
+
+ private function renderAction() {
+ return Html::element(
+ 'a',
+ [
+ 'href' => SpecialPage::getTitleFor( 'Notifications' )->getFullURL( '', false, PROTO_CANONICAL ),
+ 'style' => EchoHtmlEmailFormatter::PRIMARY_LINK_STYLE,
+ ],
+ $this->msg( 'echo-email-batch-link-text-view-all-notifications' )->text()
+ );
+ }
+
+}
diff --git a/Echo/includes/formatters/EchoHtmlEmailFormatter.php b/Echo/includes/formatters/EchoHtmlEmailFormatter.php
new file mode 100644
index 00000000..4c28fe65
--- /dev/null
+++ b/Echo/includes/formatters/EchoHtmlEmailFormatter.php
@@ -0,0 +1,159 @@
+<?php
+
+class EchoHtmlEmailFormatter extends EchoEventFormatter {
+
+ const PRIMARY_LINK_STYLE = 'cursor:pointer; text-align:center; text-decoration:none; padding:.45em 0.6em .45em; color:#FFF; background:#36C; font-family: Arial, Helvetica, sans-serif;font-size: 13px;';
+ const SECONDARY_LINK_STYLE = 'text-decoration: none;font-size: 10px;font-family: Arial, Helvetica, sans-serif; color: #72777D;';
+
+ protected function formatModel( EchoEventPresentationModel $model ) {
+ $subject = $model->getSubjectMessage()->parse();
+
+ $intro = $model->getHeaderMessage()->parse();
+
+ $bodyMsg = $model->getBodyMessage();
+ $summary = $bodyMsg ? $bodyMsg->parse() : '';
+
+ $actions = [];
+
+ $primaryLink = $model->getPrimaryLinkWithMarkAsRead();
+ if ( $primaryLink ) {
+ $actions[] = $this->renderLink( $primaryLink, self::PRIMARY_LINK_STYLE );
+ }
+
+ foreach ( array_filter( $model->getSecondaryLinks() ) as $secondaryLink ) {
+ $actions[] = $this->renderLink( $secondaryLink, self::SECONDARY_LINK_STYLE );
+ }
+
+ $iconUrl = wfExpandUrl(
+ EchoIcon::getRasterizedUrl( $model->getIconType(), $this->language->getCode() ),
+ PROTO_CANONICAL
+ );
+
+ $body = $this->renderBody(
+ $this->language,
+ $iconUrl,
+ $summary,
+ implode( "&nbsp;&nbsp;", $actions ),
+ $intro,
+ $this->getFooter()
+ );
+
+ return [
+ 'body' => $body,
+ 'subject' => $subject,
+ ];
+ }
+
+ private function renderBody( Language $lang, $emailIcon, $summary, $action, $intro, $footer ) {
+ $alignStart = $lang->alignStart();
+ $langCode = $lang->getCode();
+ $langDir = $lang->getDir();
+
+ $iconImgSrc = Sanitizer::encodeAttribute( $emailIcon );
+
+ global $wgCanonicalServer;
+ return <<< EOF
+<html><head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <style>
+ @media only screen and (max-width: 480px){
+ table[id="email-container"]{max-width:600px !important; width:100% !important;}
+ }
+ </style>
+ <base href="{$wgCanonicalServer}">
+</head><body>
+<table cellspacing="0" cellpadding="0" border="0" width="100%" align="center" lang="{$langCode}" dir="{$langDir}">
+<tr>
+ <td bgcolor="#EAECF0"><center>
+ <br /><br />
+ <table cellspacing="0" cellpadding="0" border="0" width="600" id="email-container">
+ <tr>
+ <td bgcolor="#FFFFFF" width="5%">&nbsp;</td>
+ <td bgcolor="#FFFFFF" width="10%">&nbsp;</td>
+ <td bgcolor="#FFFFFF" width="80%" style="line-height:40px;">&nbsp;</td>
+ <td bgcolor="#FFFFFF" width="5%">&nbsp;</td>
+ </tr><tr>
+ <td bgcolor="#FFFFFF" rowspan="2">&nbsp;</td>
+ <td bgcolor="#FFFFFF" align="center" valign="top" rowspan="2"><img src="{$iconImgSrc}" alt="" height="30" width="30"></td>
+ <td bgcolor="#FFFFFF" align="{$alignStart}" style="font-family: Arial, Helvetica, sans-serif; font-size:13px; line-height:20px; color:#72777D;">{$intro}</td>
+ <td bgcolor="#FFFFFF" rowspan="2">&nbsp;</td>
+ </tr><tr>
+ <td bgcolor="#FFFFFF" align="{$alignStart}" style="font-family: Arial, Helvetica, sans-serif; line-height: 20px; font-weight: 600;">
+ <table cellspacing="0" cellpadding="0" border="0">
+ <tr>
+ <td bgcolor="#FFFFFF" align="{$alignStart}" style="font-family: Arial, Helvetica, sans-serif; padding-top: 8px; font-size:13px; font-weight: bold; color: #54595D;">
+ {$summary}
+ </td>
+ </tr>
+ </table>
+ <table cellspacing="0" cellpadding="0" border="0">
+ <tr>
+ <td bgcolor="#FFFFFF" align="{$alignStart}" style="font-family: Arial, Helvetica, sans-serif; font-size:14px; padding-top: 25px;">
+ {$action}
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr><tr>
+ <td bgcolor="#FFFFFF">&nbsp;</td>
+ <td bgcolor="#FFFFFF">&nbsp;</td>
+ <td bgcolor="#FFFFFF" style="line-height:40px;">&nbsp;</td>
+ <td bgcolor="#FFFFFF">&nbsp;</td>
+ </tr><tr>
+ <td>&nbsp;</td>
+ <td>&nbsp;</td>
+ <td align="{$alignStart}" style="font-family: Arial, Helvetica, sans-serif; font-size:10px; line-height:13px; color:#72777D; padding:10px 20px;"><br />
+ {$footer}
+ <br /><br />
+ </td>
+ <td>&nbsp;</td>
+ </tr><tr>
+ <td colspan="4">&nbsp;</td>
+ </tr>
+ </table>
+ <br><br></center>
+ </td>
+</tr>
+</table>
+</body></html>
+EOF;
+ }
+
+ /**
+ * @return string
+ */
+ public function getFooter() {
+ global $wgEchoEmailFooterAddress;
+
+ $preferenceLink = $this->renderLink(
+ [
+ 'label' => $this->msg( 'echo-email-html-footer-preference-link-text', $this->user )->text(),
+ 'url' => SpecialPage::getTitleFor( 'Preferences', false, 'mw-prefsection-echo' )->getFullURL( '', false, PROTO_CANONICAL ),
+ ],
+ 'text-decoration: none; color: #36C;'
+ );
+
+ $footer = $this->msg( 'echo-email-html-footer-with-link' )
+ ->rawParams( $preferenceLink )
+ ->params( $this->user )
+ ->parse();
+
+ if ( $wgEchoEmailFooterAddress ) {
+ $footer .= '<br />' . $wgEchoEmailFooterAddress;
+ }
+
+ return $footer;
+ }
+
+ private function renderLink( $link, $style ) {
+ return Html::element(
+ 'a',
+ [
+ 'href' => wfExpandUrl( $link['url'], PROTO_CANONICAL ),
+ 'style' => $style,
+ ],
+ $link['label']
+ );
+ }
+}
diff --git a/Echo/includes/formatters/EchoIcon.php b/Echo/includes/formatters/EchoIcon.php
new file mode 100644
index 00000000..74b90f6a
--- /dev/null
+++ b/Echo/includes/formatters/EchoIcon.php
@@ -0,0 +1,88 @@
+<?php
+
+class EchoIcon {
+
+ /**
+ * @param string $icon Name of icon as registered in BeforeCreateEchoEvent hook
+ * @param string $dir either 'ltr' or 'rtl'
+ * @return string
+ */
+ public static function getUrl( $icon, $dir ) {
+ global $wgEchoNotificationIcons, $wgExtensionAssetsPath;
+ if ( !isset( $wgEchoNotificationIcons[$icon] ) ) {
+ throw new InvalidArgumentException( "The $icon icon is not registered" );
+ }
+
+ $iconInfo = $wgEchoNotificationIcons[$icon];
+ $needsPrefixing = true;
+
+ // Now we need to check it has a valid url/path
+ if ( isset( $iconInfo['url'] ) && $iconInfo['url'] ) {
+ $iconUrl = $iconInfo['url'];
+ $needsPrefixing = false;
+ } elseif ( isset( $iconInfo['path'] ) && $iconInfo['path'] ) {
+ $iconUrl = $iconInfo['path'];
+ } else {
+ // Fallback to hardcoded 'placeholder'. This is used if someone
+ // doesn't configure the 'site' icon for example.
+ $icon = 'placeholder';
+ $iconUrl = $wgEchoNotificationIcons['placeholder']['path'];
+ }
+
+ // Might be an array with different icons for ltr/rtl
+ if ( is_array( $iconUrl ) ) {
+ if ( !isset( $iconUrl[$dir] ) ) {
+ throw new UnexpectedValueException( "Icon type $icon doesn't have an icon for $dir directionality" );
+ }
+
+ $iconUrl = $iconUrl[$dir];
+ }
+
+ // And if it was a 'path', stick the assets path in front
+ if ( $needsPrefixing ) {
+ $iconUrl = "$wgExtensionAssetsPath/$iconUrl";
+ }
+
+ return $iconUrl;
+ }
+
+ /**
+ * Get a link to a rasterized version of the icon
+ *
+ * @param string $icon Icon name
+ * @param string $lang Language
+ * @return string URL to the rasterized version of the icon
+ */
+ public static function getRasterizedUrl( $icon, $lang ) {
+ global $wgEchoNotificationIcons;
+ if ( !isset( $wgEchoNotificationIcons[$icon] ) ) {
+ throw new InvalidArgumentException( "The $icon icon is not registered" );
+ }
+
+ $url = isset( $wgEchoNotificationIcons[ $icon ][ 'url' ] ) ?
+ $wgEchoNotificationIcons[ $icon ][ 'url' ] :
+ null;
+
+ // If the defined URL is explicitly false, use placeholder
+ if ( $url === false ) {
+ $icon = 'placeholder';
+ }
+
+ // If the URL is null or false call the resource loader
+ // rasterizing module
+ if ( $url === false || $url === null ) {
+ $iconUrl = wfScript( 'load' ) . '?' . wfArrayToCgi( [
+ 'modules' => 'ext.echo.emailicons',
+ 'image' => $icon,
+ 'lang' => $lang,
+ 'format' => 'rasterized'
+ ] );
+ } else {
+ // For icons that are defined by URL
+ $iconUrl = $wgEchoNotificationIcons[ $icon ][ 'url' ];
+ }
+
+ return $iconUrl;
+ }
+
+}
diff --git a/Echo/includes/formatters/EchoModelFormatter.php b/Echo/includes/formatters/EchoModelFormatter.php
new file mode 100644
index 00000000..4d4050b4
--- /dev/null
+++ b/Echo/includes/formatters/EchoModelFormatter.php
@@ -0,0 +1,31 @@
+<?php
+
+/**
+ * A formatter for the notification flyout popup. Just the bare data needed to
+ * render everything client-side.
+ */
+class EchoModelFormatter extends EchoEventFormatter {
+ /**
+ * @param EchoEventPresentationModel $model
+ * @return array
+ */
+ protected function formatModel( EchoEventPresentationModel $model ) {
+ $data = $model->jsonSerialize();
+ $data['iconUrl'] = EchoIcon::getUrl( $model->getIconType(), $this->language->getDir() );
+
+ if ( isset( $data['links']['primary']['url'] ) ) {
+ $data['links']['primary']['url'] = wfExpandUrl( $data['links']['primary']['url'] );
+ }
+
+ foreach ( $data['links']['secondary'] as &$link ) {
+ $link['url'] = wfExpandUrl( $link['url'] );
+ }
+
+ $bundledIds = $model->getBundledIds();
+ if ( $bundledIds ) {
+ $data[ 'bundledIds' ] = $bundledIds;
+ }
+
+ return $data;
+ }
+}
diff --git a/Echo/includes/formatters/EchoPlainTextDigestEmailFormatter.php b/Echo/includes/formatters/EchoPlainTextDigestEmailFormatter.php
new file mode 100644
index 00000000..88d9c5f5
--- /dev/null
+++ b/Echo/includes/formatters/EchoPlainTextDigestEmailFormatter.php
@@ -0,0 +1,78 @@
+<?php
+
+class EchoPlainTextDigestEmailFormatter extends EchoEventDigestFormatter {
+
+ /**
+ * @var string 'daily' or 'weekly'
+ */
+ protected $digestMode;
+
+ public function __construct( User $user, Language $language, $digestMode ) {
+ parent::__construct( $user, $language );
+ $this->digestMode = $digestMode;
+ }
+
+ /**
+ * @param EchoEventPresentationModel[] $models
+ * @return array of the following format:
+ * [ 'body' => formatted email body,
+ * 'subject' => formatted email subject ]
+ */
+ protected function formatModels( array $models ) {
+ $content = [];
+ foreach ( $models as $model ) {
+ $content[$model->getCategory()][] = Sanitizer::stripAllTags( $model->getHeaderMessage()->parse() );
+ }
+
+ ksort( $content );
+
+ // echo-email-batch-body-intro-daily
+ // echo-email-batch-body-intro-weekly
+ $text = $this->msg( 'echo-email-batch-body-intro-' . $this->digestMode )
+ ->params( $this->user->getName() )->text();
+
+ // Does this need to be a message?
+ $bullet = $this->msg( 'echo-email-batch-bullet' )->text();
+
+ foreach ( $content as $type => $items ) {
+ $text .= "\n\n--\n\n";
+ $text .= $this->getCategoryTitle( $type, count( $items ) );
+ $text .= "\n";
+ foreach ( $items as $item ) {
+ $text .= "\n$bullet $item";
+ }
+ }
+
+ $colon = $this->msg( 'colon-separator' )->text();
+ $text .= "\n\n--\n\n";
+ $viewAll = $this->msg( 'echo-email-batch-link-text-view-all-notifications' )->text();
+ $link = SpecialPage::getTitleFor( 'Notifications' )->getFullURL( '', false, PROTO_CANONICAL );
+ $text .= "$viewAll$colon <$link>";
+
+ $plainTextFormatter = new EchoPlainTextEmailFormatter( $this->user, $this->language );
+
+ $text .= "\n\n{$plainTextFormatter->getFooter()}";
+
+ // echo-email-batch-subject-daily
+ // echo-email-batch-subject-weekly
+ $subject = $this->msg( 'echo-email-batch-subject-' . $this->digestMode )
+ ->numParams( count( $models ), count( $models ) )
+ ->text();
+
+ return [
+ 'subject' => $subject,
+ 'body' => $text,
+ ];
+ }
+
+ /**
+ * @param string $type Notification type
+ * @param int $count Number of notifications in this type's section
+ * @return string Formatted category section title
+ */
+ private function getCategoryTitle( $type, $count ) {
+ return $this->msg( "echo-category-title-$type" )
+ ->numParams( $count )
+ ->text();
+ }
+}
diff --git a/Echo/includes/formatters/EchoPlainTextEmailFormatter.php b/Echo/includes/formatters/EchoPlainTextEmailFormatter.php
new file mode 100644
index 00000000..34fdc7d1
--- /dev/null
+++ b/Echo/includes/formatters/EchoPlainTextEmailFormatter.php
@@ -0,0 +1,53 @@
+<?php
+
+class EchoPlainTextEmailFormatter extends EchoEventFormatter {
+ protected function formatModel( EchoEventPresentationModel $model ) {
+ $subject = Sanitizer::stripAllTags( $model->getSubjectMessage()->parse() );
+
+ $text = Sanitizer::stripAllTags( $model->getHeaderMessage()->parse() );
+
+ $text .= "\n\n";
+
+ $bodyMsg = $model->getBodyMessage();
+ if ( $bodyMsg ) {
+ $text .= Sanitizer::stripAllTags( $bodyMsg->parse() );
+ }
+
+ $primaryLink = $model->getPrimaryLinkWithMarkAsRead();
+
+ $primaryUrl = wfExpandUrl( $primaryLink['url'], PROTO_CANONICAL );
+ $colon = $this->msg( 'colon-separator' )->text();
+ $text .= "\n\n{$primaryLink['label']}$colon <$primaryUrl>";
+
+ foreach ( array_filter( $model->getSecondaryLinks() ) as $secondaryLink ) {
+ $url = wfExpandUrl( $secondaryLink['url'], PROTO_CANONICAL );
+ $text .= "\n\n{$secondaryLink['label']}$colon <$url>";
+ }
+
+ // Footer
+ $text .= "\n\n{$this->getFooter()}";
+
+ return [
+ 'body' => $text,
+ 'subject' => $subject,
+ ];
+ }
+
+ /**
+ * @return string
+ */
+ public function getFooter() {
+ global $wgEchoEmailFooterAddress;
+
+ $footerMsg = $this->msg( 'echo-email-plain-footer', $this->user )->text();
+ $prefsUrl = SpecialPage::getTitleFor( 'Preferences', false, 'mw-prefsection-echo' )
+ ->getFullURL( '', false, PROTO_CANONICAL );
+ $text = "--\n\n$footerMsg\n$prefsUrl";
+
+ if ( strlen( $wgEchoEmailFooterAddress ) ) {
+ $text .= "\n\n$wgEchoEmailFooterAddress";
+ }
+
+ return $text;
+ }
+}
diff --git a/Echo/includes/formatters/EditFormatter.php b/Echo/includes/formatters/EditFormatter.php
deleted file mode 100644
index e2d24aaa..00000000
--- a/Echo/includes/formatters/EditFormatter.php
+++ /dev/null
@@ -1,87 +0,0 @@
-<?php
-
-class EchoEditFormatter extends EchoBasicFormatter {
-
- /**
- * @param EchoEvent $event
- * @param $param
- * @param $message Message
- * @param $user User
- */
- protected function processParam( $event, $param, $message, $user ) {
- if ( $param === 'subject-anchor' ) {
- $message->params( $this->formatSubjectAnchor( $event ) );
- } elseif ( $param === 'section-title' ) {
- $message->params( $this->getSectionTitle( $event, $user ) );
- } elseif ( $param === 'difflink' ) {
- $revid = $event->getExtraParam( 'revid' );
- if ( !$revid ) {
- $message->params( '' );
- return;
- }
- $diff = $event->getExtraParam( 'diffid', 'prev' );
- $props = array(
- 'attribs' => array( 'class' => 'mw-echo-diff' ),
- 'linkText' => $this->getMessage( 'parentheses' )
- ->params(
- $this->getMessage( 'showdiff' )->text()
- )->escaped(),
- 'param' => array(
- 'oldid' => $revid,
- 'diff' => $diff,
- ),
- // Set fragment to empty string for diff links
- 'fragment' => ''
- );
- $this->setTitleLink( $event, $message, $props );
- } elseif ( $param === 'summary' ) {
- $message->params( $this->getRevisionSnippet( $event, $user ) );
- } elseif ( $param === 'number' ) {
- $eventData = $event->getExtra();
- // The folliwing is a bit of a hack...
- // If the edit is a rollback, we want to say 'your edits' in the
- // notification. If the edit is an undo, we want to say 'your edit'
- // in the notification. To accomplish this, we pass a 'number' param
- // to the message which is set to 1 or 2 and formatted with {{PLURAL}}.
- if ( isset( $eventData['method'] ) && $eventData['method'] === 'rollback' ) {
- $message->params( 2 );
- } else {
- $message->params( 1 );
- }
- } elseif ( $param === 'userpage-contributions' ) {
- $user = $event->getAgent();
- $name = $user->getName();
- if ( $user->isAnon() ) {
- $message->params( "Special:Contributions/$name" );
- } else {
- $message->params( "User:$name" );
- }
- } else {
- parent::processParam( $event, $param, $message, $user );
- }
- }
-
- /**
- * Get the section title for a talk page post
- * @param $event EchoEvent
- * @param $user User
- * @return string
- */
- protected function getSectionTitle( $event, $user ) {
- $extra = $event->getExtra();
-
- if ( !empty( $extra['section-title'] ) ) {
- if ( $event->userCan( Revision::DELETED_TEXT, $user ) ) {
- return EchoDiscussionParser::getTextSnippet(
- $extra['section-title'],
- $this->getLanguage(),
- 30
- );
- } else {
- return $this->getMessage( 'echo-rev-deleted-text-view' )->text();
- }
- }
-
- return '';
- }
-}
diff --git a/Echo/includes/formatters/EditThresholdPresentationModel.php b/Echo/includes/formatters/EditThresholdPresentationModel.php
new file mode 100644
index 00000000..6a01cb5f
--- /dev/null
+++ b/Echo/includes/formatters/EditThresholdPresentationModel.php
@@ -0,0 +1,22 @@
+<?php
+
+class EchoEditThresholdPresentationModel extends EchoEventPresentationModel {
+
+ public function getIconType() {
+ return 'edit';
+ }
+
+ public function getHeaderMessageKey() {
+ return 'notification-header-thank-you-' . $this->event->getExtraParam( 'editCount' ) . '-edit';
+ }
+
+ public function getPrimaryLink() {
+ if ( !$this->event->getTitle() ) {
+ return false;
+ }
+ return [
+ 'url' => $this->event->getTitle()->getLocalURL(),
+ 'label' => $this->msg( 'notification-link-thank-you-edit', $this->getViewingUserForGender() )->text()
+ ];
+ }
+}
diff --git a/Echo/includes/formatters/EditUserTalkFormatter.php b/Echo/includes/formatters/EditUserTalkFormatter.php
deleted file mode 100644
index 2b5aaa24..00000000
--- a/Echo/includes/formatters/EditUserTalkFormatter.php
+++ /dev/null
@@ -1,42 +0,0 @@
-<?php
-
-/**
- * Custom formatter for 'edit-user-talk' notifications
- */
-class EchoEditUserTalkFormatter extends EchoEditFormatter {
-
- /**
- * {@inheritDoc}
- */
- protected function applyChangeBeforeFormatting( EchoEvent $event, User $user, $type ) {
- parent::applyChangeBeforeFormatting( $event, $user, $type );
-
- // Replace default generic notification message with 'Someone left a message
- // on your talk page in "xxxx"' if
- // * the message is not bundled and
- // * there is a section title
- //
- // We could go with the approach of creating a new notification type, but
- // * this is variant is too small to introduce a new type
- // * may not fall back to default for talk page post with oversighted content
- // * message bundling is supposed to bundle the same notfication type, creating
- // a new type will not be able to bundle them together
- if ( !$this->bundleData['use-bundle'] && $this->getSectionTitle( $event, $user ) ) {
- $this->title = array(
- 'message' => 'notification-edit-talk-page-with-section',
- 'params' => array( 'agent', 'user', 'subject-anchor', 'section-title' )
- );
- $this->flyoutTitle = array(
- 'message' => 'notification-edit-talk-page-flyout-with-section',
- 'params' => array( 'agent', 'user', 'subject-anchor', 'section-title' )
- );
- $this->email['batch-body'] = array(
- 'message' => 'notification-edit-talk-page-email-batch-body-with-section',
- 'params' => array( 'agent', 'section-title' )
- );
- // Display the summary if there is a section title
- $this->payload = array( 'summary' );
- }
- }
-
-}
diff --git a/Echo/includes/formatters/EditUserTalkPresentationModel.php b/Echo/includes/formatters/EditUserTalkPresentationModel.php
new file mode 100644
index 00000000..1fd67369
--- /dev/null
+++ b/Echo/includes/formatters/EditUserTalkPresentationModel.php
@@ -0,0 +1,90 @@
+<?php
+
+class EchoEditUserTalkPresentationModel extends EchoEventPresentationModel {
+ use EchoPresentationModelSectionTrait;
+
+ public function canRender() {
+ return (bool)$this->event->getTitle();
+ }
+
+ public function getIconType() {
+ return 'edit-user-talk';
+ }
+
+ public function getPrimaryLink() {
+ return [
+ // Need FullURL so the section is included
+ 'url' => $this->getTitleWithSection()->getFullURL(),
+ 'label' => $this->msg( 'notification-link-text-view-message' )->text()
+ ];
+ }
+
+ public function getSecondaryLinks() {
+ $diffLink = [
+ 'url' => $this->getDiffLinkUrl(),
+ 'label' => $this->msg( 'notification-link-text-view-changes', $this->getViewingUserForGender() )->text(),
+ 'description' => '',
+ 'icon' => 'changes',
+ 'prioritized' => true
+ ];
+
+ if ( $this->isBundled() ) {
+ return [ $diffLink ];
+ } else {
+ return [ $this->getAgentLink(), $diffLink ];
+ }
+ }
+
+ public function getHeaderMessage() {
+ if ( $this->isBundled() ) {
+ $msg = $this->msg( "notification-bundle-header-{$this->type}-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" );
+ $msg->params( $this->getViewingUserForGender() );
+ $msg->plaintextParams( $this->getTruncatedSectionTitle() );
+ return $msg;
+ } else {
+ $msg = parent::getHeaderMessage();
+ $msg->params( $this->getViewingUserForGender() );
+ return $msg;
+ }
+ }
+
+ public function getBodyMessage() {
+ $sectionText = $this->event->getExtraParam( 'section-text' );
+ if ( !$this->isBundled() && $this->hasSection() && $sectionText !== null ) {
+ $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
+ $msg->plaintextParams( $sectionText );
+ return $msg;
+ } else {
+ return false;
+ }
+ }
+
+ private function getDiffLinkUrl() {
+ $revId = $this->event->getExtraParam( 'revid' );
+ $oldId = $this->isBundled() ? $this->getRevBeforeFirstNotification() : 'prev';
+ $query = [
+ 'oldid' => $oldId,
+ 'diff' => $revId,
+ ];
+ return $this->event->getTitle()->getFullURL( $query );
+ }
+
+ private function getRevBeforeFirstNotification() {
+ $events = $this->getBundledEvents();
+ $firstNotificationRevId = end( $events )->getExtraParam( 'revid' );
+ return $this->event->getTitle()->getPreviousRevisionID( $firstNotificationRevId );
+ }
+
+ protected function getSubjectMessageKey() {
+ return 'notification-edit-talk-page-email-subject2';
+ }
+}
diff --git a/Echo/includes/formatters/EmailUserPresentationModel.php b/Echo/includes/formatters/EmailUserPresentationModel.php
new file mode 100644
index 00000000..da30d7ed
--- /dev/null
+++ b/Echo/includes/formatters/EmailUserPresentationModel.php
@@ -0,0 +1,21 @@
+<?php
+
+class EchoEmailUserPresentationModel extends EchoEventPresentationModel {
+
+ public function getIconType() {
+ return 'emailuser';
+ }
+
+ public function getPrimaryLink() {
+ return false;
+ }
+
+ public function getSecondaryLinks() {
+ return [ $this->getAgentLink() ];
+ }
+
+ public function getBodyMessage() {
+ $preview = $this->event->getExtraParam( 'preview' );
+ return $preview ? $this->msg( 'notification-body-emailuser' )->plaintextParams( $preview ) : false;
+ }
+}
diff --git a/Echo/includes/formatters/EventPresentationModel.php b/Echo/includes/formatters/EventPresentationModel.php
new file mode 100644
index 00000000..766cb902
--- /dev/null
+++ b/Echo/includes/formatters/EventPresentationModel.php
@@ -0,0 +1,678 @@
+<?php
+
+use Wikimedia\Timestamp\TimestampException;
+
+/**
+ * Class that returns structured data based
+ * on the provided event.
+ */
+abstract class EchoEventPresentationModel implements JsonSerializable {
+
+ /**
+ * Recommended length of usernames included in messages
+ */
+ const USERNAME_RECOMMENDED_LENGTH = 20;
+
+ /**
+ * Recommended length of usernames used as link label
+ */
+ const USERNAME_AS_LABEL_RECOMMENDED_LENGTH = 15;
+
+ /**
+ * Recommended length of page names included in messages
+ */
+ const PAGE_NAME_RECOMMENDED_LENGTH = 50;
+
+ /**
+ * Recommended length of page names used as link label
+ */
+ const PAGE_NAME_AS_LABEL_RECOMMENDED_LENGTH = 15;
+
+ /**
+ * Recommended length of section titles included in messages
+ */
+ const SECTION_TITLE_RECOMMENDED_LENGTH = 50;
+
+ /**
+ * @var EchoEvent
+ */
+ protected $event;
+
+ /**
+ * @var Language
+ */
+ protected $language;
+
+ /**
+ * @var string
+ */
+ protected $type;
+
+ /**
+ * @var User for permissions checking
+ */
+ private $user;
+
+ /**
+ * @var string 'web' or 'email'
+ */
+ private $distributionType;
+
+ /**
+ * @param EchoEvent $event
+ * @param Language|string $language
+ * @param User $user Only used for permissions checking and GENDER
+ * @param string $distributionType
+ */
+ protected function __construct( EchoEvent $event, $language, User $user, $distributionType ) {
+ $this->event = $event;
+ $this->type = $event->getType();
+ $this->language = wfGetLangObj( $language );
+ $this->user = $user;
+ $this->distributionType = $distributionType;
+ }
+
+ /**
+ * Convenience function to detect whether the event type
+ * has been updated to use the presentation model system
+ *
+ * @param string $type event type
+ * @return bool
+ */
+ public static function supportsPresentationModel( $type ) {
+ global $wgEchoNotifications;
+ return isset( $wgEchoNotifications[$type]['presentation-model'] );
+ }
+
+ /**
+ * @param EchoEvent $event
+ * @param Language|string $language
+ * @param User $user
+ * @param string $distributionType 'web' or 'email'
+ * @return EchoEventPresentationModel
+ */
+ public static function factory( EchoEvent $event, $language, User $user, $distributionType = 'web' ) {
+ global $wgEchoNotifications;
+ // @todo don't depend upon globals
+
+ $class = $wgEchoNotifications[$event->getType()]['presentation-model'];
+ return new $class( $event, $language, $user, $distributionType );
+ }
+
+ /**
+ * Get the type of event
+ *
+ * @return string
+ */
+ final public function getType() {
+ return $this->type;
+ }
+
+ /**
+ * Get the user receiving the notification
+ *
+ * @return User
+ */
+ final public function getUser() {
+ return $this->user;
+ }
+
+ /**
+ * Get the category of event
+ *
+ * @return string
+ */
+ final public function getCategory() {
+ return $this->event->getCategory();
+ }
+
+ /**
+ * Equivalent to IContextSource::msg for the current
+ * language
+ *
+ * @return Message
+ */
+ protected function msg( /* ,,, */ ) {
+ /**
+ * @var Message $msg
+ */
+ $msg = call_user_func_array( 'wfMessage', func_get_args() );
+ $msg->inLanguage( $this->language );
+
+ // Notifications are considered UI (and should be in UI language, not
+ // content), and this flag is set false by inLanguage.
+ $msg->setInterfaceMessageFlag( true );
+
+ return $msg;
+ }
+
+ /**
+ * @return EchoEvent[]
+ */
+ final protected function getBundledEvents() {
+ return $this->event->getBundledEvents() ?: [];
+ }
+
+ /**
+ * Get the ids of the bundled notifications or false if it's not bundled
+ *
+ * @return int[]|bool
+ */
+ public function getBundledIds() {
+ if ( $this->isBundled() ) {
+ return array_map( function ( EchoEvent $event ) {
+ return $event->getId();
+ }, $this->getBundledEvents() );
+ }
+ return false;
+ }
+
+ /**
+ * This method returns true when there are bundled notifications, even if they are all
+ * in the same group according to getBundleGrouping(). For presentation purposes, you may
+ * want to check if getBundleCount( true, $yourCallback ) > 1 instead.
+ *
+ * @return bool Whether there are other notifications bundled with this one.
+ */
+ final protected function isBundled() {
+ return $this->getBundleCount() > 1;
+ }
+
+ /**
+ * Count the number of event groups in this bundle.
+ *
+ * By default, each event is in its own group, and this method returns the number of events.
+ * To group events differently, pass $groupCallback. For example, to group events with the
+ * same title together, use $callback = function ( $event ) { return $event->getTitle()->getPrefixedText(); }
+ *
+ * If $includeCurrent is false, all events in the same group as the current one will be ignored.
+ *
+ * @param bool $includeCurrent Include the current event (and its group)
+ * @param callable $groupCallback Callback that takes an EchoEvent and returns a grouping value
+ * @return int Number of bundled events or groups
+ * @throws InvalidArgumentException
+ */
+ final protected function getBundleCount( $includeCurrent = true, $groupCallback = null ) {
+ $events = array_merge( $this->getBundledEvents(), [ $this->event ] );
+ if ( $groupCallback ) {
+ if ( !is_callable( $groupCallback ) ) {
+ // If we pass an invalid callback to array_map(), it'll just throw a warning
+ // and return NULL, so $count ends up being 0 or -1. Instead of doing that,
+ // throw an exception.
+ throw new InvalidArgumentException( 'Invalid callback passed to getBundleCount' );
+ }
+ $events = array_unique( array_map( $groupCallback, $events ) );
+ }
+ $count = count( $events );
+
+ if ( !$includeCurrent ) {
+ $count--;
+ }
+ return $count;
+ }
+
+ /**
+ * Return the count of notifications bundled together.
+ *
+ * For parameters, see getBundleCount().
+ *
+ * @param bool $includeCurrent
+ * @param callable $groupCallback
+ * @return int count
+ */
+ final protected function getNotificationCountForOutput( $includeCurrent = true, $groupCallback = null ) {
+ $count = $this->getBundleCount( $includeCurrent, $groupCallback );
+ $cappedCount = EchoNotificationController::getCappedNotificationCount( $count );
+ return $cappedCount;
+ }
+
+ /**
+ * @return string The symbolic icon name as defined in $wgEchoNotificationIcons
+ */
+ abstract public function getIconType();
+
+ /**
+ * @return string Timestamp the event occurred at
+ */
+ final public function getTimestamp() {
+ return $this->event->getTimestamp();
+ }
+
+ /**
+ * Helper for EchoEvent::userCan
+ *
+ * @param int $type Revision::DELETED_* constant
+ * @return bool
+ */
+ final protected function userCan( $type ) {
+ return $this->event->userCan( $type, $this->user );
+ }
+
+ /**
+ * @return array|bool ['wikitext to display', 'username for GENDER'], false if no agent
+ *
+ * We have to display wikitext so we can add CSS classes for revision deleted user.
+ * The goal of this function is for callers not to worry about whether
+ * the user is visible or not.
+ * @par Example:
+ * @code
+ * list( $formattedName, $genderName ) = $this->getAgentForOutput();
+ * $msg->params( $formattedName, $genderName );
+ * @endcode
+ */
+ final protected function getAgentForOutput() {
+ $agent = $this->event->getAgent();
+ if ( !$agent ) {
+ return false;
+ }
+
+ if ( $this->userCan( Revision::DELETED_USER ) ) {
+ // Not deleted
+ return [
+ $this->getTruncatedUsername( $agent ),
+ $agent->getName()
+ ];
+ } else {
+ // Deleted/hidden
+ $msg = $this->msg( 'rev-deleted-user' )->plain();
+ // HACK: Pass an invalid username to GENDER to force the default
+ return [ '<span class="history-deleted">' . $msg . '</span>', '[]' ];
+ }
+ }
+
+ /**
+ * Return a message with the given key and the agent's
+ * formatted name and name for GENDER as 1st and
+ * 2nd parameters.
+ * @param string $key
+ * @return Message
+ */
+ final protected function getMessageWithAgent( $key ) {
+ $msg = $this->msg( $key );
+ list( $formattedName, $genderName ) = $this->getAgentForOutput();
+ $msg->params( $formattedName, $genderName );
+ return $msg;
+ }
+
+ /**
+ * Get the viewing user's name for usage in GENDER
+ *
+ * @return string
+ */
+ final protected function getViewingUserForGender() {
+ return $this->user->getName();
+ }
+
+ /**
+ * @return array|null Link object to the user's page or Special:Contributions for anon users.
+ * Can be used for primary or secondary links.
+ * Same format as secondary link.
+ * Returns null if the current user cannot see the agent.
+ */
+ final protected function getAgentLink() {
+ return $this->getUserLink( $this->event->getAgent() );
+ }
+
+ /**
+ * To be overridden by subclasses if they are unable to render the
+ * notification, for example when a page is deleted.
+ * If this function returns false, no other methods will be called
+ * on the object.
+ *
+ * @return bool
+ */
+ public function canRender() {
+ return true;
+ }
+
+ /**
+ * @return string Message key that will be used in getHeaderMessage
+ */
+ protected function getHeaderMessageKey() {
+ return "notification-header-{$this->type}";
+ }
+
+ /**
+ * Get a message object and add the performer's name as
+ * a parameter. It is expected that subclasses will override
+ * this.
+ *
+ * @return Message
+ */
+ public function getHeaderMessage() {
+ return $this->getMessageWithAgent( $this->getHeaderMessageKey() );
+ }
+
+ /**
+ * @return string Message key that will be used in getCompactHeaderMessage
+ */
+ public function getCompactHeaderMessageKey() {
+ return "notification-compact-header-{$this->type}";
+ }
+
+ /**
+ * Get a message object and add the performer's name as
+ * a parameter. It is expected that subclasses will override
+ * this.
+ *
+ * This message should be more compact than the header message
+ * ( getHeaderMessage() ). It is displayed when a
+ * notification is part of an expanded bundle.
+ *
+ * @return Message
+ */
+ public function getCompactHeaderMessage() {
+ $msg = $this->getMessageWithAgent( $this->getCompactHeaderMessageKey() );
+ if ( $msg->isDisabled() ) {
+ // Back-compat for models that haven't been updated yet
+ $msg = $this->getHeaderMessage();
+ }
+
+ return $msg;
+ }
+
+ /**
+ * @return string Message key that will be used in getSubjectMessage
+ */
+ protected function getSubjectMessageKey() {
+ return "notification-subject-{$this->type}";
+ }
+
+ /**
+ * Get a message object and add the performer's name as
+ * a parameter. It is expected that subclasses will override
+ * this. The output of the message should be plaintext.
+ *
+ * This message is used as the subject line in
+ * single-notification emails.
+ *
+ * For backward compatibility, if this is not defined,
+ * the header message ( getHeaderMessage() ) is used instead.
+ *
+ * @return Message
+ */
+ public function getSubjectMessage() {
+ $msg = $this->getMessageWithAgent( $this->getSubjectMessageKey() );
+ if ( $msg->isDisabled() ) {
+ // Back-compat for models that haven't been updated yet
+ $msg = $this->getHeaderMessage();
+ }
+
+ return $msg;
+ }
+
+ /**
+ * Get a message for the notification's body, false if it has no body
+ *
+ * @return bool|Message
+ */
+ public function getBodyMessage() {
+ return false;
+ }
+
+ /**
+ * Array of primary link details, with possibly-relative URL & label.
+ *
+ * @return array|bool Array of link data, or false for no link:
+ * ['url' => (string) url, 'label' => (string) link text (non-escaped)]
+ */
+ abstract public function getPrimaryLink();
+
+ /**
+ * Like getPrimaryLink(), but with the URL altered to add ?markasread=XYZ. When this link is followed,
+ * the notification is marked as read.
+ *
+ * When the notification is a bundle, the notification IDs are added to the parameter value
+ * separated by a "|".
+ *
+ * @return array|bool
+ */
+ final public function getPrimaryLinkWithMarkAsRead() {
+ $primaryLink = $this->getPrimaryLink();
+ if ( $primaryLink ) {
+ $eventIds = [ $this->event->getId() ];
+ if ( $this->getBundledIds() ) {
+ $eventIds = array_merge( $eventIds, $this->getBundledIds() );
+ }
+ $primaryLink['url'] = wfAppendQuery( $primaryLink['url'], [ 'markasread' => implode( '|', $eventIds ) ] );
+ }
+ return $primaryLink;
+ }
+
+ /**
+ * Array of secondary link details, including possibly-relative URLs, label,
+ * description & icon name.
+ *
+ * @return array Array of links in the format of:
+ * [['url' => (string) url,
+ * 'label' => (string) link text (non-escaped),
+ * 'description' => (string) descriptive text (optional, non-escaped),
+ * 'icon' => (bool|string) symbolic ooui icon name (or false if there is none),
+ * 'type' => (string) optional action type. Used to note a dynamic action, by setting it to 'dynamic-action'
+ * 'data' => (array) optional array containing information about the dynamic action. It must include 'tokenType' (string), 'messages' (array) with messages supplied for the item and the confirmation dialog and 'params' (array) for the API operation needed to complete the action. For example:
+ * 'data' => [
+ * 'tokenType' => 'watch',
+ * 'params' => [
+ * 'action' => 'watch',
+ * 'titles' => 'Namespace:SomeTitle'
+ * ],
+ * 'messages' => [
+ * 'confirmation' => [
+ * 'title' => 'message (parsed as HTML)',
+ * 'description' => 'optional message (parsed as HTML)'
+ * ]
+ * ]
+ * ]
+ * 'prioritized' => (bool) true if the link should be outside the
+ * action menu, false for inside)],
+ * ...]
+ *
+ * Note that you should call array_values(array_filter()) on the
+ * result of this function (FIXME).
+ */
+ public function getSecondaryLinks() {
+ return [];
+ }
+
+ /**
+ * Get the ID of the associated event
+ * @return int Event id
+ */
+ public function getEventId() {
+ return $this->event->getId();
+ }
+
+ /**
+ * @return array
+ * @throws TimestampException
+ */
+ public function jsonSerialize() {
+ $body = $this->getBodyMessage();
+
+ return [
+ 'header' => $this->getHeaderMessage()->parse(),
+ 'compactHeader' => $this->getCompactHeaderMessage()->parse(),
+ 'body' => $body ? $body->escaped() : '',
+ 'icon' => $this->getIconType(),
+ 'links' => [
+ 'primary' => $this->getPrimaryLinkWithMarkAsRead() ?: [],
+ 'secondary' => array_values( array_filter( $this->getSecondaryLinks() ) ),
+ ],
+ ];
+ }
+
+ /**
+ * @param User $user
+ * @return string
+ */
+ protected function getTruncatedUsername( User $user ) {
+ return $this->language->embedBidi( $this->language->truncate( $user->getName(), self::USERNAME_RECOMMENDED_LENGTH, '...', false ) );
+ }
+
+ /**
+ * @param Title $title
+ * @param bool $includeNamespace
+ * @return string
+ */
+ protected function getTruncatedTitleText( Title $title, $includeNamespace = false ) {
+ $text = $includeNamespace ? $title->getPrefixedText() : $title->getText();
+ return $this->language->embedBidi( $this->language->truncate( $text, self::PAGE_NAME_RECOMMENDED_LENGTH, '...', false ) );
+ }
+
+ /**
+ * @param User|null $user
+ * @return array|null
+ */
+ final protected function getUserLink( $user ) {
+ if ( !$user ) {
+ return null;
+ }
+
+ if ( !$this->userCan( Revision::DELETED_USER ) ) {
+ return null;
+ }
+
+ $url = $user->isAnon()
+ ? SpecialPage::getTitleFor( 'Contributions', $user->getName() )->getFullURL()
+ : $user->getUserPage()->getFullURL();
+
+ $label = $user->getName();
+ $truncatedLabel = $this->language->truncate( $label, self::USERNAME_AS_LABEL_RECOMMENDED_LENGTH, '...', false );
+ $isTruncated = $label !== $truncatedLabel;
+
+ return [
+ 'url' => $url,
+ 'label' => $this->language->embedBidi( $truncatedLabel ),
+ 'tooltip' => $isTruncated ? $label : '',
+ 'description' => '',
+ 'icon' => 'userAvatar',
+ 'prioritized' => true,
+ ];
+ }
+
+ /**
+ * @param Title $title
+ * @param string $description
+ * @param bool $prioritized
+ * @param array $query
+ * @return array
+ */
+ final protected function getPageLink( Title $title, $description, $prioritized, $query = [] ) {
+ if ( $title->getNamespace() === NS_USER_TALK ) {
+ $icon = 'userSpeechBubble';
+ } elseif ( $title->isTalkPage() ) {
+ $icon = 'speechBubbles';
+ } else {
+ $icon = 'article';
+ }
+
+ return [
+ 'url' => $title->getFullURL( $query ),
+ 'label' => $this->language->embedBidi(
+ $this->language->truncate( $title->getText(), self::PAGE_NAME_AS_LABEL_RECOMMENDED_LENGTH, '...', false )
+ ),
+ 'tooltip' => $title->getPrefixedText(),
+ 'description' => $description,
+ 'icon' => $icon,
+ 'prioritized' => $prioritized,
+ ];
+ }
+
+ /**
+ * 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 $label link text (non-escaped)
+ * @param string $description descriptive text (optional, non-escaped)
+ * @param array $data Action data
+ * @param array $query
+ * @return array Array compatible with the structure of
+ * secondary links
+ */
+ final protected function getDynamicActionLink( Title $title, $icon, $label, $description = null, $data = [], $query = [] ) {
+ if ( !$icon && $title->getNamespace() === NS_USER_TALK ) {
+ $icon = 'userSpeechBubble';
+ } elseif ( !$icon && $title->isTalkPage() ) {
+ $icon = 'speechBubbles';
+ } elseif ( !$icon ) {
+ $icon = 'article';
+ }
+
+ return [
+ 'type' => 'dynamic-action',
+ 'label' => $label,
+ 'description' => $description,
+ 'data' => $data,
+ 'url' => $title->getFullURL( $query ),
+ 'icon' => $icon,
+ ];
+ }
+
+ /**
+ * Get an 'watch' or 'unwatch' dynamic action link
+ *
+ * @param Title $title Title to watch or unwatch
+ * @return array Array compatible with dynamic action link
+ */
+ final protected function getWatchActionLink( Title $title ) {
+ $isTitleWatched = $this->getUser()->isWatched( $title );
+ $availableAction = $isTitleWatched ? 'unwatch' : 'watch';
+
+ $data = [
+ 'tokenType' => 'watch',
+ 'params' => [
+ 'action' => 'watch',
+ 'titles' => $title->getPrefixedText(),
+ ],
+ 'messages' => [
+ 'confirmation' => [
+ // notification-dynamic-actions-watch-confirmation
+ // notification-dynamic-actions-unwatch-confirmation
+ 'title' => $this
+ ->msg( 'notification-dynamic-actions-' . $availableAction . '-confirmation' )
+ ->params(
+ $this->getTruncatedTitleText( $title ),
+ $title->getFullURL(),
+ $this->getUser()->getName()
+ ),
+ // notification-dynamic-actions-watch-confirmation-description
+ // notification-dynamic-actions-unwatch-confirmation-description
+ 'description' => $this
+ ->msg( 'notification-dynamic-actions-' . $availableAction . '-confirmation-description' )
+ ->params(
+ $this->getTruncatedTitleText( $title ),
+ $title->getFullURL(),
+ $this->getUser()->getName()
+ ),
+ ],
+ ],
+ ];
+
+ // "Unwatching" action requires another parameter
+ if ( $isTitleWatched ) {
+ $data[ 'params' ][ 'unwatch' ] = 1;
+ }
+
+ return $this->getDynamicActionLink(
+ $title,
+ // Design requirements are to flip the star icons
+ // in their meaning; that is, for the 'unwatch' action
+ // we should display an empty star, and for the 'watch'
+ // action a full star. In OOUI icons, their names
+ // are reversed.
+ $isTitleWatched ? 'star' : 'unStar',
+ // notification-dynamic-actions-watch
+ // notification-dynamic-actions-unwatch
+ $this->msg( 'notification-dynamic-actions-' . $availableAction )
+ ->params(
+ $this->getTruncatedTitleText( $title ),
+ $title->getFullURL( [ 'action' => $availableAction ] ),
+ $this->getUser()->getName()
+ ),
+ null,
+ $data,
+ [ 'action' => $availableAction ]
+ );
+ }
+}
diff --git a/Echo/includes/formatters/MentionFormatter.php b/Echo/includes/formatters/MentionFormatter.php
deleted file mode 100644
index 2bd98ae2..00000000
--- a/Echo/includes/formatters/MentionFormatter.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-class EchoMentionFormatter extends EchoCommentFormatter {
- /**
- * {@inheritDoc}
- */
- protected function applyChangeBeforeFormatting( EchoEvent $event, User $user, $type ) {
- parent::applyChangeBeforeFormatting( $event, $user, $type );
-
- // If we can't find a section title for the mention,
- // fall back to `notification-mention-nosection`.
- if ( !$this->getSectionTitle( $event, $user ) ) {
- $this->title = array(
- 'message' => 'notification-mention-nosection',
- 'params' => array( 'agent', 'main-title-text', 'title' )
- );
- $this->flyoutTitle = array(
- 'message' => 'notification-mention-nosection-flyout',
- 'params' => array( 'agent', 'main-title-text', 'title' )
- );
- $this->email['batch-body'] = array(
- 'message' => 'notification-mention-nosection-email-batch-body',
- 'params' => array( 'agent', 'main-title-text' )
- );
- }
- }
-}
diff --git a/Echo/includes/formatters/MentionPresentationModel.php b/Echo/includes/formatters/MentionPresentationModel.php
new file mode 100644
index 00000000..69e3cc23
--- /dev/null
+++ b/Echo/includes/formatters/MentionPresentationModel.php
@@ -0,0 +1,129 @@
+<?php
+
+class EchoMentionPresentationModel extends EchoEventPresentationModel {
+ use EchoPresentationModelSectionTrait;
+
+ public function getIconType() {
+ return 'mention';
+ }
+
+ public function canRender() {
+ return (bool)$this->event->getTitle();
+ }
+
+ protected function getHeaderMessageKey() {
+ if ( $this->onArticleTalkpage() ) {
+ return $this->hasSection() ?
+ 'notification-header-mention-article-talkpage' :
+ 'notification-header-mention-article-talkpage-nosection';
+ } elseif ( $this->onAgentTalkpage() ) {
+ return $this->hasSection() ?
+ 'notification-header-mention-agent-talkpage' :
+ 'notification-header-mention-agent-talkpage-nosection';
+ } elseif ( $this->onUserTalkpage() ) {
+ return $this->hasSection() ?
+ 'notification-header-mention-user-talkpage-v2' :
+ 'notification-header-mention-user-talkpage-nosection';
+ } else {
+ return $this->hasSection() ?
+ 'notification-header-mention-other' :
+ 'notification-header-mention-other-nosection';
+ }
+ }
+
+ public function getHeaderMessage() {
+ $msg = $this->getMessageWithAgent( $this->getHeaderMessageKey() );
+ $msg->params( $this->getViewingUserForGender() );
+
+ if ( $this->onArticleTalkpage() ) {
+ $msg->params( $this->getTruncatedTitleText( $this->event->getTitle() ) );
+ } elseif ( $this->onAgentTalkpage() ) {
+ // No params to add here.
+ // If we remove this check, onUserTalkpage() has to
+ // make sure it is a user talk page but NOT the agent's talk page.
+ } elseif ( $this->onUserTalkpage() ) {
+ $username = $this->event->getTitle()->getText();
+ $msg->params( $this->getTruncatedUsername( User::newFromName( $username, false ) ) );
+ $msg->params( $username );
+ } else {
+ $msg->params( $this->getTruncatedTitleText( $this->event->getTitle(), true ) );
+ }
+
+ if ( $this->hasSection() ) {
+ $msg->plaintextParams( $this->getTruncatedSectionTitle() );
+ }
+
+ return $msg;
+ }
+
+ public function getBodyMessage() {
+ $content = $this->event->getExtraParam( 'content' );
+ if ( $content && $this->userCan( Revision::DELETED_TEXT ) ) {
+ $msg = $this->msg( 'notification-body-mention' );
+ $msg->plaintextParams(
+ EchoDiscussionParser::getTextSnippet(
+ $content,
+ $this->language,
+ 150,
+ $this->event->getTitle()
+ )
+ );
+ return $msg;
+ } else {
+ return false;
+ }
+ }
+
+ public function getPrimaryLink() {
+ return [
+ // Need FullURL so the section is included
+ 'url' => $this->getTitleWithSection()->getFullURL(),
+ 'label' => $this->msg( 'notification-link-text-view-mention' )->text()
+ ];
+ }
+
+ public function getSecondaryLinks() {
+ $title = $this->event->getTitle();
+
+ $url = $title->getLocalURL( [
+ 'oldid' => 'prev',
+ 'diff' => $this->event->getExtraParam( 'revid' )
+ ] );
+ $viewChangesLink = [
+ 'url' => $url,
+ 'label' => $this->msg( 'notification-link-text-view-changes', $this->getViewingUserForGender() )->text(),
+ 'description' => '',
+ 'icon' => 'changes',
+ 'prioritized' => true,
+ ];
+
+ return [ $this->getAgentLink(), $viewChangesLink ];
+ }
+
+ private function onArticleTalkpage() {
+ return $this->event->getTitle()->getNamespace() === NS_TALK;
+ }
+
+ private function onAgentTalkpage() {
+ return $this->event->getTitle()->equals( $this->event->getAgent()->getTalkPage() );
+ }
+
+ 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;
+ }
+
+ protected function getSubjectMessageKey() {
+ return 'notification-mention-email-subject';
+ }
+}
diff --git a/Echo/includes/formatters/MentionStatusPresentationModel.php b/Echo/includes/formatters/MentionStatusPresentationModel.php
new file mode 100644
index 00000000..974c23d2
--- /dev/null
+++ b/Echo/includes/formatters/MentionStatusPresentationModel.php
@@ -0,0 +1,142 @@
+<?php
+
+/**
+ * Presenter for 'mention-failure' and 'mention-success' notifications
+ *
+ * @author Christoph Fischer <christoph.fischer@wikimedia.de>
+ *
+ * @license MIT
+ */
+class EchoMentionStatusPresentationModel extends EchoEventPresentationModel {
+ use EchoPresentationModelSectionTrait;
+
+ public function getIconType() {
+ if ( $this->isMixedBundle() ) {
+ return 'mention-status-bundle';
+ }
+ if ( $this->isMentionSuccess() ) {
+ return 'mention-success';
+ }
+ return 'mention-failure';
+ }
+
+ public function canRender() {
+ return (bool)$this->event->getTitle();
+ }
+
+ public function getHeaderMessage() {
+ if ( $this->isTooManyMentionsFailure() ) {
+ $msg = $this->getMessageWithAgent( 'notification-header-mention-failure-too-many' );
+ $msg->numParams( $this->getMaxMentions() );
+ return $msg;
+ }
+
+ if ( $this->isBundled() ) {
+ if ( $this->isMixedBundle() ) {
+ $successCount = $this->getBundleSuccessCount();
+
+ $msg = $this->getMessageWithAgent( 'notification-header-mention-status-bundle' );
+ $msg->numParams( $this->getBundleCount() );
+ $msg->params( $this->getTruncatedTitleText( $this->event->getTitle() ) );
+ $msg->numParams( $this->getBundleCount() - $successCount );
+ $msg->numParams( $successCount );
+ return $msg;
+ }
+ if ( $this->isMentionSuccess() ) {
+ $msgKey = 'notification-header-mention-success-bundle';
+ } else {
+ $msgKey = 'notification-header-mention-failure-bundle';
+ }
+ $msg = $this->getMessageWithAgent( $msgKey );
+ $msg->numParams( $this->getBundleCount() );
+ $msg->params( $this->getTruncatedTitleText( $this->event->getTitle() ) );
+ return $msg;
+ }
+
+ if ( $this->isMentionSuccess() ) {
+ $msgKey = 'notification-header-mention-success';
+ } else {
+ // Messages that can be used here:
+ // * notification-header-mention-failure-user-unknown
+ // * notification-header-mention-failure-user-anonymous
+ $msgKey = 'notification-header-mention-failure-' . $this->getFailureType();
+ }
+ $msg = $this->getMessageWithAgent( $msgKey );
+ $msg->params( $this->getSubjectName() );
+ return $msg;
+ }
+
+ public function getCompactHeaderMessage() {
+ if ( $this->isMentionSuccess() ) {
+ $msg = $this->getMessageWithAgent( 'notification-compact-header-mention-success' );
+ } else {
+ // Messages that can be used here:
+ // * notification-compact-header-mention-failure-user-unknown
+ // * notification-compact-header-mention-failure-user-anonymous
+ $msg = $this->msg( 'notification-compact-header-mention-failure-' . $this->getFailureType() );
+ }
+ $msg->params( $this->getSubjectName() );
+ return $msg;
+ }
+
+ public function getPrimaryLink() {
+ return [
+ // Need FullURL so the section is included
+ 'url' => $this->getTitleWithSection()->getFullURL(),
+ 'label' => $this->msg( 'notification-link-text-view-mention-failure' )
+ ->numParams( $this->getBundleCount() )
+ ->text()
+ ];
+ }
+
+ public function getSecondaryLinks() {
+ if ( $this->isBundled() ) {
+ return [];
+ }
+
+ $talkPageLink = $this->getPageLink(
+ $this->getTitleWithSection(),
+ '',
+ true
+ );
+
+ return [ $talkPageLink ];
+ }
+
+ public function isMentionSuccessEvent( EchoEvent $event ) {
+ return $event->getType() === 'mention-success';
+ }
+
+ private function isMentionSuccess() {
+ return $this->isMentionSuccessEvent( $this->event );
+ }
+
+ private function getSubjectName() {
+ return $this->event->getExtraParam( 'subject-name', '' );
+ }
+
+ private function getFailureType() {
+ return $this->event->getExtraParam( 'failure-type', 'user-unknown' );
+ }
+
+ private function isTooManyMentionsFailure() {
+ return $this->getFailureType() === 'too-many' ||
+ $this->getType() === 'mention-failure-too-many';
+ }
+
+ private function getMaxMentions() {
+ global $wgEchoMaxMentionsCount;
+ return $this->event->getExtraParam( 'max-mentions', $wgEchoMaxMentionsCount );
+ }
+
+ private function getBundleSuccessCount() {
+ $events = array_merge( $this->getBundledEvents(), [ $this->event ] );
+ return count( array_filter( $events, [ $this, 'isMentionSuccessEvent' ] ) );
+ }
+
+ private function isMixedBundle() {
+ $successCount = $this->getBundleSuccessCount();
+ $failCount = $this->getBundleCount() - $successCount;
+ return $successCount > 0 && $failCount > 0;
+ }
+}
diff --git a/Echo/includes/formatters/NotificationFormatter.php b/Echo/includes/formatters/NotificationFormatter.php
deleted file mode 100644
index b7406d47..00000000
--- a/Echo/includes/formatters/NotificationFormatter.php
+++ /dev/null
@@ -1,190 +0,0 @@
-<?php
-
-/**
- * Abstract class for constructing a notification message, this class includes
- * only the most generic formatting functionality as it may be extended by
- * notification formatters for other extensions with unique content or
- * requirements.
- */
-abstract class EchoNotificationFormatter {
-
- /**
- * List of valid output format
- * @var array
- */
- protected $validOutputFormats = array( 'text', 'flyout', 'html', 'email', 'htmlemail' );
-
- /**
- * List of valid distribution type
- */
- protected $validDistributionType = array( 'web', 'email', 'emaildigest', 'emailsubject' );
-
- /**
- * Current output format, default is 'text'
- * @var string
- */
- protected $outputFormat = 'text';
-
- /**
- * Distribution type, default is 'web'
- * @var string
- */
- protected $distributionType = 'web';
-
- /**
- * List of parameters for constructing messages
- * @var array
- */
- protected $parameters;
-
- /**
- * Creates an instance of the given class with the given parameters.
- * @param $parameters array Associative array of parameters
- * @throws MWException
- */
- public function __construct( array $parameters ) {
- $this->parameters = $parameters;
- }
-
- /**
- * Shows a notification in human-readable format.
- * @param $event EchoEvent being notified about.
- * @param $user User being notified.
- * @param $type string The notification type (e.g. notify, email)
- * @return Mixed; depends on output format
- * @see EchoNotificationFormatter::setOutputFormat
- */
- public abstract function format( $event, $user, $type );
-
- /**
- * Set the output format that the notification will be displayed in.
- * @param $format string A valid output format (by default, 'text', 'html', 'flyout', and 'email' are allowed)
- * @throws InvalidArgumentException
- */
- public function setOutputFormat( $format ) {
- if ( !in_array( $format, $this->validOutputFormats, true ) ) {
- throw new InvalidArgumentException( "Invalid output format $format" );
- }
-
- $this->outputFormat = $format;
- }
-
- public function setDistributionType( $type ) {
- if ( !in_array( $type, $this->validDistributionType, true ) ) {
- throw new InvalidArgumentException( "Invalid distribution type $type" );
- }
-
- $this->distributionType = $type;
- }
-
- /**
- * Create an EchoNotificationFormatter for the given type.
- * @param string $type
- * Select the class of formatter to use with the 'formatter-class' field.
- * For other parameters, see the appropriate class' constructor.
- * @throws RuntimeException
- * @return EchoNotificationFormatter object.
- */
- public static function factory( $type ) {
- global $wgEchoNotifications;
- if ( !isset( $wgEchoNotifications[$type] ) ) {
- throw new InvalidArgumentException( "The notification type '$type' is not registered" );
- }
-
- $parameters = $wgEchoNotifications[$type];
- if ( isset( $parameters['formatter-class'] ) ) {
- $class = $parameters['formatter-class'];
- } else {
- $class = 'EchoBasicFormatter';
- }
-
- if ( !class_exists( $class ) ) {
- throw new RuntimeException( "Class $class does not exist" );
- }
-
- return new $class( $parameters );
- }
-
- /**
- * Returns a link to a title, or the title itself.
- * @param $title Title object
- * @return string Text suitable for output format
- */
- protected function formatTitle( Title $title ) {
- return $title->getPrefixedText();
- }
-
- /**
- * Formats a timestamp in a human-readable format
- * @param $ts string Timestamp in some format compatible with wfTimestamp()
- * @return string Human-readable timestamp
- */
- protected function formatTimestamp( $ts ) {
- $timestamp = new MWTimestamp( $ts );
- $ts = $timestamp->getHumanTimestamp();
- return $ts;
- }
-
- /**
- * @todo this shouldn't be static
- * @param string $icon Name of icon as registered in BeforeCreateEchoEvent hook
- * @param string $dir either 'ltr' or 'rtl'
- * @return string
- */
- public static function getIconUrl( $icon, $dir ) {
- global $wgEchoNotificationIcons, $wgExtensionAssetsPath;
- if ( !isset( $wgEchoNotificationIcons[$icon] ) ) {
- throw new InvalidArgumentException( "The $icon icon is not registered" );
- }
-
- $iconInfo = $wgEchoNotificationIcons[$icon];
- $needsPrefixing = true;
-
- // Now we need to check it has a valid url/path
- if ( isset( $iconInfo['url'] ) && $iconInfo['url'] ) {
- $iconUrl = $iconInfo['url'];
- $needsPrefixing = false;
- } elseif ( isset( $iconInfo['path'] ) && $iconInfo['path'] ) {
- $iconUrl = $iconInfo['path'];
- } else {
- // Fallback to hardcoded 'placeholder'. This is used if someone
- // doesn't configure the 'site' icon for example.
- $icon = 'placeholder';
- $iconUrl = $wgEchoNotificationIcons['placeholder']['path'];
- }
-
- // Might be an array with different icons for ltr/rtl
- if ( is_array( $iconUrl ) ) {
- if ( !isset( $iconUrl[$dir] ) ) {
- throw new UnexpectedValueException( "Icon type $icon doesn't have an icon for $dir directionality" );
- }
-
- $iconUrl = $iconUrl[$dir];
- }
-
- // And if it was a 'path', stick the assets path in front
- if ( $needsPrefixing ) {
- $iconUrl = "$wgExtensionAssetsPath/$iconUrl";
- }
-
- return $iconUrl;
- }
-
- /**
- * Returns a revision snippet
- * @param EchoEvent $event The event that the notification is for.
- * @param User $user The user to format the notification for.
- * @return String The revision snippet (or empty string)
- */
- public function getRevisionSnippet( $event, $user ) {
- $extra = $event->getExtra();
- if ( !isset( $extra['section-text'] ) || !$event->userCan( Revision::DELETED_TEXT, $user ) ) {
- return '';
- }
-
- $snippet = trim( $extra['section-text'] );
-
- return $snippet;
- }
-
-}
diff --git a/Echo/includes/formatters/PageLinkFormatter.php b/Echo/includes/formatters/PageLinkFormatter.php
deleted file mode 100644
index 80fbc456..00000000
--- a/Echo/includes/formatters/PageLinkFormatter.php
+++ /dev/null
@@ -1,199 +0,0 @@
-<?php
-
-/**
- * Custom formatter for 'page-link' notifications
- */
-class EchoPageLinkFormatter extends EchoBasicFormatter {
-
- /**
- * This is a workaround for backwards compatibility.
- * In https://gerrit.wikimedia.org/r/#/c/63076 we changed
- * the schema to save link-from-page-id instead of
- * link-from-namespace & link-from-title
- */
- protected function extractExtra( $extra ) {
- if ( isset( $extra['link-from-namespace'], $extra['link-from-title'] )
- && !isset( $extra['link-from-page-id'] )
- ) {
- $title = Title::makeTitleSafe(
- $extra['link-from-namespace'],
- $extra['link-from-title']
- );
- if ( $title ) {
- $extra['link-from-page-id'] = $title->getArticleId();
- unset(
- $extra['link-from-namespace'],
- $extra['link-from-title']
- );
- }
- }
-
- return $extra;
- }
-
- /**
- * This method overwrite parent method and construct the bundle iterator
- * based on link from, it will be used in a message like this: Page A was
- * link from Page B and X other pages
- *
- * @param $event EchoEvent
- * @param $user User
- * @param $type string deprecated
- */
- protected function generateBundleData( $event, $user, $type ) {
- global $wgEchoMaxNotificationCount;
-
- $data = $this->getRawBundleData( $event, $user, $type );
-
- if ( !$data ) {
- return;
- }
- $extra = self::extractExtra( $event->getExtra() );
-
- if ( !$this->isTitleSet( $extra ) ) {
- // Link from title is required for bundling notification
- return;
- }
-
- $count = 1;
- $linkFrom = array(
- $extra['link-from-page-id'] => true
- );
- foreach ( $data as $bundledEvent ) {
- $extra = $bundledEvent->getExtra();
- if ( !$extra ) {
- continue;
- }
-
- if ( $this->isTitleSet( $extra ) ) {
- $pageId = $extra['link-from-page-id'];
-
- if ( !isset( $linkFrom[$pageId] ) ) {
- $linkFrom[$pageId] = true;
- $count++;
- }
- }
- if ( $count > $wgEchoMaxNotificationCount + 1 ) {
- break;
- }
- }
-
- $this->bundleData['link-from-page-other-count'] = $count - 1;
- if ( $count > 1 ) {
- $this->bundleData['use-bundle'] = true;
- }
- }
-
- /**
- * Internal function to check if link from page id key is set
- * @param $extra array
- * @return bool
- */
- private function isTitleSet( $extra ) {
- return isset( $extra['link-from-page-id'] ) && $extra['link-from-page-id'];
- }
-
- /**
- * @param $event EchoEvent
- * @param $param string
- * @param $message Message
- * @param $user User
- */
- protected function processParam( $event, $param, $message, $user ) {
- $extra = self::extractExtra( $event->getExtra() );
- switch ( $param ) {
- // 'A' part in this message: link from page A and X others
- case 'link-from-page':
- $title = null;
- if ( $this->isTitleSet( $extra ) ) {
- $title = Title::newFromId( $extra['link-from-page-id'] );
- // Link-from page could be a brand new page and page_id would not be replicated
- // to slave db yet. If job queue is enabled to process web and email notification,
- // the check against master database is not necessary since there is already a
- // delay in the job queue
- if ( !$title ) {
- global $wgEchoUseJobQueue;
- $diff = wfTimestamp() - wfTimestamp( TS_UNIX, $event->getTimestamp() );
- if ( !$wgEchoUseJobQueue && $diff < 5 ) {
- $title = Title::newFromID( $extra['link-from-page-id'], Title::GAID_FOR_UPDATE );
- }
- }
- if ( $title ) {
- if ( $this->outputFormat === 'htmlemail' ) {
- $message->rawParams(
- Linker::link(
- $title,
- $this->formatTitle( $title ),
- array( 'style' => $this->getHTMLLinkStyle() ),
- array(),
- array( 'https' )
- )
- );
- } else {
- $message->params( $this->formatTitle( $title ) );
- }
- }
- }
-
- if ( !$title ) {
- $message->params( $this->getMessage( 'echo-no-title' ) );
- }
- break;
-
- // example: {7} other page, {99+} other pages
- case 'link-from-page-other-display':
- global $wgEchoMaxNotificationCount;
-
- if ( $this->bundleData['link-from-page-other-count'] > $wgEchoMaxNotificationCount ) {
- $message->params(
- $this->getMessage( 'echo-notification-count' )
- ->numParams( $wgEchoMaxNotificationCount )
- ->text()
- );
- } else {
- $message->numParams( $this->bundleData['link-from-page-other-count'] );
- }
- break;
-
- // the number used for plural support
- case 'link-from-page-other-count':
- $message->params( $this->bundleData['link-from-page-other-count'] );
- break;
-
- default:
- parent::processParam( $event, $param, $message, $user );
- break;
- }
- }
-
- /**
- * Helper function for getLink()
- *
- * @param EchoEvent $event
- * @param User $user The user receiving the notification
- * @param String $destination The destination type for the link
- * @return Array including target and query parameters
- */
- protected function getLinkParams( $event, $user, $destination ) {
- $target = null;
- $query = array();
- // Set up link parameters based on the destination (or pass to parent)
- switch ( $destination ) {
- case 'link-from-page':
- if ( $this->bundleData['use-bundle'] ) {
- if ( $event->getTitle() ) {
- $target = SpecialPage::getTitleFor( 'WhatLinksHere', $event->getTitle()->getPrefixedText() );
- }
- } else {
- $extra = self::extractExtra( $event->getExtra() );
- if ( $this->isTitleSet( $extra ) ) {
- $target = Title::newFromId( $extra['link-from-page-id'] );
- }
- }
- break;
- default:
- return parent::getLinkParams( $event, $user, $destination );
- }
- return array( $target, $query );
- }
-}
diff --git a/Echo/includes/formatters/PageLinkedPresentationModel.php b/Echo/includes/formatters/PageLinkedPresentationModel.php
new file mode 100644
index 00000000..d6e41323
--- /dev/null
+++ b/Echo/includes/formatters/PageLinkedPresentationModel.php
@@ -0,0 +1,114 @@
+<?php
+
+class EchoPageLinkedPresentationModel extends EchoEventPresentationModel {
+
+ private $pageFrom;
+
+ public function getIconType() {
+ return 'linked';
+ }
+
+ /**
+ * The page containing the link may be a new page
+ * that is not yet replicated.
+ * This event won't be rendered unless/until
+ * both pages are available.
+ * @return bool
+ */
+ public function canRender() {
+ $pageTo = $this->event->getTitle();
+ $pageFrom = $this->getPageFrom();
+ return (bool)$pageTo && (bool)$pageFrom;
+ }
+
+ public function getPrimaryLink() {
+ if ( $this->isBundled() ) {
+ return false;
+ } else {
+ return [
+ 'url' => $this->getPageFrom()->getFullURL(),
+ 'label' => $this->msg( 'notification-link-text-view-page' )->text(),
+ ];
+ }
+ }
+
+ public function getSecondaryLinks() {
+ $whatLinksHereLink = [
+ 'url' => SpecialPage::getTitleFor( 'Whatlinkshere', $this->event->getTitle()->getPrefixedText() )->getFullURL(),
+ 'label' => $this->msg( 'notification-link-text-what-links-here' )->text(),
+ 'description' => '',
+ 'icon' => 'linked',
+ 'prioritized' => true
+ ];
+
+ $revid = $this->event->getExtraParam( 'revid' );
+ $diffLink = null;
+ if ( $revid !== null ) {
+ $diffLink = [
+ 'url' => $this->getPageFrom()->getFullURL( [ 'diff' => $revid, 'oldid' => 'prev' ] ),
+ 'label' => $this->msg( 'notification-link-text-view-changes', $this->getViewingUserForGender() )->text(),
+ 'description' => '',
+ 'icon' => 'changes',
+ 'prioritized' => true
+ ];
+ }
+
+ return [ $whatLinksHereLink, $diffLink ];
+ }
+
+ protected function getHeaderMessageKey() {
+ if ( $this->getBundleCount( true, [ $this, 'getLinkedPageId' ] ) > 1 ) {
+ return "notification-bundle-header-{$this->type}";
+ }
+ return "notification-header-{$this->type}";
+ }
+
+ public function getHeaderMessage() {
+ $msg = parent::getHeaderMessage();
+ $msg->params( $this->getTruncatedTitleText( $this->event->getTitle(), true ) );
+ $msg->params( $this->getTruncatedTitleText( $this->getPageFrom(), true ) );
+ $count =
+ $this->getNotificationCountForOutput( true, [ $this, 'getLinkedPageId' ] );
+
+ // Repeat is B/C until unused parameter is removed from translations
+ $msg->numParams( $count, $count );
+ return $msg;
+ }
+
+ public function getCompactHeaderMessage() {
+ $msg = $this->msg( parent::getCompactHeaderMessageKey() );
+ $msg->params( $this->getTruncatedTitleText( $this->getPageFrom(), true ) );
+ return $msg;
+ }
+
+ /**
+ * Get the page ID of the linked-from page for a given event.
+ * @param EchoEvent $event page-linked event
+ * @return int Page ID, or 0 if the page doesn't exist
+ */
+ public function getLinkedPageId( EchoEvent $event ) {
+ $extra = $event->getExtra();
+ if ( isset( $extra['link-from-page-id'] ) ) {
+ return $extra['link-from-page-id'];
+ }
+ // Backwards compatiblity for events from before https://gerrit.wikimedia.org/r/#/c/63076
+ 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 0;
+ }
+
+ private function getPageFrom() {
+ if ( !$this->pageFrom ) {
+ $this->pageFrom = Title::newFromId( $this->getLinkedPageId( $this->event ) );
+ }
+ return $this->pageFrom;
+ }
+
+ protected function getSubjectMessageKey() {
+ return 'notification-page-linked-email-subject';
+ }
+}
diff --git a/Echo/includes/formatters/PresentationModelSectionTrait.php b/Echo/includes/formatters/PresentationModelSectionTrait.php
new file mode 100644
index 00000000..7d0e6b9f
--- /dev/null
+++ b/Echo/includes/formatters/PresentationModelSectionTrait.php
@@ -0,0 +1,96 @@
+<?php
+/**
+ * Trait that adds section title handling to an EchoEventPresentationModel subclass.
+ */
+trait EchoPresentationModelSectionTrait {
+ private $rawSectionTitle = null;
+ private $parsedSectionTitle = null;
+
+ /**
+ * Get the raw (unparsed) section title
+ * @return string Section title
+ */
+ protected function getRawSectionTitle() {
+ if ( $this->rawSectionTitle !== null ) {
+ return $this->rawSectionTitle;
+ }
+ $sectionTitle = $this->event->getExtraParam( 'section-title' );
+ if ( !$sectionTitle ) {
+ $this->rawSectionTitle = false;
+ return false;
+ }
+ // Check permissions
+ if ( !$this->userCan( Revision::DELETED_TEXT ) ) {
+ $this->rawSectionTitle = false;
+ return false;
+ }
+
+ $this->rawSectionTitle = $sectionTitle;
+ return $this->rawSectionTitle;
+ }
+
+ /**
+ * Get the section title parsed to plain text
+ * @return string Section title (plain text)
+ */
+ protected function getParsedSectionTitle() {
+ if ( $this->parsedSectionTitle !== null ) {
+ return $this->parsedSectionTitle;
+ }
+ $rawSectionTitle = $this->getRawSectionTitle();
+ if ( !$rawSectionTitle ) {
+ $this->parsedSectionTitle = false;
+ return false;
+ }
+ $this->parsedSectionTitle = EchoDiscussionParser::getTextSnippet(
+ $rawSectionTitle,
+ $this->language,
+ 150,
+ $this->event->getTitle()
+ );
+ return $this->parsedSectionTitle;
+ }
+
+ /**
+ * Check if there is a section.
+ *
+ * This also returns false if the revision is deleted,
+ * even if there is a section, because the section can't
+ * be viewed in that case.
+ * @return bool Whether there is a section
+ */
+ protected function hasSection() {
+ return (bool)$this->getRawSectionTitle();
+ }
+
+ /**
+ * Get a Title pointing to the section, if available.
+ * @return Title
+ */
+ protected function getTitleWithSection() {
+ $title = $this->event->getTitle();
+ $section = $this->getParsedSectionTitle();
+ // Like Parser::guessSectionNameFromWikiText() but without the link stripping
+ $fragment = Sanitizer::escapeId(
+ Sanitizer::normalizeSectionNameWhitespace( $section ),
+ 'noninitial'
+ );
+ if ( $section ) {
+ $title = Title::makeTitle(
+ $title->getNamespace(),
+ $title->getDBkey(),
+ $fragment
+ );
+ }
+ return $title;
+ }
+
+ protected function getTruncatedSectionTitle() {
+ return $this->language->embedBidi( $this->language->truncate(
+ $this->getParsedSectionTitle(),
+ self::SECTION_TITLE_RECOMMENDED_LENGTH,
+ '...',
+ false
+ ) );
+ }
+}
diff --git a/Echo/includes/formatters/RevertedPresentationModel.php b/Echo/includes/formatters/RevertedPresentationModel.php
new file mode 100644
index 00000000..04d63b93
--- /dev/null
+++ b/Echo/includes/formatters/RevertedPresentationModel.php
@@ -0,0 +1,85 @@
+<?php
+
+class EchoRevertedPresentationModel extends EchoEventPresentationModel {
+
+ public function getIconType() {
+ return 'revert';
+ }
+
+ public function canRender() {
+ return (bool)$this->event->getTitle();
+ }
+
+ public function getHeaderMessage() {
+ $msg = parent::getHeaderMessage();
+ $msg->params( $this->getTruncatedTitleText( $this->event->getTitle(), true ) );
+ $msg->params( $this->getNumberOfEdits() );
+ return $msg;
+ }
+
+ public function getBodyMessage() {
+ $summary = $this->event->getExtraParam( 'summary' );
+ if ( !$this->isAutomaticSummary( $summary ) && $this->userCan( Revision::DELETED_COMMENT ) ) {
+ $msg = $this->msg( "notification-body-{$this->type}" );
+ $msg->plaintextParams( $this->formatSummary( $summary ) );
+ return $msg;
+ } else {
+ return false;
+ }
+ }
+
+ private function formatSummary( $wikitext ) {
+ return EchoDiscussionParser::getTextSnippetFromSummary( $wikitext, $this->language );
+ }
+
+ public function getPrimaryLink() {
+ $url = $this->event->getTitle()->getLocalURL( [
+ 'oldid' => 'prev',
+ 'diff' => $this->event->getExtraParam( 'revid' )
+ ] );
+ return [
+ 'url' => $url,
+ 'label' => $this->msg( 'notification-link-text-view-changes', $this->getViewingUserForGender() )->text()
+ ];
+ }
+
+ public function getSecondaryLinks() {
+ $links = [ $this->getAgentLink() ];
+
+ $title = $this->event->getTitle();
+ if ( $title->canHaveTalkPage() ) {
+ $links[] = $this->getPageLink(
+ $title->getTalkPage(), null, true
+ );
+ }
+
+ return $links;
+ }
+
+ /**
+ * Return a number that represents if one or multiple edits
+ * have been reverted for formatting purposes.
+ * @return int
+ */
+ private function getNumberOfEdits() {
+ $method = $this->event->getExtraParam( 'method' );
+ if ( $method && $method === 'rollback' ) {
+ return 2;
+ } else {
+ return 1;
+ }
+ }
+
+ private function isAutomaticSummary( $summary ) {
+ $autoSummaryMsg = wfMessage( 'undo-summary' )->inContentLanguage();
+ $autoSummaryMsg->params( $this->event->getExtraParam( 'reverted-revision-id' ) );
+ $autoSummaryMsg->params( $this->getViewingUserForGender() );
+ $autoSummary = $autoSummaryMsg->text();
+
+ return $summary === $autoSummary;
+ }
+
+ protected function getSubjectMessageKey() {
+ return 'notification-reverted-email-subject2';
+ }
+}
diff --git a/Echo/includes/formatters/SpecialNotificationsFormatter.php b/Echo/includes/formatters/SpecialNotificationsFormatter.php
new file mode 100644
index 00000000..d5e7d981
--- /dev/null
+++ b/Echo/includes/formatters/SpecialNotificationsFormatter.php
@@ -0,0 +1,113 @@
+<?php
+
+/**
+ * A formatter for Special:Notifications
+ *
+ * This formatter uses OOUI libraries. Any calls to this formatter must
+ * also call OutputPage::enableOOUI() before calling this formatter.
+ */
+class SpecialNotificationsFormatter extends EchoEventFormatter {
+ protected function formatModel( EchoEventPresentationModel $model ) {
+ $markReadSpecialPage = new SpecialNotificationsMarkRead();
+ $id = $model->getEventId();
+
+ $icon = Html::element(
+ 'img',
+ [
+ 'class' => 'mw-echo-icon',
+ 'src' => $this->getIconURL( $model ),
+ ]
+ );
+
+ OutputPage::setupOOUI();
+
+ $markAsReadIcon = new OOUI\IconWidget( [
+ 'icon' => 'close',
+ 'title' => wfMessage( 'echo-notification-markasread' ),
+ ] );
+
+ $markAsReadForm = $markReadSpecialPage->getMinimalForm(
+ $id,
+ $this->msg( 'echo-notification-markasread' )->text(),
+ false,
+ $markAsReadIcon->toString()
+ );
+
+ $markAsReadButton = Html::rawElement(
+ 'div',
+ [ 'class' => 'mw-echo-markAsReadButton' ],
+ $markAsReadForm->prepareForm()->getHTML( /* First submission attempt */ false )
+ );
+
+ $html = Xml::tags(
+ 'div',
+ [ 'class' => 'mw-echo-title' ],
+ $model->getHeaderMessage()->parse()
+ ) . "\n";
+
+ $body = $model->getBodyMessage();
+ if ( $body ) {
+ $html .= Xml::tags(
+ 'div',
+ [ 'class' => 'mw-echo-payload' ],
+ $body->escaped()
+ ) . "\n";
+ }
+
+ $ts = $this->language->getHumanTimestamp(
+ new MWTimestamp( $model->getTimestamp() ),
+ null,
+ $this->user
+ );
+
+ $footerItems = [ Html::element( 'span', [ 'class' => 'mw-echo-notification-footer-element' ], $ts ) ];
+
+ // Add links to the footer, primary goes first, then secondary ones
+ $links = [];
+ $primaryLink = $model->getPrimaryLinkWithMarkAsRead();
+ if ( $primaryLink !== false ) {
+ $links[] = $primaryLink;
+ }
+ $links = array_merge( $links, array_filter( $model->getSecondaryLinks() ) );
+ foreach ( $links as $link ) {
+ $footerAttributes = [
+ 'href' => $link['url'],
+ 'class' => 'mw-echo-notification-footer-element',
+ ];
+
+ if ( isset( $link['tooltip'] ) ) {
+ $footerAttributes['title'] = $link['tooltip'];
+ }
+
+ $footerItems[] = Html::element(
+ 'a',
+ $footerAttributes,
+ $link['label']
+ );
+ }
+
+ $pipe = wfMessage( 'pipe-separator' )->inLanguage( $this->language )->escaped();
+ $html .= Xml::tags(
+ 'div',
+ [ 'class' => 'mw-echo-notification-footer' ],
+ implode( Html::element( 'span', [ 'class' => 'mw-echo-notification-footer-element' ], $pipe ), $footerItems )
+ ) . "\n";
+
+ // Wrap everything in mw-echo-content class
+ $html = Xml::tags( 'div', [ 'class' => 'mw-echo-content' ], $html );
+
+ // And then add the mark as read button
+ // and the icon in front and wrap with
+ // mw-echo-state class.
+ $html = Xml::tags( 'div', [ 'class' => 'mw-echo-state' ], $markAsReadButton . $icon . $html );
+
+ return $html;
+ }
+
+ private function getIconURL( EchoEventPresentationModel $model ) {
+ return EchoIcon::getUrl(
+ $model->getIconType(),
+ $this->language->getDir()
+ );
+ }
+}
diff --git a/Echo/includes/formatters/UserRightsFormatter.php b/Echo/includes/formatters/UserRightsFormatter.php
deleted file mode 100644
index 3f1ca492..00000000
--- a/Echo/includes/formatters/UserRightsFormatter.php
+++ /dev/null
@@ -1,71 +0,0 @@
-<?php
-
-/**
- * Formatter for 'user-rights' notifications
- */
-class EchoUserRightsFormatter extends EchoBasicFormatter {
-
- /**
- * @param $event EchoEvent
- * @param $param string
- * @param $message Message
- * @param $user User
- */
- protected function processParam( $event, $param, $message, $user ) {
- $extra = $event->getExtra();
- switch ( $param ) {
- // List of user rights that are granted or revoked
- case 'user-rights-list':
- $lang = $this->getLanguage();
-
- $list = array();
-
- foreach ( array( 'add', 'remove' ) as $action ) {
- if ( isset( $extra[$action] ) && $extra[$action] ) {
-
- // Get the localized group names, bug 55338
- $groups = array();
- foreach( $extra[$action] as $group ) {
- $msg = $this->getMessage( 'group-' . $group );
- $groups[] = $msg->isBlank() ? $group : $msg->escaped();
- }
-
- // Messages that can be used here:
- // * notification-user-rights-add
- // * notification-user-rights-remove
- $list[] = $this->getMessage( 'notification-user-rights-' . $action )
- ->params( $lang->commaList( $groups ), count( $groups ) )
- ->escaped();
- }
- }
- $message->params( $lang->semicolonList( $list ) );
- break;
-
- default:
- parent::processParam( $event, $param, $message, $user );
- break;
- }
- }
-
- /**
- * Helper function for getLink()
- *
- * @param EchoEvent $event
- * @param User $user The user receiving the notification
- * @param String $destination The destination type for the link
- * @return Array including target and query parameters
- */
- protected function getLinkParams( $event, $user, $destination ) {
- $target = null;
- $query = array();
- // Set up link parameters based on the destination (or pass to parent)
- switch ( $destination ) {
- case 'user-rights-list':
- $target = SpecialPage::getTitleFor( 'Listgrouprights' );
- break;
- default:
- return parent::getLinkParams( $event, $user, $destination );
- }
- return array( $target, $query );
- }
-}
diff --git a/Echo/includes/formatters/UserRightsPresentationModel.php b/Echo/includes/formatters/UserRightsPresentationModel.php
new file mode 100644
index 00000000..53f20d03
--- /dev/null
+++ b/Echo/includes/formatters/UserRightsPresentationModel.php
@@ -0,0 +1,110 @@
+<?php
+
+/**
+ * Formatter for 'user-rights' notifications
+ */
+class EchoUserRightsPresentationModel extends EchoEventPresentationModel {
+
+ public function getIconType() {
+ return 'user-rights';
+ }
+
+ public function getHeaderMessage() {
+ list( $formattedName, $genderName ) = $this->getAgentForOutput();
+ $add = array_map(
+ [ $this->language, 'embedBidi' ],
+ $this->getLocalizedGroupNames( array_values( $this->event->getExtraParam( 'add', [] ) ) )
+ );
+ $remove = array_map(
+ [ $this->language, 'embedBidi' ],
+ $this->getLocalizedGroupNames( array_values( $this->event->getExtraParam( 'remove', [] ) ) )
+ );
+ $expiryChanged = array_map(
+ [ $this->language, 'embedBidi' ],
+ $this->getLocalizedGroupNames( array_values( $this->event->getExtraParam( 'expiry-changed', [] ) ) )
+ );
+ if ( $expiryChanged ) {
+ $msg = $this->msg( 'notification-header-user-rights-expiry-change' );
+ $msg->params( $genderName );
+ $msg->params( $this->language->commaList( $expiryChanged ) );
+ $msg->params( count( $expiryChanged ) );
+ $msg->params( $this->getViewingUserForGender() );
+ return $msg;
+ } elseif ( $add && !$remove ) {
+ $msg = $this->msg( 'notification-header-user-rights-add-only' );
+ $msg->params( $genderName );
+ $msg->params( $this->language->commaList( $add ) );
+ $msg->params( count( $add ) );
+ $msg->params( $this->getViewingUserForGender() );
+ return $msg;
+ } elseif ( !$add && $remove ) {
+ $msg = $this->msg( 'notification-header-user-rights-remove-only' );
+ $msg->params( $genderName );
+ $msg->params( $this->language->commaList( $remove ) );
+ $msg->params( count( $remove ) );
+ $msg->params( $this->getViewingUserForGender() );
+ return $msg;
+ } else {
+ $msg = $this->msg( 'notification-header-user-rights-add-and-remove' );
+ $msg->params( $genderName );
+ $msg->params( $this->language->commaList( $add ) );
+ $msg->params( count( $add ) );
+ $msg->params( $this->language->commaList( $remove ) );
+ $msg->params( count( $remove ) );
+ $msg->params( $this->getViewingUserForGender() );
+ return $msg;
+ }
+ }
+
+ public function getBodyMessage() {
+ $reason = $this->event->getExtraParam( 'reason' );
+ return $reason ? $this->msg( 'notification-body-user-rights' )->params( $reason ) : false;
+ }
+
+ private function getLocalizedGroupNames( $names ) {
+ return array_map( function ( $name ) {
+ $msg = $this->msg( 'group-' . $name );
+ return $msg->isBlank() ? $name : $msg->text();
+ }, $names );
+ }
+
+ public function getPrimaryLink() {
+ $addedGroups = array_values( $this->event->getExtraParam( 'add', [] ) );
+ $removedGroups = array_values( $this->event->getExtraParam( 'remove', [] ) );
+ if ( count( $addedGroups ) >= 1 && count( $removedGroups ) === 0 ) {
+ $fragment = $addedGroups[0];
+ } elseif ( count( $addedGroups ) === 0 && count( $removedGroups ) >= 1 ) {
+ $fragment = $removedGroups[0];
+ } else {
+ $fragment = '';
+ }
+ return [
+ 'url' => SpecialPage::getTitleFor( 'Listgrouprights', false, $fragment )->getFullURL(),
+ 'label' => $this->msg( 'echo-learn-more' )->text()
+ ];
+ }
+
+ public function getSecondaryLinks() {
+ return [ $this->getAgentLink(), $this->getLogLink() ];
+ }
+
+ private function getLogLink() {
+ $affectedUserPage = User::newFromId( $this->event->getExtraParam( 'user' ) )->getUserPage();
+ $query = [
+ 'type' => 'rights',
+ 'page' => $affectedUserPage->getPrefixedText(),
+ 'user' => $this->event->getAgent()->getName(),
+ ];
+ return [
+ 'label' => $this->msg( 'echo-log' )->text(),
+ 'url' => SpecialPage::getTitleFor( 'Log' )->getFullURL( $query ),
+ 'description' => '',
+ 'icon' => false,
+ 'prioritized' => true,
+ ];
+ }
+
+ protected function getSubjectMessageKey() {
+ return 'notification-user-rights-email-subject';
+ }
+}
diff --git a/Echo/includes/formatters/WelcomePresentationModel.php b/Echo/includes/formatters/WelcomePresentationModel.php
new file mode 100644
index 00000000..ca14f17b
--- /dev/null
+++ b/Echo/includes/formatters/WelcomePresentationModel.php
@@ -0,0 +1,25 @@
+<?php
+
+class EchoWelcomePresentationModel extends EchoEventPresentationModel {
+
+ public function getIconType() {
+ return 'site';
+ }
+
+ public function getPrimaryLink() {
+ $msg = $this->msg( 'notification-welcome-link' );
+ if ( $msg->isDisabled() ) {
+ return false;
+ }
+
+ $title = Title::newFromText( $msg->plain() );
+ if ( !$title ) {
+ return false;
+ }
+
+ return [
+ 'url' => $title->getFullURL(),
+ 'label' => $this->msg( 'notification-welcome-linktext' )->text(),
+ ];
+ }
+}
diff --git a/Echo/includes/gateway/UserNotificationGateway.php b/Echo/includes/gateway/UserNotificationGateway.php
index 5251b776..28261a7c 100644
--- a/Echo/includes/gateway/UserNotificationGateway.php
+++ b/Echo/includes/gateway/UserNotificationGateway.php
@@ -35,7 +35,7 @@ class EchoUserNotificationGateway {
/**
* Mark notifications as read
* @param $eventIDs array
- * @return boolean
+ * @return bool
*/
public function markRead( array $eventIDs ) {
if ( !$eventIDs ) {
@@ -46,12 +46,36 @@ class EchoUserNotificationGateway {
return $dbw->update(
self::$notificationTable,
- array( 'notification_read_timestamp' => $dbw->timestamp( wfTimestampNow() ) ),
- array(
+ [ 'notification_read_timestamp' => $dbw->timestamp( wfTimestampNow() ) ],
+ [
'notification_user' => $this->user->getId(),
'notification_event' => $eventIDs,
'notification_read_timestamp' => null,
- ),
+ ],
+ __METHOD__
+ );
+ }
+
+ /**
+ * Mark notifications as unread
+ * @param $eventIDs array
+ * @return bool
+ */
+ public function markUnRead( array $eventIDs ) {
+ if ( !$eventIDs ) {
+ return;
+ }
+
+ $dbw = $this->dbFactory->getEchoDb( DB_MASTER );
+
+ return $dbw->update(
+ self::$notificationTable,
+ [ 'notification_read_timestamp' => null ],
+ [
+ 'notification_user' => $this->user->getId(),
+ 'notification_event' => $eventIDs,
+ 'notification_read_timestamp IS NOT NULL'
+ ],
__METHOD__
);
}
@@ -66,25 +90,25 @@ class EchoUserNotificationGateway {
return $dbw->update(
self::$notificationTable,
- array( 'notification_read_timestamp' => $dbw->timestamp( wfTimestampNow() ) ),
- array(
+ [ 'notification_read_timestamp' => $dbw->timestamp( wfTimestampNow() ) ],
+ [
'notification_user' => $this->user->getId(),
- 'notification_read_timestamp' => NULL,
- 'notification_bundle_base' => 1,
- ),
+ 'notification_read_timestamp' => null,
+ ],
__METHOD__
);
}
/**
* Get notification count for the types specified
- * @param int use master or slave storage to pull count
- * @param array event types to retrieve
+ * @param int $dbSource use master or slave storage to pull count
+ * @param array $eventTypesToLoad event types to retrieve
+ * @param int $cap Max count
* @return int
*/
- public function getNotificationCount( $dbSource, array $eventTypesToLoad = array() ) {
+ public function getCappedNotificationCount( $dbSource, array $eventTypesToLoad = [], $cap = MWEchoNotifUser::MAX_BADGE_COUNT ) {
// double check
- if ( !in_array( $dbSource, array( DB_SLAVE, DB_MASTER ) ) ) {
+ if ( !in_array( $dbSource, [ DB_SLAVE, DB_MASTER ] ) ) {
$dbSource = DB_SLAVE;
}
@@ -92,60 +116,53 @@ class EchoUserNotificationGateway {
return 0;
}
- global $wgEchoMaxNotificationCount;
-
$db = $this->dbFactory->getEchoDb( $dbSource );
- $res = $db->select(
- array(
+ return $db->selectRowCount(
+ [
self::$notificationTable,
self::$eventTable
- ),
- array( 'notification_event' ),
- array(
+ ],
+ [ '1' ],
+ [
'notification_user' => $this->user->getId(),
- 'notification_bundle_base' => 1,
'notification_read_timestamp' => null,
+ 'event_deleted' => 0,
'event_type' => $eventTypesToLoad,
- ),
+ ],
__METHOD__,
- array( 'LIMIT' => $wgEchoMaxNotificationCount + 1 ),
- array(
- 'echo_event' => array( 'LEFT JOIN', 'notification_event=event_id' ),
- )
+ [ 'LIMIT' => $cap ],
+ [
+ 'echo_event' => [ 'LEFT JOIN', 'notification_event=event_id' ],
+ ]
);
- if ( $res ) {
- return $db->numRows( $res );
- } else {
- return 0;
- }
}
/**
* IMPORTANT: should only call this function if the number of unread notification
* is reasonable, for example, unread notification count is less than the max
- * display defined in $wgEchoMaxNotificationCount
+ * display defined in MWEchoNotifUser::MAX_BADGE_COUNT
* @param string
* @return int[]
*/
public function getUnreadNotifications( $type ) {
$dbr = $this->dbFactory->getEchoDb( DB_SLAVE );
$res = $dbr->select(
- array(
+ [
self::$notificationTable,
self::$eventTable
- ),
- array( 'notification_event' ),
- array(
+ ],
+ [ 'notification_event' ],
+ [
'notification_user' => $this->user->getId(),
- 'notification_bundle_base' => 1,
'notification_read_timestamp' => null,
+ 'event_deleted' => 0,
'event_type' => $type,
'notification_event = event_id'
- ),
+ ],
__METHOD__
);
- $eventIds = array();
+ $eventIds = [];
if ( $res ) {
foreach ( $res as $row ) {
$eventIds[$row->notification_event] = $row->notification_event;
diff --git a/Echo/includes/iterator/CallbackFilterIterator.php b/Echo/includes/iterator/CallbackFilterIterator.php
deleted file mode 100644
index 4095d1d4..00000000
--- a/Echo/includes/iterator/CallbackFilterIterator.php
+++ /dev/null
@@ -1,24 +0,0 @@
-<?php
-
-/**
- * This class is implemented as part of SPL starting at PHP5.4. This
- * re-implementation provides backwards compatibility to mediawiki
- * running on PHP5.3.
- */
-class CallbackFilterIterator extends FilterIterator {
- protected $callback;
-
- public function __construct( Iterator $iterator, $callback ) {
- parent::__construct( $iterator );
- $this->callback = $callback;
- }
-
- public function accept() {
- return call_user_func(
- $this->callback,
- $this->current(),
- $this->key(),
- $this->getInnerIterator()
- );
- }
-}
diff --git a/Echo/includes/iterator/FilteredSequentialIterator.php b/Echo/includes/iterator/FilteredSequentialIterator.php
index cc0271f9..246a0910 100644
--- a/Echo/includes/iterator/FilteredSequentialIterator.php
+++ b/Echo/includes/iterator/FilteredSequentialIterator.php
@@ -13,19 +13,19 @@
* $users = new EchoFilteredSequentialIterator;
* $users->add( array( $userA, $userB, $userC ) );
*
- * $it = new EchoBatchRowIterator( ... );
+ * $it = new BatchRowIterator( ... );
* ...
* $it = new RecursiveIteratorIterator( $it );
* $users->add( new EchoCallbackIterator( $it, function( $row ) {
- * ...
- * return $user;
+ * ...
+ * return $user;
* } ) );
*
* foreach ( $users as $user ) {
- * ...
+ * ...
* }
*
- * By default the EchoBatchRowIterator returns an array of rows, this class
+ * By default the BatchRowIterator returns an array of rows, this class
* expects a stream of user objects. To bridge that gap the
* RecursiveIteratorIterator is used to flatten and the EchoCallbackIterator
* is used to transform each database $row into a User object.
@@ -36,12 +36,12 @@ class EchoFilteredSequentialIterator implements IteratorAggregate {
/**
* @var Iterator[]
*/
- protected $iterators = array();
+ protected $iterators = [];
/**
* @var callable[]
*/
- protected $filters = array();
+ protected $filters = [];
/**
* @param Iterator|IteratorAggregate|array $users
@@ -87,15 +87,15 @@ class EchoFilteredSequentialIterator implements IteratorAggregate {
* @return Iterator
*/
protected function createIterator() {
- switch( count( $this->iterators ) ) {
- case 0:
- return new EmptyIterator;
+ switch ( count( $this->iterators ) ) {
+ case 0:
+ return new EmptyIterator;
- case 1:
- return reset( $this->iterators );
+ case 1:
+ return reset( $this->iterators );
- default:
- return new RecursiveIteratorIterator( new EchoMultipleIterator( $this->iterators ) );
+ default:
+ return new RecursiveIteratorIterator( new EchoMultipleIterator( $this->iterators ) );
}
}
@@ -103,23 +103,27 @@ class EchoFilteredSequentialIterator implements IteratorAggregate {
* @return callable
*/
protected function createFilter() {
- switch( count( $this->filters ) ) {
- case 0:
- return function() { return true; };
+ switch ( count( $this->filters ) ) {
+ case 0:
+ return function () {
+ return true;
+ };
- case 1:
- return reset( $this->filters );
+ case 1:
+ return reset( $this->filters );
- default:
- $filters = $this->filters;
- return function( $user ) use( $filters ) {
- foreach ( $filters as $filter ) {
- if ( !call_user_func( $filter, $user ) ) {
- return false;
+ default:
+ $filters = $this->filters;
+
+ return function ( $user ) use ( $filters ) {
+ foreach ( $filters as $filter ) {
+ if ( !call_user_func( $filter, $user ) ) {
+ return false;
+ }
}
- }
- return true;
- };
+
+ return true;
+ };
}
}
}
diff --git a/Echo/includes/iterator/MultipleIterator.php b/Echo/includes/iterator/MultipleIterator.php
index 4134326e..02d55d41 100644
--- a/Echo/includes/iterator/MultipleIterator.php
+++ b/Echo/includes/iterator/MultipleIterator.php
@@ -10,7 +10,7 @@
* * Lots less features(e.g. simple!)
*/
class EchoMultipleIterator implements RecursiveIterator {
- protected $active = array();
+ protected $active = [];
protected $children;
protected $key = 0;
@@ -44,10 +44,11 @@ class EchoMultipleIterator implements RecursiveIterator {
}
public function current() {
- $result = array();
+ $result = [];
foreach ( $this->active as $it ) {
$result[] = $it->current();
}
+
return $result;
}
diff --git a/Echo/includes/jobs/NotificationDeleteJob.php b/Echo/includes/jobs/NotificationDeleteJob.php
index ba7f36e3..2b6064ed 100644
--- a/Echo/includes/jobs/NotificationDeleteJob.php
+++ b/Echo/includes/jobs/NotificationDeleteJob.php
@@ -17,7 +17,7 @@ class EchoNotificationDeleteJob extends Job {
* UserIds to be processed
* @var int[]
*/
- protected $userIds = array();
+ protected $userIds = [];
/**
* @param Title $title
@@ -35,15 +35,16 @@ class EchoNotificationDeleteJob extends Job {
global $wgEchoMaxUpdateCount;
if ( count( $this->userIds ) > 1 ) {
// If there are multiple users, queue a single job for each one
- $jobs = array();
+ $jobs = [];
foreach ( $this->userIds as $userId ) {
- $jobs[] = new EchoNotificationDeleteJob( $this->title, array( 'userIds' => array( $userId ) ) );
+ $jobs[] = new EchoNotificationDeleteJob( $this->title, [ 'userIds' => [ $userId ] ] );
}
JobQueueGroup::singleton()->push( $jobs );
+
return true;
}
- $notifMapper = new EchoNotificationMapper();
+ $notifMapper = new EchoNotificationMapper();
$targetMapper = new EchoTargetPageMapper();
// Back-compat for older jobs which used array( $userId => $userId );
@@ -56,15 +57,11 @@ class EchoNotificationDeleteJob extends Job {
$user, $notif->getEvent()->getId()
);
if ( $res ) {
- $res = $targetMapper->deleteByUserEventOffset(
- $user, $notif->getEvent()->getId()
- );
- }
- if ( $res ) {
$notifUser = MWEchoNotifUser::newFromUser( $user );
$notifUser->resetNotificationCount( DB_MASTER );
}
}
+
return true;
}
diff --git a/Echo/includes/jobs/NotificationEmailBundleJob.php b/Echo/includes/jobs/NotificationEmailBundleJob.php
deleted file mode 100644
index fbb4ff89..00000000
--- a/Echo/includes/jobs/NotificationEmailBundleJob.php
+++ /dev/null
@@ -1,26 +0,0 @@
-<?php
-
-class MWEchoNotificationEmailBundleJob extends Job {
- function __construct( $title, $params ) {
- parent::__construct( __CLASS__, $title, $params );
- // If there is already a job with the same params, this job will be ignored
- // for example, if there is a page link bundle notification job for article A
- // created by user B, any subsequent jobs with the same data will be ignored
- $this->removeDuplicates = true;
- }
-
- function run() {
- $bundle = MWEchoEmailBundler::newFromUserHash(
- User::newFromId( $this->params['user_id'] ),
- $this->params['bundle_hash']
- );
-
- if ( $bundle ) {
- $bundle->processBundleEmail();
- } else {
- throw new MWException( 'Fail to create bundle object for: user_id: ' . $this->params['user_id'] . ', bundle_hash: ' . $this->params['bundle_hash'] );
- }
-
- return true;
- }
-}
diff --git a/Echo/includes/jobs/NotificationJob.php b/Echo/includes/jobs/NotificationJob.php
index af7e1df6..a736d3d3 100644
--- a/Echo/includes/jobs/NotificationJob.php
+++ b/Echo/includes/jobs/NotificationJob.php
@@ -17,21 +17,27 @@ class EchoNotificationJob extends Job {
MWEchoDbFactory::newFromDefault()->waitFor( $masterPos );
EchoNotificationController::notify( $this->event, false );
+
return true;
}
- // back compat detects masterPos from prior job params
+ /**
+ * back compat detects masterPos from prior job params
+ *
+ * @return array
+ */
function getMasterPosition() {
- $masterPos = array(
+ $masterPos = [
'wikiDb' => false,
'echoDb' => false,
- );
+ ];
if ( !empty( $this->params['mainDbMasterPos'] ) ) {
$masterPos['wikiDb'] = $this->params['mainDbMasterPos'];
}
if ( !empty( $this->params['echoDbMasterPos'] ) ) {
$masterPos['echoDb'] = $this->params['echoDbMasterPos'];
}
+
return $masterPos;
}
}
diff --git a/Echo/includes/mapper/AbstractMapper.php b/Echo/includes/mapper/AbstractMapper.php
index a69aa105..d9603e0d 100644
--- a/Echo/includes/mapper/AbstractMapper.php
+++ b/Echo/includes/mapper/AbstractMapper.php
@@ -18,7 +18,7 @@ abstract class EchoAbstractMapper {
protected $listeners;
/**
- * @param MWEchoDbFactory|null
+ * @param MWEchoDbFactory|null $dbFactory
*/
public function __construct( MWEchoDbFactory $dbFactory = null ) {
if ( $dbFactory === null ) {
@@ -33,13 +33,14 @@ abstract class EchoAbstractMapper {
* @param string $method Method name
* @param string $key Identification of the callable
* @param callable $callable
+ * @throws MWException
*/
public function attachListener( $method, $key, $callable ) {
if ( !method_exists( $this, $method ) ) {
throw new MWException( $method . ' does not exist in ' . get_class( $this ) );
}
if ( !isset( $this->listeners[$method] ) ) {
- $this->listeners[$method] = array();
+ $this->listeners[$method] = [];
}
$this->listeners[$method][$key] = $callable;
@@ -61,6 +62,7 @@ abstract class EchoAbstractMapper {
* Get the listener for a method
*
* @return array
+ * @throws MWException
*/
public function getMethodListeners( $method ) {
if ( !method_exists( $this, $method ) ) {
@@ -69,7 +71,7 @@ abstract class EchoAbstractMapper {
if ( isset( $this->listeners[$method] ) ) {
return $this->listeners[$method];
} else {
- return array();
+ return [];
}
}
diff --git a/Echo/includes/mapper/EventMapper.php b/Echo/includes/mapper/EventMapper.php
index e60999a1..e5c3bac7 100644
--- a/Echo/includes/mapper/EventMapper.php
+++ b/Echo/includes/mapper/EventMapper.php
@@ -9,25 +9,24 @@ class EchoEventMapper extends EchoAbstractMapper {
/**
* Insert an event record
*
- * @param EchoEvent
+ * @param EchoEvent $event
* @return int|bool
*/
public function insert( EchoEvent $event ) {
$dbw = $this->dbFactory->getEchoDb( DB_MASTER );
- $id = $dbw->nextSequenceValue( 'echo_event_id' );
-
$row = $event->toDbArray();
- if ( $id ) {
- $row['event_id'] = $id;
- }
$res = $dbw->insert( 'echo_event', $row, __METHOD__ );
if ( $res ) {
- if ( !$id ) {
- $id = $dbw->insertId();
+ $id = $dbw->insertId();
+
+ $listeners = $this->getMethodListeners( __FUNCTION__ );
+ foreach ( $listeners as $listener ) {
+ $dbw->onTransactionIdle( $listener );
}
+
return $id;
} else {
return false;
@@ -37,17 +36,18 @@ class EchoEventMapper extends EchoAbstractMapper {
/**
* Create an EchoEvent by id
*
- * @param int
- * @param boolean
- * @return EchoEvent
+ * @param int $id
+ * @param bool $fromMaster
+ * @return EchoEvent|bool false if it wouldn't load/unserialize
* @throws MWException
*/
public function fetchById( $id, $fromMaster = false ) {
$db = $fromMaster ? $this->dbFactory->getEchoDb( DB_MASTER ) : $this->dbFactory->getEchoDb( DB_SLAVE );
- $row = $db->selectRow( 'echo_event', '*', array( 'event_id' => $id ), __METHOD__ );
+ $row = $db->selectRow( 'echo_event', '*', [ 'event_id' => $id ], __METHOD__ );
- if ( !$row && !$fromMaster ) {
+ // If the row was not found, fall back on the master if it makes sense to do so
+ if ( !$row && !$fromMaster && $this->dbFactory->canRetryMaster() ) {
return $this->fetchById( $id, true );
} elseif ( !$row ) {
throw new MWException( "No EchoEvent found with ID: $id" );
@@ -57,64 +57,109 @@ class EchoEventMapper extends EchoAbstractMapper {
}
/**
- * Get a list of echo events identified by user and bundle hash
- *
- * @param $user User
- * @param $bundleHash string the bundle hash
- * @param $type string distribution type
- * @param $order string 'ASC'/'DESC'
- * @param $limit int
- * @return EchoEvent[]|bool
+ * @param int[] $eventIds
+ * @param bool $deleted
+ * @return bool|ResultWrapper
*/
- public function fetchByUserBundleHash( User $user, $bundleHash, $type = 'web', $order = 'DESC', $limit = 250 ) {
- $dbr = $this->dbFactory->getEchoDb( DB_SLAVE );
+ public function toggleDeleted( $eventIds, $deleted ) {
+ $dbw = $this->dbFactory->getEchoDb( DB_MASTER );
- // We only display 99+ if the number is over 100, we can do limit 250, this should
- // be sufficient to return 99 distinct group iterators, avoid select count( distinct )
- // for the following reason:
- // 1. it will not scale for large volume data
- // 2. notification may have random grouping iterator
- // 3. agent may be anonymous, can't do distinct over two columns: event_agent_id and event_agent_ip
- if ( $type == 'web' ) {
- $res = $dbr->select(
- array( 'echo_notification', 'echo_event' ),
- array( 'event_agent_id', 'event_agent_ip', 'event_extra',
- 'event_id', 'event_page_id', 'event_type', 'event_variant',
- 'notification_timestamp' ),
- array(
- 'notification_event=event_id',
- 'notification_user' => $user->getId(),
- 'notification_bundle_base' => 0,
- 'notification_bundle_display_hash' => $bundleHash
- ),
- __METHOD__,
- array( 'ORDER BY' => 'notification_timestamp ' . $order, 'LIMIT' => $limit )
- );
- // this would be email for now
- } else {
- $res = $dbr->select(
- array( 'echo_email_batch', 'echo_event' ),
- array( 'event_agent_id', 'event_agent_ip', 'event_extra',
- 'event_id', 'event_page_id', 'event_type', 'event_variant' ),
- array(
- 'eeb_event_id=event_id',
- 'eeb_user_id' => $user->getId(),
- 'eeb_event_hash' => $bundleHash
- ),
- __METHOD__,
- array( 'ORDER BY' => 'eeb_event_id ' . $order, 'LIMIT' => $limit )
- );
- }
+ $selectDeleted = $deleted ? 0 : 1;
+ $setDeleted = $deleted ? 1 : 0;
+ $res = $dbw->update(
+ 'echo_event',
+ [
+ 'event_deleted' => $setDeleted,
+ ],
+ [
+ 'event_deleted' => $selectDeleted,
+ 'event_id' => $eventIds,
+ ],
+ __METHOD__
+ );
+ return $res;
+ }
+
+ /**
+ * Fetch events associated with a page
+ *
+ * @param int $pageId
+ * @return EchoEvent[] Events
+ */
+ public function fetchByPage( $pageId ) {
+ $events = [];
+
+ $dbr = $this->dbFactory->getEchoDb( DB_SLAVE );
+ $res = $dbr->select(
+ [ 'echo_event', 'echo_target_page' ],
+ [ '*' ],
+ [
+ 'etp_page' => $pageId
+ ],
+ __METHOD__,
+ [ 'GROUP BY' => 'etp_event' ],
+ [ 'echo_target_page' => [ 'INNER JOIN', 'event_id=etp_event' ] ]
+ );
if ( $res ) {
- $data = array();
foreach ( $res as $row ) {
- $data[] = EchoEvent::newFromRow( $row );
+ $events[] = EchoEvent::newFromRow( $row );
}
- return $data;
- } else {
- return false;
}
+
+ return $events;
+ }
+
+ /**
+ * Fetch event IDs associated with a page
+ *
+ * @param int $pageId
+ * @return int[] Event IDs
+ */
+ public function fetchIdsByPage( $pageId ) {
+ $events = $this->fetchByPage( $pageId );
+ $eventIds = array_map(
+ function ( EchoEvent $event ) {
+ return $event->getId();
+ },
+ $events
+ );
+ return $eventIds;
+ }
+
+ /**
+ * Fetch events unread by a user and associated with a page
+ *
+ * @param User $user
+ * @param int $pageId
+ * @return EchoEvent[]
+ */
+ public function fetchUnreadByUserAndPage( User $user, $pageId ) {
+ $dbr = $this->dbFactory->getEchoDb( DB_SLAVE );
+
+ $res = $dbr->select(
+ [ 'echo_event', 'echo_notification', 'echo_target_page' ],
+ '*',
+ [
+ 'event_deleted' => 0,
+ 'notification_user' => $user->getId(),
+ 'notification_read_timestamp' => null,
+ 'etp_page' => $pageId,
+ ],
+ __METHOD__,
+ null,
+ [
+ 'echo_target_page' => [ 'INNER JOIN', 'etp_event=event_id' ],
+ 'echo_notification' => [ 'INNER JOIN', [ 'notification_event=event_id' ] ],
+ ]
+ );
+
+ $data = [];
+ foreach ( $res as $row ) {
+ $data[] = EchoEvent::newFromRow( $row );
+ }
+
+ return $data;
}
}
diff --git a/Echo/includes/mapper/NotificationMapper.php b/Echo/includes/mapper/NotificationMapper.php
index c43c2963..6ec00a9a 100644
--- a/Echo/includes/mapper/NotificationMapper.php
+++ b/Echo/includes/mapper/NotificationMapper.php
@@ -6,22 +6,6 @@
class EchoNotificationMapper extends EchoAbstractMapper {
/**
- * @var EchoTargetPageMapper
- */
- protected $targetPageMapper;
-
- public function __construct(
- MWEchoDbFactory $dbFactory = null,
- EchoTargetPageMapper $targetPageMapper = null
- ) {
- parent::__construct( $dbFactory );
- if ( $targetPageMapper === null ) {
- $targetPageMapper = new EchoTargetPageMapper( $this->dbFactory );
- }
- $this->targetPageMapper = $targetPageMapper;
- }
-
- /**
* Insert a notification record
* @param EchoNotification
* @return null
@@ -29,39 +13,41 @@ class EchoNotificationMapper extends EchoAbstractMapper {
public function insert( EchoNotification $notification ) {
$dbw = $this->dbFactory->getEchoDb( DB_MASTER );
- $fname = __METHOD__;
- $row = $notification->toDbArray();
$listeners = $this->getMethodListeners( __FUNCTION__ );
- $dbw->onTransactionIdle( function() use ( $dbw, $row, $fname, $listeners ) {
- $dbw->startAtomic( $fname );
- // 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',
- array( 'notification_bundle_base' => 0 ),
- array(
- '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 );
- $dbw->endAtomic( $fname );
+ $row = $notification->toDbArray();
+ DeferredUpdates::addUpdate( new AtomicSectionUpdate(
+ $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
+ );
+ }
- if ( $res ) {
- foreach ( $listeners as $listener ) {
- call_user_func( $listener );
+ $row['notification_timestamp'] =
+ $dbw->timestamp( $row['notification_timestamp'] );
+ $res = $dbw->insert( 'echo_notification', $row, $fname );
+ if ( $res ) {
+ foreach ( $listeners as $listener ) {
+ $dbw->onTransactionIdle( $listener );
+ }
}
}
- } );
+ ) );
}
/**
@@ -71,10 +57,10 @@ class EchoNotificationMapper extends EchoAbstractMapper {
* @return int[]
*/
protected function extractQueryOffset( $continue ) {
- $offset = array (
+ $offset = [
'timestamp' => 0,
'offset' => 0,
- );
+ ];
if ( $continue ) {
$values = explode( '|', $continue, 3 );
if ( count( $values ) !== 2 ) {
@@ -95,42 +81,48 @@ class EchoNotificationMapper extends EchoAbstractMapper {
* which is done via a deleteJob
* @param User $user
* @param int $limit
+ * @param string $continue Used for offset
* @param string[] $eventTypes
- * @param int $dbSource Use master or slave database to pull count
+ * @param Title[] $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
* @return EchoNotification[]
*/
- public function fetchUnreadByUser( User $user, $limit, array $eventTypes = array(), $dbSource = DB_SLAVE ) {
- $data = array();
-
- if ( !$eventTypes ) {
- return $data;
+ public function fetchUnreadByUser( User $user, $limit, $continue, array $eventTypes = [], array $titles = null, $dbSource = DB_SLAVE ) {
+ $conds['notification_read_timestamp'] = null;
+ if ( $titles ) {
+ $conds['event_page_id'] = $this->getIdsForTitles( $titles );
+ if ( !$conds['event_page_id'] ) {
+ return [];
+ }
}
+ return $this->fetchByUserInternal( $user, $limit, $continue, $eventTypes, $conds, $dbSource );
+ }
- $dbr = $this->dbFactory->getEchoDb( $dbSource );
- $res = $dbr->select(
- array( 'echo_notification', 'echo_event' ),
- '*',
- array(
- 'notification_user' => $user->getID(),
- 'event_type' => $eventTypes,
- 'notification_bundle_base' => 1,
- 'notification_read_timestamp' => NULL
- ),
- __METHOD__,
- array(
- 'LIMIT' => $limit,
- 'ORDER BY' => 'notification_timestamp DESC'
- ),
- array(
- 'echo_event' => array( 'LEFT JOIN', 'notification_event=event_id' ),
- )
- );
- if ( $res ) {
- foreach ( $res as $row ) {
- $data[$row->event_id] = EchoNotification::newFromRow( $row );
+ /**
+ * Get read notifications by user in the amount specified by limit order by
+ * notification timestamp in descending order. We have an index to retrieve
+ * unread notifications but it's not optimized for ordering by timestamp. The
+ * descending order is only allowed if we keep the notification in low volume,
+ * which is done via a deleteJob
+ * @param User $user
+ * @param int $limit
+ * @param string $continue Used for offset
+ * @param string[] $eventTypes
+ * @param Title[] $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
+ * @return EchoNotification[]
+ */
+ public function fetchReadByUser( User $user, $limit, $continue, array $eventTypes = [], array $titles = null, $dbSource = DB_SLAVE ) {
+ $conds = [ 'notification_read_timestamp IS NOT NULL' ];
+ if ( $titles ) {
+ $conds['event_page_id'] = $this->getIdsForTitles( $titles );
+ if ( !$conds['event_page_id'] ) {
+ return [];
}
}
- return $data;
+ return $this->fetchByUserInternal( $user, $limit, $continue, $eventTypes, $conds, $dbSource );
}
/**
@@ -141,32 +133,66 @@ class EchoNotificationMapper extends EchoAbstractMapper {
* @param string $continue Used for offset
* @param array $eventTypes Event types to load
* @param array $excludeEventIds Event id's to exclude.
+ * @param Title[] $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.
* @return EchoNotification[]
*/
- public function fetchByUser( User $user, $limit, $continue, array $eventTypes = array(), array $excludeEventIds = array() ) {
+ public function fetchByUser( User $user, $limit, $continue, array $eventTypes = [], array $excludeEventIds = [], array $titles = null ) {
$dbr = $this->dbFactory->getEchoDb( DB_SLAVE );
+ $conds = [];
+ if ( $excludeEventIds ) {
+ $conds[] = 'event_id NOT IN ( ' . $dbr->makeList( $excludeEventIds ) . ' ) ';
+ }
+ if ( $titles ) {
+ $conds['event_page_id'] = $this->getIdsForTitles( $titles );
+ if ( !$conds['event_page_id'] ) {
+ return [];
+ }
+ }
+
+ return $this->fetchByUserInternal( $user, $limit, $continue, $eventTypes, $conds );
+ }
+
+ protected function getIdsForTitles( array $titles ) {
+ $ids = [];
+ foreach ( $titles as $title ) {
+ if ( $title === null ) {
+ $ids[] = null;
+ } elseif ( $title->exists() ) {
+ $ids[] = $title->getArticleId();
+ }
+ }
+ return $ids;
+ }
+
+ /**
+ * @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 array $eventTypes Event types to load
+ * @param array $conds Additional query conditions.
+ * @param int $dbSource Use master or slave database
+ * @return EchoNotification[]
+ */
+ protected function fetchByUserInternal( User $user, $limit, $continue, array $eventTypes = [], array $conds = [], $dbSource = DB_SLAVE ) {
+ $dbr = $this->dbFactory->getEchoDb( $dbSource );
+
if ( !$eventTypes ) {
- return array();
+ return [];
}
// There is a problem with querying by event type, if a user has only one or none
- // flow notification and huge amount other notications, the lookup of only flow
+ // flow notification and huge amount other notifications, the lookup of only flow
// notification will result in a slow query. Luckily users won't have that many
// notifications. We should have some cron job to remove old notifications so
// 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.
- //
- // Look for notifications with base = 1
- $conds = array(
+ $conds = [
'notification_user' => $user->getID(),
'event_type' => $eventTypes,
- 'notification_bundle_base' => 1
- );
-
- if ( $excludeEventIds ) {
- $conds[] = 'event_id NOT IN ( ' . $dbr->makeList( $excludeEventIds ) . ' ) ';
- }
+ 'event_deleted' => 0,
+ ] + $conds;
$offset = $this->extractQueryOffset( $continue );
@@ -178,77 +204,68 @@ class EchoNotificationMapper extends EchoAbstractMapper {
}
$res = $dbr->select(
- array( 'echo_notification', 'echo_event', 'echo_target_page' ),
+ [ 'echo_notification', 'echo_event' ],
'*',
$conds,
__METHOD__,
- array(
+ [
'ORDER BY' => 'notification_timestamp DESC, notification_event DESC',
'LIMIT' => $limit,
- ),
- array(
- 'echo_event' => array( 'LEFT JOIN', 'notification_event=event_id' ),
- 'echo_target_page' => array( 'LEFT JOIN', array( 'notification_event=etp_event', 'notification_user=etp_user' ) ),
- )
+ ],
+ [
+ 'echo_event' => [ 'LEFT JOIN', 'notification_event=event_id' ],
+ ]
);
-
// query failure of some sort
if ( !$res ) {
- return array();
+ return [];
}
- $events = array();
+ $allNotifications = [];
foreach ( $res as $row ) {
- $events[$row->event_id] = $row;
- }
-
- // query returned no events
- if ( !$events ) {
- return array();
- }
-
- $targetPages = $this->targetPageMapper->fetchByUserPageId( $user, array_keys( $events ) );
-
- $data = array();
- foreach ( $events as $eventId => $row ) {
try {
- if ( isset( $targetPages[$row->event_id] ) ) {
- $targets = $targetPages[$row->event_id];
- } else {
- $targets = null;
+ $notification = EchoNotification::newFromRow( $row );
+ if ( $notification ) {
+ $allNotifications[] = $notification;
}
- $data[$row->event_id] = EchoNotification::newFromRow( $row, $targets );
} catch ( Exception $e ) {
$id = isset( $row->event_id ) ? $row->event_id : 'unknown event';
wfDebugLog( 'Echo', __METHOD__ . ": Failed initializing event: $id" );
MWExceptionHandler::logException( $e );
}
}
+
+ $data = [];
+ /** @var EchoNotification $notification */
+ foreach ( $allNotifications as $notification ) {
+ $data[ $notification->getEvent()->getId() ] = $notification;
+ }
+
return $data;
}
/**
* Get the last notification in a set of bundle-able notifications by a bundle hash
- * @param User
- * @param string The hash used to identify a set of bundle-able notifications
+ * @param User $user
+ * @param string $bundleHash The hash used to identify a set of bundle-able notifications
* @return EchoNotification|bool
*/
public function fetchNewestByUserBundleHash( User $user, $bundleHash ) {
$dbr = $this->dbFactory->getEchoDb( DB_SLAVE );
$row = $dbr->selectRow(
- array( 'echo_notification', 'echo_event' ),
- array( '*' ),
- array(
+ [ 'echo_notification', 'echo_event' ],
+ [ '*' ],
+ [
'notification_user' => $user->getId(),
'notification_bundle_hash' => $bundleHash
- ),
+ ],
__METHOD__,
- array( 'ORDER BY' => 'notification_timestamp DESC', 'LIMIT' => 1 ),
- array(
- 'echo_event' => array( 'LEFT JOIN', 'notification_event=event_id' ),
- )
+ [ 'ORDER BY' => 'notification_timestamp DESC', 'LIMIT' => 1 ],
+ [
+ 'echo_event' => [ 'LEFT JOIN', 'notification_event=event_id' ],
+ ]
);
if ( $row ) {
return EchoNotification::newFromRow( $row );
@@ -258,6 +275,41 @@ class EchoNotificationMapper extends EchoAbstractMapper {
}
/**
+ * Fetch EchoNotifications by user and event IDs.
+ *
+ * @param User $user
+ * @param int[] $eventIds
+ * @return EchoNotification[]|bool
+ */
+ public function fetchByUserEvents( User $user, $eventIds ) {
+ $dbr = $this->dbFactory->getEchoDb( DB_SLAVE );
+
+ $result = $dbr->select(
+ [ 'echo_notification', 'echo_event' ],
+ '*',
+ [
+ 'notification_user' => $user->getId(),
+ 'notification_event' => $eventIds
+ ],
+ __METHOD__,
+ [],
+ [
+ 'echo_event' => [ 'INNER JOIN', 'notification_event=event_id' ],
+ ]
+ );
+
+ if ( $result ) {
+ $notifications = [];
+ foreach ( $result as $row ) {
+ $notifications[] = EchoNotification::newFromRow( $row );
+ }
+ return $notifications;
+ } else {
+ return false;
+ }
+ }
+
+ /**
* Fetch a notification by user in the specified offset. The caller should
* know that passing a big number for offset is NOT going to work
* @param User $user
@@ -267,21 +319,21 @@ class EchoNotificationMapper extends EchoAbstractMapper {
public function fetchByUserOffset( User $user, $offset ) {
$dbr = $this->dbFactory->getEchoDb( DB_SLAVE );
$row = $dbr->selectRow(
- array( 'echo_notification', 'echo_event' ),
- array( '*' ),
- array(
+ [ 'echo_notification', 'echo_event' ],
+ [ '*' ],
+ [
'notification_user' => $user->getId(),
- 'notification_bundle_base' => 1
- ),
+ 'event_deleted' => 0,
+ ],
__METHOD__,
- array(
+ [
'ORDER BY' => 'notification_timestamp DESC, notification_event DESC',
'OFFSET' => $offset,
'LIMIT' => 1
- ),
- array(
- 'echo_event' => array( 'LEFT JOIN', 'notification_event=event_id' ),
- )
+ ],
+ [
+ 'echo_event' => [ 'LEFT JOIN', 'notification_event=event_id' ],
+ ]
);
if ( $row ) {
@@ -295,19 +347,49 @@ class EchoNotificationMapper extends EchoAbstractMapper {
* Batch delete notifications by user and eventId offset
* @param User $user
* @param int $eventId
- * @return boolean
+ * @return bool
*/
public function deleteByUserEventOffset( User $user, $eventId ) {
$dbw = $this->dbFactory->getEchoDb( DB_MASTER );
$res = $dbw->delete(
'echo_notification',
- array(
+ [
'notification_user' => $user->getId(),
'notification_event < ' . (int)$eventId
- ),
+ ],
__METHOD__
);
+
return $res;
}
+ /**
+ * Fetch ids of users that have notifications for certain events
+ *
+ * @param int[] $eventIds
+ * @return int[]|false
+ */
+ public function fetchUsersWithNotificationsForEvents( $eventIds ) {
+ $dbr = $this->dbFactory->getEchoDb( DB_SLAVE );
+
+ $res = $dbr->select(
+ [ 'echo_notification' ],
+ [ 'userId' => 'DISTINCT notification_user' ],
+ [
+ 'notification_event' => $eventIds
+ ],
+ __METHOD__
+ );
+
+ if ( $res ) {
+ $userIds = [];
+ foreach ( $res as $row ) {
+ $userIds[] = $row->userId;
+ }
+ return $userIds;
+ } else {
+ return false;
+ }
+ }
+
}
diff --git a/Echo/includes/mapper/TargetPageMapper.php b/Echo/includes/mapper/TargetPageMapper.php
index b4a2108d..33dd7bc5 100644
--- a/Echo/includes/mapper/TargetPageMapper.php
+++ b/Echo/includes/mapper/TargetPageMapper.php
@@ -9,49 +9,16 @@ class EchoTargetPageMapper extends EchoAbstractMapper {
* List of db fields used to construct an EchoTargetPage model
* @var string[]
*/
- protected static $fields = array(
- 'etp_user',
+ protected static $fields = [
'etp_page',
'etp_event'
- );
-
- /**
- * Fetch EchoTargetPage instances by user & page_id. The resulting
- * array is indexed by the event id. Each entry contains an array
- * of EchoTargetPage instances.
- *
- * @param User $user
- * @param int|int[] $pageId One or more page ids to fetch target pages of
- * @return EchoTargetPage[][]|boolean
- */
- public function fetchByUserPageId( User $user, $pageId ) {
- $dbr = $this->dbFactory->getEchoDb( DB_SLAVE );
-
- $res = $dbr->select(
- array( 'echo_target_page' ),
- self::$fields,
- array(
- 'etp_user' => $user->getId(),
- 'etp_page' => $pageId
- ),
- __METHOD__
- );
- if ( $res ) {
- $targetPages = array();
- foreach ( $res as $row ) {
- $targetPages[$row->etp_event][] = EchoTargetPage::newFromRow( $row );
- }
- return $targetPages;
- } else {
- return false;
- }
- }
+ ];
/**
* Insert an EchoTargetPage instance into the database
*
* @param EchoTargetPage $targetPage
- * @return boolean
+ * @return bool
*/
public function insert( EchoTargetPage $targetPage ) {
$dbw = $this->dbFactory->getEchoDb( DB_MASTER );
@@ -62,91 +29,4 @@ class EchoTargetPageMapper extends EchoAbstractMapper {
return $res;
}
-
- /**
- * Delete an EchoTargetPage instance from the database
- *
- * @param EchoTargetPage
- * @return boolean
- */
- public function delete( EchoTargetPage $targetPage ) {
- $dbw = $this->dbFactory->getEchoDb( DB_MASTER );
-
- $res = $dbw->delete(
- 'echo_target_page',
- array(
- 'etp_user' => $targetPage->getUser()->getId(),
- 'etp_page' => $targetPage->getPageId(),
- 'etp_event' => $targetPage->getEventId()
- ),
- __METHOD__
- );
- return $res;
- }
-
- /**
- * Delete multiple EchoTargetPage records by user & set of event_id
- *
- * @param User $user
- * @param int[] $eventIds
- * @return boolean
- */
- public function deleteByUserEvents( User $user, array $eventIds ) {
- if ( !$eventIds ) {
- return true;
- }
-
- $dbw = $this->dbFactory->getEchoDb( DB_MASTER );
-
- $res = $dbw->delete(
- 'echo_target_page',
- array(
- 'etp_user' => $user->getId(),
- 'etp_event' => $eventIds
- ),
- __METHOD__
- );
- return $res;
- }
-
- /**
- * Delete multiple EchoTargetPage records by user & event_id offset
- *
- * @param User $user
- * @param int $eventId
- * @return boolean
- */
- public function deleteByUserEventOffset( User $user, $eventId ) {
- $dbw = $this->dbFactory->getEchoDb( DB_MASTER );
-
- $res = $dbw->delete(
- 'echo_target_page',
- array(
- 'etp_user' => $user->getId(),
- 'etp_event < ' . (int)$eventId
- ),
- __METHOD__
- );
- return $res;
- }
-
- /**
- * Delete multiple EchoTargetPage records by user
- *
- * @param User $user
- * @return boolean
- */
- public function deleteByUser( User $user ) {
- $dbw = $this->dbFactory->getEchoDb( DB_MASTER );
-
- $res = $dbw->delete(
- 'echo_target_page',
- array(
- 'etp_user' => $user->getId()
- ),
- __METHOD__
- );
- return $res;
- }
-
}
diff --git a/Echo/includes/model/Event.php b/Echo/includes/model/Event.php
index 7d4d0cd6..ecc0941a 100644
--- a/Echo/includes/model/Event.php
+++ b/Echo/includes/model/Event.php
@@ -1,10 +1,12 @@
<?php
+use MediaWiki\Logger\LoggerFactory;
+
/**
* Immutable class to represent an event.
* In Echo nomenclature, an event is a single occurrence.
*/
-class EchoEvent extends EchoAbstractEntity{
+class EchoEvent extends EchoAbstractEntity implements Bundleable {
protected $type = null;
protected $id = null;
@@ -29,7 +31,7 @@ class EchoEvent extends EchoAbstractEntity{
*/
protected $revision = null;
- protected $extra = array();
+ protected $extra = [];
/**
* Notification timestamp
@@ -45,20 +47,36 @@ class EchoEvent extends EchoAbstractEntity{
protected $bundleHash;
/**
+ * Other events bundled with this one
+ *
+ * @var EchoEvent[]
+ */
+ protected $bundledEvents;
+
+ /**
+ * Deletion flag
+ *
+ * @var int
+ */
+ protected $deleted = 0;
+
+ /**
* You should not call the constructor.
* Instead use one of the factory functions:
* EchoEvent::create To create a new event
* EchoEvent::newFromRow To create an event object from a row object
* EchoEvent::newFromID To create an event object from the database given its ID
*/
- protected function __construct() {}
+ protected function __construct() {
+ }
## Save the id and timestamp
function __sleep() {
if ( !$this->id ) {
throw new MWException( "Unable to serialize an uninitialized EchoEvent" );
}
- return array( 'id', 'timestamp' );
+
+ return [ 'id', 'timestamp' ];
}
function __wakeup() {
@@ -71,7 +89,7 @@ class EchoEvent extends EchoAbstractEntity{
/**
* Creates an EchoEvent object
- * @param $info array Named arguments:
+ * @param array $info Named arguments:
* type (required): The event type;
* variant: A variant of the type;
* agent: The user who caused the event;
@@ -79,18 +97,20 @@ class EchoEvent extends EchoAbstractEntity{
* extra: Event-specific extra information (e.g. post content)
*
* @throws MWException
- * @return EchoEvent|bool false if aborted via hook
+ * @return EchoEvent|bool false if aborted via hook or Echo DB is read-only
*/
- public static function create( $info = array() ) {
+ public static function create( $info = [] ) {
global $wgEchoNotifications;
// Do not create event and notifications if write access is locked
- if ( wfReadOnly() ) {
- throw new ReadOnlyError();
+ if ( wfReadOnly()
+ || MWEchoDbFactory::newFromDefault()->getEchoDb( DB_MASTER )->isReadOnly()
+ ) {
+ return false;
}
$obj = new EchoEvent;
- static $validFields = array( 'type', 'variant', 'agent', 'title', 'extra' );
+ static $validFields = [ 'type', 'variant', 'agent', 'title', 'extra' ];
if ( empty( $info['type'] ) ) {
throw new MWException( "'type' parameter is mandatory" );
@@ -119,6 +139,7 @@ class EchoEvent extends EchoAbstractEntity{
// event_extra.
if ( strlen( $obj->serializeExtra() ) > 50000 ) {
wfDebugLog( __CLASS__, __FUNCTION__ . ': event extra data is too huge for ' . $info['type'] );
+
return false;
}
@@ -130,24 +151,24 @@ class EchoEvent extends EchoAbstractEntity{
}
if ( $obj->agent && !
- ( $obj->agent instanceof User ||
- $obj->agent instanceof StubObject )
+ ( $obj->agent instanceof User ||
+ $obj->agent instanceof StubObject )
) {
throw new MWException( "Invalid user parameter" );
}
- if ( !Hooks::run( 'BeforeEchoEventInsert', array( $obj ) ) ) {
+ if ( !Hooks::run( 'BeforeEchoEventInsert', [ $obj ] ) ) {
return false;
}
- //@Todo - Database insert logic should not be inside the model
+ // @Todo - Database insert logic should not be inside the model
$obj->insert();
- Hooks::run( 'EchoEventInsertComplete', array( $obj ) );
+ Hooks::run( 'EchoEventInsertComplete', [ $obj ] );
global $wgEchoUseJobQueue;
- EchoNotificationController::notify( $obj, $wgEchoUseJobQueue );
+ EchoNotificationController::notify( $obj, $wgEchoUseJobQueue );
return $obj;
}
@@ -157,11 +178,12 @@ class EchoEvent extends EchoAbstractEntity{
* @return array
*/
public function toDbArray() {
- $data = array (
+ $data = [
'event_type' => $this->type,
'event_variant' => $this->variant,
+ 'event_deleted' => $this->deleted,
'event_extra' => $this->serializeExtra()
- );
+ ];
if ( $this->id ) {
$data['event_id'] = $this->id;
}
@@ -183,6 +205,7 @@ class EchoEvent extends EchoAbstractEntity{
$data['event_page_id'] = $pageId;
}
}
+
return $data;
}
@@ -205,12 +228,51 @@ class EchoEvent extends EchoAbstractEntity{
protected function insert() {
$eventMapper = new EchoEventMapper();
$this->id = $eventMapper->insert( $this );
+
+ $targetPages = self::resolveTargetPages( $this->getExtraParam( 'target-page' ) );
+ if ( $targetPages ) {
+ $targetMapper = new EchoTargetPageMapper();
+ foreach ( $targetPages as $title ) {
+ $targetPage = EchoTargetPage::create( $title, $this );
+ if ( $targetPage ) {
+ $targetMapper->insert( $targetPage );
+ }
+ }
+ }
+ }
+
+ /**
+ * @param int[]|int|false $targetPageIds
+ * @return Title[]
+ */
+ protected static function resolveTargetPages( $targetPageIds ) {
+ if ( !$targetPageIds ) {
+ return [];
+ }
+ if ( !is_array( $targetPageIds ) ) {
+ $targetPageIds = [ $targetPageIds ];
+ }
+ $result = [];
+ foreach ( $targetPageIds as $targetPageId ) {
+ // Make sure the target-page id is a valid id
+ $title = Title::newFromID( $targetPageId );
+ // Try master if there is no match
+ if ( !$title ) {
+ $title = Title::newFromID( $targetPageId, Title::GAID_FOR_UPDATE );
+ }
+ if ( $title ) {
+ $result[] = $title;
+ }
+ }
+
+ return $result;
}
/**
* Loads data from the provided $row into this object.
*
- * @param $row stdClass row object from echo_event
+ * @param stdClass $row row object from echo_event
+ * @return bool Whether loading was successful
*/
public function loadFromRow( $row ) {
$this->id = $row->event_id;
@@ -226,8 +288,20 @@ class EchoEvent extends EchoAbstractEntity{
}
$this->variant = $row->event_variant;
- $this->extra = $row->event_extra ? unserialize( $row->event_extra ) : array();
+ try {
+ $this->extra = $row->event_extra ? unserialize( $row->event_extra ) : [];
+ } catch ( Exception $e ) {
+ // T73489: unserializing can fail for old notifications
+ LoggerFactory::getInstance( 'Echo' )->warning(
+ 'Failed to unserialize event {id}',
+ [
+ 'id' => $row->event_id
+ ]
+ );
+ return false;
+ }
$this->pageId = $row->event_page_id;
+ $this->deleted = $row->event_deleted;
if ( $row->event_agent_id ) {
$this->agent = User::newFromID( $row->event_agent_id );
@@ -253,16 +327,22 @@ class EchoEvent extends EchoAbstractEntity{
$revisionCache = EchoRevisionLocalCache::create();
$revisionCache->add( $this->extra['revid'] );
}
+
+ return true;
}
/**
* Loads data from the database into this object, given the event ID.
- * @param $id int Event ID
- * @param $fromMaster bool
+ * @param int $id Event ID
+ * @param bool $fromMaster
+ * @return bool Whether it loaded successfully
*/
public function loadFromID( $id, $fromMaster = false ) {
$eventMapper = new EchoEventMapper();
$event = $eventMapper->fetchById( $id, $fromMaster );
+ if ( !$event ) {
+ return false;
+ }
// Copy over the attribute
$this->id = $event->id;
@@ -272,34 +352,39 @@ class EchoEvent extends EchoAbstractEntity{
$this->pageId = $event->pageId;
$this->agent = $event->agent;
$this->title = $event->title;
+ $this->deleted = $event->deleted;
// Don't overwrite timestamp if it exists already
if ( !$this->timestamp ) {
$this->timestamp = $event->timestamp;
}
+
+ return true;
}
/**
* Creates an EchoEvent from a row object
*
- * @param $row stdClass row object from echo_event
- * @return EchoEvent object.
+ * @param stdClass $row row object from echo_event
+ * @return EchoEvent|bool
*/
public static function newFromRow( $row ) {
$obj = new EchoEvent();
- $obj->loadFromRow( $row );
- return $obj;
+ return $obj->loadFromRow( $row )
+ ? $obj
+ : false;
}
/**
* Creates an EchoEvent from the database by ID
*
- * @param $id int Event ID
- * @return EchoEvent
+ * @param int $id Event ID
+ * @return EchoEvent|bool
*/
public static function newFromID( $id ) {
$obj = new EchoEvent();
- $obj->loadFromID( $id );
- return $obj;
+ return $obj->loadFromID( $id )
+ ? $obj
+ : false;
}
/**
@@ -312,7 +397,7 @@ class EchoEvent extends EchoAbstractEntity{
} elseif ( is_null( $this->extra ) ) {
$extra = null;
} else {
- $extra = serialize( array( $this->extra ) );
+ $extra = serialize( [ $this->extra ] );
}
return $extra;
@@ -331,9 +416,9 @@ class EchoEvent extends EchoAbstractEntity{
if ( isset( $wgEchoNotificationCategories[$category]['no-dismiss'] ) ) {
$noDismiss = $wgEchoNotificationCategories[$category]['no-dismiss'];
} else {
- $noDismiss = array();
+ $noDismiss = [];
}
- if ( !in_array( $distribution, $noDismiss ) && !in_array( 'all' , $noDismiss ) ) {
+ if ( !in_array( $distribution, $noDismiss ) && !in_array( 'all', $noDismiss ) ) {
return true;
} else {
return false;
@@ -345,13 +430,13 @@ class EchoEvent extends EchoAbstractEntity{
* field of this revision, if it's marked as deleted. When no
* revision is attached always returns true.
*
- * @param $field Integer:one of Revision::DELETED_TEXT,
+ * @param int $field One of Revision::DELETED_TEXT,
* Revision::DELETED_COMMENT,
* Revision::DELETED_USER
- * @param $user User object to check, or null to use $wgUser
- * @return Boolean
+ * @param User $user User object to check
+ * @return bool
*/
- public function userCan( $field, User $user = null ) {
+ public function userCan( $field, User $user ) {
$revision = $this->getRevision();
// User is handled specially
if ( $field === Revision::DELETED_USER ) {
@@ -366,11 +451,6 @@ class EchoEvent extends EchoAbstractEntity{
return $revision->userCan( $field, $user );
} else {
// Use User::isHidden()
- if ( !$user ) {
- // @FIXME Require a user object for this function
- global $wgUser;
- $user = $wgUser;
- }
return $user->isAllowedAny( 'viewsuppressed', 'hideuser' ) || !$agent->isHidden();
}
} elseif ( $revision ) {
@@ -430,9 +510,10 @@ class EchoEvent extends EchoAbstractEntity{
}
/**
- * @return Title|null
+ * @param bool $fromMaster
+ * @return null|Title
*/
- public function getTitle() {
+ public function getTitle( $fromMaster = false ) {
if ( $this->title ) {
return $this->title;
} elseif ( $this->pageId ) {
@@ -441,13 +522,15 @@ class EchoEvent extends EchoAbstractEntity{
if ( $title ) {
return $this->title = $title;
}
- return $this->title = Title::newFromId( $this->pageId );
+
+ return $this->title = Title::newFromID( $this->pageId, $fromMaster ? Title::GAID_FOR_UPDATE : 0 );
} elseif ( isset( $this->extra['page_title'], $this->extra['page_namespace'] ) ) {
return $this->title = Title::makeTitleSafe(
$this->extra['page_namespace'],
$this->extra['page_title']
);
}
+
return null;
}
@@ -463,8 +546,10 @@ class EchoEvent extends EchoAbstractEntity{
if ( $revision ) {
return $this->revision = $revision;
}
+
return $this->revision = Revision::newFromId( $this->extra['revid'] );
}
+
return null;
}
@@ -474,6 +559,7 @@ class EchoEvent extends EchoAbstractEntity{
*/
public function getCategory() {
$attributeManager = EchoAttributeManager::newFromGlobalVars();
+
return $attributeManager->getNotificationCategory( $this->type );
}
@@ -483,18 +569,20 @@ class EchoEvent extends EchoAbstractEntity{
*/
public function getSection() {
$attributeManager = EchoAttributeManager::newFromGlobalVars();
+
return $attributeManager->getNotificationSection( $this->type );
}
/**
* Determine whether an event can use the job queue, or should be immediate
- * @return boolean
+ * @return bool
*/
public function getUseJobQueue() {
global $wgEchoNotifications;
if ( isset( $wgEchoNotifications[$this->type]['immediate'] ) ) {
return !(bool)$wgEchoNotifications[$this->type]['immediate'];
}
+
return true;
}
@@ -512,14 +600,13 @@ class EchoEvent extends EchoAbstractEntity{
public function setTitle( Title $title ) {
$this->title = $title;
- $pageId = $title->getArticleId();
+ $pageId = $title->getArticleID();
if ( $pageId ) {
$this->pageId = $pageId;
} else {
- $this->extra['page_title'] = $title->getDBKey();
+ $this->extra['page_title'] = $title->getDBkey();
$this->extra['page_namespace'] = $title->getNamespace();
}
-
}
public function setExtra( $name, $value ) {
@@ -529,30 +616,32 @@ class EchoEvent extends EchoAbstractEntity{
/**
* Get the message key of the primary or secondary link for a notification type.
*
- * @param $rank String 'primary' or 'secondary'
+ * @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'];
+ if ( isset( $wgEchoNotifications[$type][$rank . '-link']['message'] ) ) {
+ return $wgEchoNotifications[$type][$rank . '-link']['message'];
}
+
return '';
}
/**
* Get the link destination of the primary or secondary link for a notification type.
*
- * @param $rank String 'primary' or 'secondary'
+ * @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'];
+ if ( isset( $wgEchoNotifications[$type][$rank . '-link']['destination'] ) ) {
+ return $wgEchoNotifications[$type][$rank . '-link']['destination'];
}
+
return '';
}
@@ -564,9 +653,52 @@ class EchoEvent extends EchoAbstractEntity{
}
/**
- * @param $hash string
+ * @param string $hash
*/
public function setBundleHash( $hash ) {
$this->bundleHash = $hash;
}
+
+ /**
+ * @return bool
+ */
+ public function isDeleted() {
+ return $this->deleted === 1;
+ }
+
+ public function setBundledEvents( $events ) {
+ $this->bundledEvents = $events;
+ }
+
+ public function getBundledEvents() {
+ return $this->bundledEvents;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function canBeBundled() {
+ return true;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getBundlingKey() {
+ return $this->getBundleHash();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setBundledElements( $bundleables ) {
+ $this->setBundledEvents( $bundleables );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getSortingKey() {
+ return $this->getTimestamp();
+ }
}
diff --git a/Echo/includes/model/Notification.php b/Echo/includes/model/Notification.php
index c538495b..4c5f338d 100644
--- a/Echo/includes/model/Notification.php
+++ b/Echo/includes/model/Notification.php
@@ -1,6 +1,6 @@
<?php
-class EchoNotification extends EchoAbstractEntity {
+class EchoNotification extends EchoAbstractEntity implements Bundleable {
/**
* @var User
@@ -50,13 +50,19 @@ class EchoNotification extends EchoAbstractEntity {
protected $bundleDisplayHash = '';
/**
+ * @var EchoNotification[]
+ */
+ protected $bundledNotifications;
+
+ /**
* Do not use this constructor.
*/
- protected function __construct() {}
+ protected function __construct() {
+ }
/**
* Creates an EchoNotification object based on event and user
- * @param $info array The following keys are required:
+ * @param array $info The following keys are required:
* - 'event' The EchoEvent being notified about.
* - 'user' The User being notified.
* @throws MWException
@@ -64,7 +70,7 @@ class EchoNotification extends EchoAbstractEntity {
*/
public static function create( array $info ) {
$obj = new EchoNotification();
- static $validFields = array( 'event', 'user' );
+ static $validFields = [ 'event', 'user' ];
foreach ( $validFields as $field ) {
if ( isset( $info[$field] ) ) {
@@ -89,7 +95,7 @@ class EchoNotification extends EchoAbstractEntity {
$obj->timestamp = wfTimestampNow();
}
- //@Todo - Database insert logic should not be inside the model
+ // @Todo - Database insert logic should not be inside the model
$obj->insert();
return $obj;
@@ -106,13 +112,9 @@ class EchoNotification extends EchoAbstractEntity {
// Get the bundle key for this event if web bundling is enabled
$bundleKey = '';
if ( !empty( $wgEchoNotifications[$this->event->getType()]['bundle']['web'] ) ) {
- Hooks::run( 'EchoGetBundleRules', array( $this->event, &$bundleKey ) );
+ Hooks::run( 'EchoGetBundleRules', [ $this->event, &$bundleKey ] );
}
- // The list of event ids to be removed from echo_target_page,
- // this is mainly for bundled notifications when an event is
- // no longer the bundle base
- $eventIds = array();
if ( $bundleKey ) {
$hash = md5( $bundleKey );
$this->bundleHash = $hash;
@@ -123,86 +125,35 @@ class EchoNotification extends EchoAbstractEntity {
// 2. last bundle notification with the same hash was read
if ( $lastNotif && !$lastNotif->getReadTimestamp() ) {
$this->bundleDisplayHash = $lastNotif->getBundleDisplayHash();
- $lastEvent = $lastNotif->getEvent();
- if ( $lastEvent ) {
- $eventIds[] = $lastEvent->getId();
- }
} else {
$this->bundleDisplayHash = md5( $bundleKey . '-display-hash-' . wfTimestampNow() );
}
}
- // Create a target page object if specified by event
- $event = $this->event;
- $user = $this->user;
- $targetPages = self::resolveTargetPages( $event->getExtraParam( 'target-page' ) );
- if ( $targetPages ) {
- $notifMapper->attachListener( 'insert', 'add-target-page', function() use ( $event, $user, $eventIds, $targetPages ) {
- $targetMapper = new EchoTargetPageMapper();
- if ( $eventIds ) {
- $targetMapper->deleteByUserEvents( $user, $eventIds );
- }
- foreach ( $targetPages as $title ) {
- $targetPage = EchoTargetPage::create( $user, $title, $event );
- if ( $targetPage ) {
- $targetMapper->insert( $targetPage );
- }
- }
- } );
- }
-
- $notifUser = MWEchoNotifUser::newFromUser( $user );
+ $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, $section ) {
$notifUser->resetNotificationCount( DB_MASTER );
- if ( $section === EchoAttributeManager::MESSAGE && !$notifUser->hasMessages() ) {
- $notifUser->cacheHasMessages();
- }
}
);
$notifMapper->insert( $this );
- if ( $event->getType() === 'edit-user-talk' ) {
+ if ( $this->event->getCategory() === 'edit-user-talk' ) {
$notifUser->flagCacheWithNewTalkNotification();
+ $this->user->setNewtalk( true );
}
- Hooks::run( 'EchoCreateNotificationComplete', array( $this ) );
- }
-
- /**
- * @param int[]|int|false $targetPageIds
- * @return Title[]
- */
- protected static function resolveTargetPages( $targetPageIds ) {
- if ( !$targetPageIds ) {
- return array();
- }
- if ( !is_array( $targetPageIds ) ) {
- $targetPageIds = array( $targetPageIds );
- }
- $result = array();
- foreach ( $targetPageIds as $targetPageId ) {
- // Make sure the target-page id is a valid id
- $title = Title::newFromID( $targetPageId );
- // Try master if there is no match
- if ( !$title ) {
- $title = Title::newFromID( $targetPageId, Title::GAID_FOR_UPDATE );
- }
- if ( $title ) {
- $result[] = $title;
- }
- }
- return $result;
+ Hooks::run( 'EchoCreateNotificationComplete', [ $this ] );
}
/**
* Load a notification record from std class
- * @param stdClass
- * @param EchoTargetPage[]|null An array of EchoTargetPage instances, or null if not loaded.
- * @return EchoNotification
+ * @param stdClass $row
+ * @param EchoTargetPage[]|null $targetPages An array of EchoTargetPage instances, or null if not loaded.
+ * @return EchoNotification|bool false if failed to load/unserialize
*/
public static function newFromRow( $row, $targetPages = null ) {
$notification = new EchoNotification();
@@ -213,6 +164,10 @@ class EchoNotification extends EchoAbstractEntity {
$notification->event = EchoEvent::newFromID( $row->notification_event );
}
+ if ( $notification->event === false ) {
+ return false;
+ }
+
$notification->targetPages = $targetPages;
$notification->user = User::newFromId( $row->notification_user );
// Notification timestamp should never be empty
@@ -225,6 +180,7 @@ class EchoNotification extends EchoAbstractEntity {
$notification->bundleBase = $row->notification_bundle_base;
$notification->bundleHash = $row->notification_bundle_hash;
$notification->bundleDisplayHash = $row->notification_bundle_display_hash;
+
return $notification;
}
@@ -233,7 +189,7 @@ class EchoNotification extends EchoAbstractEntity {
* @return array
*/
public function toDbArray() {
- return array(
+ return [
'notification_event' => $this->event->getId(),
'notification_user' => $this->user->getId(),
'notification_timestamp' => $this->timestamp,
@@ -241,7 +197,7 @@ class EchoNotification extends EchoAbstractEntity {
'notification_bundle_base' => $this->bundleBase,
'notification_bundle_hash' => $this->bundleHash,
'notification_bundle_display_hash' => $this->bundleDisplayHash
- );
+ ];
}
/**
@@ -276,6 +232,10 @@ class EchoNotification extends EchoAbstractEntity {
return $this->readTimestamp;
}
+ public function isRead() {
+ return $this->getReadTimestamp() !== null;
+ }
+
/**
* Getter method
* @return int Notification bundle base
@@ -309,4 +269,40 @@ class EchoNotification extends EchoAbstractEntity {
public function getTargetPages() {
return $this->targetPages;
}
+
+ public function setBundledNotifications( $notifications ) {
+ $this->bundledNotifications = $notifications;
+ }
+
+ public function getBundledNotifications() {
+ return $this->bundledNotifications;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function canBeBundled() {
+ return !$this->isRead();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getBundlingKey() {
+ return $this->getBundleHash();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setBundledElements( $bundleables ) {
+ $this->setBundledNotifications( $bundleables );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getSortingKey() {
+ return ( $this->isRead() ? '0' : '1' ) . '_' . $this->getTimestamp();
+ }
}
diff --git a/Echo/includes/model/TargetPage.php b/Echo/includes/model/TargetPage.php
index cc846605..2b371977 100644
--- a/Echo/includes/model/TargetPage.php
+++ b/Echo/includes/model/TargetPage.php
@@ -1,21 +1,16 @@
<?php
/**
- * Map a title to an echo notification so that we can mark a notification as read
+ * Map a title to an echo event so that we can mark a notification as read
* when visiting the page. This only supports titles with ids because majority
* of notifications have page_id and searching by namespace and title is slow
*/
class EchoTargetPage extends EchoAbstractEntity {
/**
- * @var User
+ * @var Title|null|bool false if not initialized yet
*/
- protected $user;
-
- /**
- * @var Title|null
- */
- protected $title;
+ protected $title = false;
/**
* @var int
@@ -33,29 +28,35 @@ class EchoTargetPage extends EchoAbstractEntity {
protected $eventId;
/**
+ * @var string
+ */
+ protected $eventType;
+
+ /**
* Only allow creating instance internally
*/
- protected function __construct() {}
+ protected function __construct() {
+ }
/**
- * Create a EchoTargetPage instance from User, Title and EchoEvent
+ * Create a EchoTargetPage instance from Title and EchoEvent
*
- * @param User $user
* @param Title $title
* @param EchoEvent $event
- * @return TargetPage|null
+ * @return EchoTargetPage|null
*/
- public static function create( User $user, Title $title, EchoEvent $event ) {
+ public static function create( Title $title, EchoEvent $event ) {
// This only support title with a page_id
- if ( $user->isAnon() || !$title->getArticleID() ) {
+ if ( !$title->getArticleID() ) {
return null;
}
$obj = new self();
- $obj->user = $user;
$obj->event = $event;
$obj->eventId = $event->getId();
+ $obj->eventType = $event->getType();
$obj->title = $title;
$obj->pageId = $title->getArticleID();
+
return $obj;
}
@@ -67,37 +68,33 @@ class EchoTargetPage extends EchoAbstractEntity {
* @throws MWException
*/
public static function newFromRow( $row ) {
- $requiredFields = array (
- 'etp_user',
+ $requiredFields = [
'etp_page',
'etp_event'
- );
+ ];
foreach ( $requiredFields as $field ) {
if ( !isset( $row->$field ) || !$row->$field ) {
throw new MWException( $field . ' is not set in the row!' );
}
}
$obj = new self();
- $obj->user = User::newFromId( $row->etp_user );
$obj->pageId = $row->etp_page;
$obj->eventId = $row->etp_event;
- return $obj;
- }
+ if ( isset( $row->event_type ) ) {
+ $obj->eventType = $row->event_type;
+ }
- /**
- * @return User
- */
- public function getUser() {
- return $this->user;
+ return $obj;
}
/**
* @return Title|null
*/
public function getTitle() {
- if ( !$this->title ) {
+ if ( $this->title === false ) {
$this->title = Title::newFromId( $this->pageId );
}
+
return $this->title;
}
@@ -115,6 +112,7 @@ class EchoTargetPage extends EchoAbstractEntity {
if ( !$this->event ) {
$this->event = EchoEvent::newFromID( $this->eventId );
}
+
return $this->event;
}
@@ -126,14 +124,24 @@ class EchoTargetPage extends EchoAbstractEntity {
}
/**
+ * @return string
+ */
+ public function getEventType() {
+ if ( !$this->eventType ) {
+ $this->eventType = $this->getEvent()->getType();
+ }
+
+ return $this->eventType;
+ }
+
+ /**
* Convert the properties to a database row
* @return array
*/
public function toDbArray() {
- return array (
- 'etp_user' => $this->user->getId(),
+ return [
'etp_page' => $this->pageId,
'etp_event' => $this->eventId
- );
+ ];
}
}
diff --git a/Echo/includes/ooui/LabelIconWidget.php b/Echo/includes/ooui/LabelIconWidget.php
new file mode 100644
index 00000000..e44e8ff9
--- /dev/null
+++ b/Echo/includes/ooui/LabelIconWidget.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace EchoOOUI;
+
+use OOUI\IconElement;
+use OOUI\LabelElement;
+use OOUI\TitledElement;
+use OOUI\Tag;
+use OOUI\Widget;
+
+/**
+ * Widget combining a label and icon
+ */
+class LabelIconWidget extends Widget {
+ use IconElement;
+ use LabelElement;
+ use TitledElement;
+
+ /**
+ * @param array $config Configuration options
+ * @param string|HtmlSnippet $config['label'] Label text
+ * @param string $config['title'] Title text
+ * @param string $config['icon'] Icon key
+ */
+ public function __construct( $config ) {
+ parent::__construct( $config );
+
+ $this->tableRow = new Tag( 'div' );
+ $this->tableRow->setAttributes( [
+ 'class' => 'oo-ui-labelIconWidget-row',
+ ] );
+
+ $this->icon = new Tag( 'div' );
+ $this->label = new Tag( 'div' );
+
+ $this->initializeIconElement( array_merge( $config, [ 'iconElement' => $this->icon ] ) );
+ $this->initializeLabelElement( array_merge( $config, [ 'labelElement' => $this->label ] ) );
+ $this->initializeTitledElement( $config );
+
+ $this->addClasses( [ 'oo-ui-labelIconWidget' ] );
+ $this->tableRow->appendContent( $this->icon, $this->label );
+ $this->appendContent( $this->tableRow );
+ }
+}
diff --git a/Echo/includes/schemaUpdate.php b/Echo/includes/schemaUpdate.php
index d8b62dec..4bc58daa 100644
--- a/Echo/includes/schemaUpdate.php
+++ b/Echo/includes/schemaUpdate.php
@@ -5,15 +5,14 @@
* Updates event_page_id based on event_page_title and event_page_namespace
* Updates extra data for page-linked events to contain page id's
*/
-class EchoSuppressionRowUpdateGenerator implements EchoRowUpdateGenerator
-{
+class EchoSuppressionRowUpdateGenerator implements RowUpdateGenerator {
/**
- * @var callable Hack to allow replacing Title::newFromText in tests
+ * @var callable Hack to allow replacing Title::makeTitleSafe in tests
*/
- protected $newTitleFromText = array( 'Title', 'newFromText' );
+ protected $newTitleFromNsAndText = [ 'Title', 'makeTitleSafe' ];
/**
- * {@inheritDoc}
+ * @inheritDoc
*/
public function update( $row ) {
$update = $this->updatePageIdFromTitle( $row );
@@ -27,21 +26,21 @@ class EchoSuppressionRowUpdateGenerator implements EchoRowUpdateGenerator
/**
* Hackish method of mocking Title::newFromText for tests
*
- * @param $callable callable
+ * @param callable $callable
*/
- public function setNewTitleFromText( $callable ) {
- $this->newTitleFromText = $callable;
+ public function setNewTitleFromNsAndText( $callable ) {
+ $this->newTitleFromNsAndText = $callable;
}
/**
- * Hackish method of mocking Title::newFromText for tests
+ * Hackish method of mocking Title::makeTitleSafe for tests
*
- * @param $text string The page name to look up
- * @param $defaultNamespace integer The default namespace of the page to look up
- * @return Title|null The title located for the text + namespace, or null if invalid
+ * @param int $namespace The namespace of the page to look up
+ * @param string $text The page name to look up
+ * @return Title|null The title located for the namespace + text, or null if invalid
*/
- protected function newTitleFromText( $text, $defaultNamespace = NS_MAIN ) {
- return call_user_func( $this->newTitleFromText, $text, $defaultNamespace );
+ protected function newTitleFromNsAndText( $namespace, $text ) {
+ return call_user_func( $this->newTitleFromNsAndText, $namespace, $text );
}
/**
@@ -49,12 +48,12 @@ class EchoSuppressionRowUpdateGenerator implements EchoRowUpdateGenerator
* to having only a page id in the table. Any event from a page that doesn't have an
* article id gets the title+namespace moved to the event extra data
*
- * @param $row stdClass A row from the database
+ * @param stdClass $row A row from the database
* @return array All updates required for this row
*/
protected function updatePageIdFromTitle( $row ) {
- $update = array();
- $title = $this->newTitleFromText( $row->event_page_title, $row->event_page_namespace );
+ $update = [];
+ $title = $this->newTitleFromNsAndText( $row->event_page_namespace, $row->event_page_title );
if ( $title !== null ) {
$pageId = $title->getArticleId();
if ( $pageId ) {
@@ -78,8 +77,8 @@ class EchoSuppressionRowUpdateGenerator implements EchoRowUpdateGenerator
* Updates the extra data for page-linked events to point to the id of the article
* rather than the namespace+title combo.
*
- * @param $row stdClass A row from the database
- * @param $update array
+ * @param stdClass $row A row from the database
+ * @param array $update
*
* @return array All updates required for this row
*/
@@ -87,7 +86,7 @@ class EchoSuppressionRowUpdateGenerator implements EchoRowUpdateGenerator
$extra = $this->extra( $row, $update );
if ( isset( $extra['link-from-title'], $extra['link-from-namespace'] ) ) {
- $title = $this->newTitleFromText( $extra['link-from-title'], $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
@@ -106,17 +105,18 @@ class EchoSuppressionRowUpdateGenerator implements EchoRowUpdateGenerator
* extra data returns that updated data rather than the origional. If
* no extra data exists returns array()
*
- * @param $row stdClass The database row being updated
- * @param $update array Updates that need to be applied to the database row
+ * @param stdClass $row The database row being updated
+ * @param array $update Updates that need to be applied to the database row
* @return array The event extra data
*/
- protected function extra( $row, array $update = array() ) {
+ protected function extra( $row, array $update = [] ) {
if ( isset( $update['event_extra'] ) ) {
return unserialize( $update['event_extra'] );
} elseif ( $row->event_extra ) {
return unserialize( $row->event_extra );
}
- return array();
+
+ return [];
}
}
diff --git a/Echo/includes/special/NotificationPager.php b/Echo/includes/special/NotificationPager.php
new file mode 100644
index 00000000..07a4e06c
--- /dev/null
+++ b/Echo/includes/special/NotificationPager.php
@@ -0,0 +1,71 @@
+<?php
+
+/**
+ * This pager is used by Special:Notifications (NO-JS).
+ * The heavy-lifting is done by IndexPager (grand-parent to this class).
+ * It paginates on notification_event for a specific user, only for the enabled event types.
+ *
+ * Class NotificationPager
+ */
+class NotificationPager extends ReverseChronologicalPager {
+ public function __construct() {
+ $dbFactory = MWEchoDbFactory::newFromDefault();
+ $this->mDb = $dbFactory->getEchoDb( DB_SLAVE );
+
+ parent::__construct();
+ }
+
+ function formatRow( $row ) {
+ $msg = "This pager does not support row formatting. Use 'getNotifications()' to get a list of EchoNotification objects.";
+ throw new Exception( $msg );
+ }
+
+ function getQueryInfo() {
+ $attributeManager = EchoAttributeManager::newFromGlobalVars();
+ $eventTypes = $attributeManager->getUserEnabledEvents( $this->getUser(), 'web' );
+
+ return [
+ 'tables' => [ 'echo_notification', 'echo_event' ],
+ 'fields' => '*',
+ 'conds' => [
+ 'notification_user' => $this->getUser()->getId(),
+ 'event_type' => $eventTypes,
+ 'event_deleted' => 0,
+ ],
+ 'options' => [],
+ 'join_conds' =>
+ [ 'echo_event' =>
+ [
+ 'JOIN',
+ 'notification_event=event_id',
+ ],
+ ],
+ ];
+ }
+
+ public function getNotifications() {
+ if ( !$this->mQueryDone ) {
+ $this->doQuery();
+ }
+
+ $notifications = [];
+ foreach ( $this->mResult as $row ) {
+ $notifications[] = EchoNotification::newFromRow( $row );
+ }
+
+ // get rid of the overfetched
+ if ( count( $notifications ) > $this->getLimit() ) {
+ array_pop( $notifications );
+ }
+
+ if ( $this->mIsBackwards ) {
+ $notifications = array_reverse( $notifications );
+ }
+
+ return $notifications;
+ }
+
+ function getIndexField() {
+ return 'notification_event';
+ }
+}
diff --git a/Echo/includes/special/SpecialDisplayNotificationsConfiguration.php b/Echo/includes/special/SpecialDisplayNotificationsConfiguration.php
new file mode 100644
index 00000000..09be63ca
--- /dev/null
+++ b/Echo/includes/special/SpecialDisplayNotificationsConfiguration.php
@@ -0,0 +1,302 @@
+<?Php
+
+class SpecialDisplayNotificationsConfiguration extends UnlistedSpecialPage {
+ /**
+ * EchoAttributeManager to access notification configuration
+ *
+ * @var EchoAttributeManager $attributeManager;
+ */
+ protected $attributeManager;
+
+ /**
+ * Notification controller
+ *
+ * @var EchoNotificationController $notificationController;
+ */
+ protected $notificationController;
+
+ /**
+ * Category names, mapping internal name to HTML-formatted name
+ *
+ * @var array $categoryNames
+ */
+ protected $categoryNames;
+
+ // Should be one mapping text (friendly) name to internal name, but there
+ // is no friendly name
+ /**
+ * Notification type names. Mapping internal name to internal name
+ *
+ * @var array $notificationTypeNames
+ */
+ protected $notificationTypeNames;
+
+ /**
+ * Notify types, mapping internal name to text name
+ *
+ * @var array $notifyTypes
+ */
+ protected $notifyTypes;
+
+ // Due to how HTMLForm works, it's convenient to have both directions
+ /**
+ * Category names, mapping HTML-formatted name to internal name
+ *
+ * @var array $flippedCategoryNames
+ */
+ protected $flippedCategoryNames;
+
+ /**
+ * Notify types, mapping text name to internal name
+ *
+ * @var array $flippedNotifyTypes
+ */
+ protected $flippedNotifyTypes;
+
+ public function __construct() {
+ parent::__construct( 'DisplayNotificationsConfiguration' );
+
+ $this->attributeManager = EchoAttributeManager::newFromGlobalVars();
+ $this->notificationController = new EchoNotificationController();
+ }
+
+ public function execute( $subPage ) {
+ global $wgEchoNotifiers, $wgEchoNotifications;
+
+ $this->setHeaders();
+ $this->checkPermissions();
+
+ $internalCategoryNames = $this->attributeManager->getInternalCategoryNames();
+ $this->categoryNames = [];
+
+ foreach ( $internalCategoryNames as $internalCategoryName ) {
+ $formattedFriendlyCategoryName = Html::element(
+ 'strong',
+ [],
+ $this->msg( 'echo-category-title-' . $internalCategoryName )->numParams( 1 )->text()
+ );
+
+ $formattedInternalCategoryName = $this->msg( 'parentheses' )->rawParams(
+ Html::element(
+ 'em',
+ [],
+ $internalCategoryName
+ )
+ )->parse();
+
+ $this->categoryNames[$internalCategoryName] = $formattedFriendlyCategoryName . ' ' . $formattedInternalCategoryName;
+ }
+
+ $this->flippedCategoryNames = array_flip( $this->categoryNames );
+
+ $this->notifyTypes = [];
+ foreach ( $wgEchoNotifiers as $notifyType => $notifier ) {
+ $this->notifyTypes[$notifyType] = $this->msg( 'echo-pref-' . $notifyType )->text();
+ }
+
+ $this->flippedNotifyTypes = array_flip( $this->notifyTypes );
+
+ $notificationTypes = array_keys( $wgEchoNotifications );
+ $this->notificationTypeNames = array_combine( $notificationTypes, $notificationTypes );
+
+ $this->getOutput()->setPageTitle( $this->msg( 'echo-displaynotificationsconfiguration' )->text() );
+ $this->outputHeader( 'echo-displaynotificationsconfiguration-summary' );
+ $this->outputConfiguration();
+ }
+
+ /**
+ * Outputs the Echo configuration
+ */
+ protected function outputConfiguration() {
+ $this->outputNotificationsInCategories();
+ $this->outputNotificationsInSections();
+ $this->outputAvailability();
+ $this->outputMandatory();
+ $this->outputEnabledDefault();
+ }
+
+ /**
+ * Displays a checkbox matrix, using an HTMLForm
+ *
+ * @param string $id Arbitrary ID
+ * @param string $legendMsgKey Message key for an explanatory legend. For example,
+ * "We wrote this feature because in the days of yore, there was but one notification badge"
+ * @param array $rowLabelMapping Associative array mapping label to tag
+ * @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 ) {
+ $form = new HTMLForm(
+ [
+ $id => [
+ 'type' => 'checkmatrix',
+ 'rows' => $rowLabelMapping,
+ 'columns' => $columnLabelMapping,
+ 'default' => $value,
+ 'disabled' => true,
+ ]
+ ],
+ $this
+ );
+
+ $form->setTitle( $this->getTitle() )
+ ->prepareForm()
+ ->suppressDefaultSubmit()
+ ->setWrapperLegendMsg( $legendMsgKey )
+ ->displayForm( false );
+ }
+
+ /**
+ * Outputs the notification types in each category
+ */
+ protected function outputNotificationsInCategories() {
+ $notificationsByCategory = $this->attributeManager->getEventsByCategory();
+
+ $out = $this->getOutput();
+ $out->addHTML(
+ Html::element(
+ 'h2',
+ [ 'id' => 'mw-echo-displaynotificationsconfiguration-notifications-by-category' ],
+ $this->msg( 'echo-displaynotificationsconfiguration-notifications-by-category-header' )->text()
+ )
+ );
+
+ $out->addHTML( Html::openElement( 'ul' ) );
+ foreach ( $notificationsByCategory as $categoryName => $notificationTypes ) {
+ $implodedTypes = Html::element(
+ 'span',
+ [],
+ implode( $this->msg( 'comma-separator' )->text(), $notificationTypes )
+ );
+
+ $out->addHTML(
+ Html::rawElement(
+ 'li',
+ [],
+ $this->categoryNames[$categoryName] . $this->msg( 'colon-separator' )->text() . ' ' . $implodedTypes
+ )
+ );
+ }
+ $out->addHTML( Html::closeElement( 'ul' ) );
+ }
+
+ /**
+ * Output the notification types in each section (alert/message)
+ */
+ protected function outputNotificationsInSections() {
+ $this->getOutput()->addHTML( Html::element( 'h2', [ 'id' => 'mw-echo-displaynotificationsconfiguration-sorting-by-section' ], $this->msg( 'echo-displaynotificationsconfiguration-sorting-by-section-header' )->text() ) );
+
+ $bySectionValue = [];
+
+ $flippedSectionNames = [];
+
+ foreach ( EchoAttributeManager::$sections as $section ) {
+ $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' )->text()] = $section;
+ foreach ( $types as $type ) {
+ $bySectionValue[] = "$section-$type";
+ }
+ }
+
+ $this->outputCheckMatrix( 'type-by-section', 'echo-displaynotificationsconfiguration-sorting-by-section-legend', $this->notificationTypeNames, $flippedSectionNames, $bySectionValue );
+ }
+
+ /**
+ * 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' ], $this->msg( 'echo-displaynotificationsconfiguration-available-notification-methods-header' )->text() ) );
+
+ $byCategoryValue = [];
+
+ foreach ( $this->notifyTypes as $notifyType => $displayNotifyType ) {
+ foreach ( $this->categoryNames as $category => $displayCategory ) {
+ if ( $this->attributeManager->isNotifyTypeAvailableForCategory( $category, $notifyType ) ) {
+ $byCategoryValue[] = "$notifyType-$category";
+ }
+ }
+ }
+
+ $this->outputCheckMatrix( 'availability-by-category', 'echo-displaynotificationsconfiguration-available-notification-methods-by-category-legend', $this->flippedCategoryNames, $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 );
+ }
+
+ /**
+ * Output which notification categories are turned on by default, for each notify type
+ */
+ protected function outputEnabledDefault() {
+ $this->getOutput()->addHTML( Html::element( 'h2', [ 'id' => 'mw-echo-displaynotificationsconfiguration-enabled-default' ], $this->msg( 'echo-displaynotificationsconfiguration-enabled-default-header' )->text() ) );
+
+ // In reality, anon users are not relevant to Echo, but this lets us easily query default options.
+ $anonUser = new User;
+
+ $byCategoryValueExisting = [];
+ foreach ( $this->notifyTypes as $notifyType => $displayNotifyType ) {
+ foreach ( $this->categoryNames as $category => $displayCategory ) {
+ $tag = "$notifyType-$category";
+ if ( $anonUser->getOption( "echo-subscriptions-$tag" ) ) {
+ $byCategoryValueExisting[] = "$notifyType-$category";
+ }
+ }
+ }
+
+ $this->outputCheckMatrix( 'enabled-by-default-generic', 'echo-displaynotificationsconfiguration-enabled-default-existing-users-legend', $this->flippedCategoryNames, $this->flippedNotifyTypes, $byCategoryValueExisting );
+
+ $loggedInUser = new User;
+ // This might not catch if there are other hooks that do similar.
+ // We can't run the actual hook, to avoid side effects.
+ $overrides = EchoHooks::getNewUserPreferenceOverrides();
+ foreach ( $overrides as $prefKey => $value ) {
+ $loggedInUser->setOption( $prefKey, $value );
+ }
+
+ $byCategoryValueNew = [];
+ foreach ( $this->notifyTypes as $notifyType => $displayNotifyType ) {
+ foreach ( $this->categoryNames as $category => $displayCategory ) {
+ $tag = "$notifyType-$category";
+ if ( $loggedInUser->getOption( "echo-subscriptions-$tag" ) ) {
+ $byCategoryValueNew[] = "$notifyType-$category";
+ }
+ }
+ }
+
+ $this->outputCheckMatrix( 'enabled-by-default-new', 'echo-displaynotificationsconfiguration-enabled-default-new-users-legend', $this->flippedCategoryNames, $this->flippedNotifyTypes, $byCategoryValueNew );
+ }
+
+ /**
+ * Output which notify types are mandatory for each category
+ */
+ protected function outputMandatory() {
+ $byCategoryValue = [];
+
+ $this->getOutput()->addHTML( Html::element( 'h2', [ 'id' => 'mw-echo-displaynotificationsconfiguration-mandatory-notification-methods' ], $this->msg( 'echo-displaynotificationsconfiguration-mandatory-notification-methods-header' )->text() ) );
+
+ foreach ( $this->notifyTypes as $notifyType => $displayNotifyType ) {
+ foreach ( $this->categoryNames as $category => $displayCategory ) {
+ if ( !$this->attributeManager->isNotifyTypeDismissableForCategory( $category, $notifyType ) ) {
+ $byCategoryValue[] = "$notifyType-$category";
+ }
+ }
+ }
+
+ $this->outputCheckMatrix( 'mandatory', 'echo-displaynotificationsconfiguration-mandatory-notification-methods-by-category-legend', $this->flippedCategoryNames, $this->flippedNotifyTypes, $byCategoryValue );
+ }
+}
diff --git a/Echo/includes/special/SpecialNotifications.php b/Echo/includes/special/SpecialNotifications.php
index 49af8fe6..e6552ee8 100644
--- a/Echo/includes/special/SpecialNotifications.php
+++ b/Echo/includes/special/SpecialNotifications.php
@@ -12,138 +12,228 @@ class SpecialNotifications extends SpecialPage {
}
public function execute( $par ) {
-
$this->setHeaders();
$out = $this->getOutput();
$out->setPageTitle( $this->msg( 'echo-specialpage' )->text() );
+ $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',
+ ] );
+
$user = $this->getUser();
if ( $user->isAnon() ) {
- // return to this title upon login
- $returnTo = array( 'returnto' => $this->getPageTitle()->getPrefixedDBkey() );
- // the html message for anon users
- $anonMsgHtml = $this->msg(
- 'echo-anon',
- SpecialPage::getTitleFor( 'Userlogin', 'signup' )->getFullURL( $returnTo ),
- SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( $returnTo )
- )->parse();
- $out->addHTML( Html::rawElement( 'span', array( 'class' => 'plainlinks' ), $anonMsgHtml ) );
+ // Redirect to login page and inform user of the need to login
+ $this->requireLogin( 'echo-notification-loginrequired' );
return;
}
$out->addSubtitle( $this->buildSubtitle() );
- // The continue parameter to pull current set of data from, this
- // would be used for browsers with javascript disabled
- $continue = $this->getRequest()->getVal( 'continue', null );
+ $out->enableOOUI();
- // Pull the notifications
- $notif = array();
- $notificationMapper = new EchoNotificationMapper();
+ $pager = new NotificationPager( $this->getContext() );
+ $pager->setOffset( $this->getRequest()->getVal( 'offset' ) );
+ $pager->setLimit( $this->getRequest()->getVal( 'limit', self::DISPLAY_NUM ) );
+ $notifications = $pager->getNotifications();
- $attributeManager = EchoAttributeManager::newFromGlobalVars();
- $notifications = $notificationMapper->fetchByUser(
- $user,
- /* $limit = */self::DISPLAY_NUM + 1,
- $continue,
- $attributeManager->getUserEnabledEvents( $user, 'web' )
- );
- foreach ( $notifications as $notification ) {
- $notif[] = EchoDataOutputFormatter::formatOutput( $notification, 'html', $user );
- }
+ $noJSDiv = new OOUI\Tag();
+ $noJSDiv->addClasses( [ 'mw-echo-special-nojs' ] );
// If there are no notifications, display a message saying so
- if ( !$notif ) {
- $out->addWikiMsg( 'echo-none' );
+ if ( !$notifications ) {
+ // Wrap this with nojs so it is still hidden if JS is loading
+ $noJSDiv->appendContent(
+ new OOUI\LabelWidget( [ 'label' => $this->msg( 'echo-none' )->text() ] )
+ );
+ $out->addHTML( $noJSDiv );
+ $out->addModules( [ 'ext.echo.special' ] );
return;
}
- // Check if there is more data to load for next request
- if ( count( $notif ) > self::DISPLAY_NUM ) {
- $lastItem = array_pop( $notif );
- $nextContinue = $lastItem['timestamp']['utcunix'] . '|' . $lastItem['id'];
- } else {
- $nextContinue = null;
+ $notif = [];
+ foreach ( $notifications as $notification ) {
+ $output = EchoDataOutputFormatter::formatOutput( $notification, 'special', $user, $this->getLanguage() );
+ if ( $output ) {
+ $notif[] = $output;
+ }
}
// Add the notifications to the page (interspersed with date headers)
$dateHeader = '';
- $notices = '';
- $unread = array();
+ $unread = [];
+ $anyUnread = false;
$echoSeenTime = EchoSeenTime::newFromUser( $user );
$seenTime = $echoSeenTime->getTime();
+ $notifArray = [];
foreach ( $notif as $row ) {
- $class = 'mw-echo-notification';
+ if ( !$row['*'] ) {
+ continue;
+ }
+
+ $classes = [ 'mw-echo-notification' ];
if ( !isset( $row['read'] ) ) {
- $class .= ' mw-echo-unread';
+ $classes[] = 'mw-echo-notification-unread';
if ( !$row['targetpages'] ) {
$unread[] = $row['id'];
}
}
if ( $seenTime !== null && $row['timestamp']['mw'] > $seenTime ) {
- $class .= ' mw-echo-unseen';
+ $classes[] = 'mw-echo-notification-unseen';
}
- if ( !$row['*'] ) {
- continue;
- }
// Output the date header if it has not been displayed
if ( $dateHeader !== $row['timestamp']['date'] ) {
$dateHeader = $row['timestamp']['date'];
- $notices .= Html::rawElement( 'li', array( 'class' => 'mw-echo-date-section' ), $dateHeader );
+ $notifArray[ $dateHeader ] = [
+ 'unread' => [],
+ 'notices' => []
+ ];
}
- $notices .= Html::rawElement(
- 'li',
- array(
- 'class' => $class,
+ // Collect unread IDs
+ if ( !isset( $row['read'] ) ) {
+ $anyUnread = true;
+ $notifArray[ $dateHeader ][ 'unread' ][] = $row['id'];
+ }
+
+ $li = new OOUI\Tag( 'li' );
+ $li
+ ->addClasses( $classes )
+ ->setAttributes( [
'data-notification-category' => $row['category'],
'data-notification-event' => $row['id'],
'data-notification-type' => $row['type']
- ),
- $row['*']
- );
+ ] )
+ ->appendContent( new OOUI\HtmlSnippet( $row['*'] ) );
+
+ // Store
+ $notifArray[ $dateHeader ][ 'notices' ][] = $li;
}
- $html = Html::rawElement( 'ul', array( 'id' => 'mw-echo-special-container' ), $notices );
-
- // Build the more link
- if ( $nextContinue ) {
- $html .= Html::element(
- 'a',
- array(
- 'href' => SpecialPage::getTitleFor( 'Notifications' )->getLinkURL(
- array( 'continue' => $nextContinue )
- ),
- 'class' => 'mw-ui-button mw-ui-primary',
- 'id' => 'mw-echo-more'
- ),
- $this->msg( 'moredotdotdot' )->text()
+
+ $markAllAsReadFormWrapper = '';
+ // Ensure there are some unread notifications
+ if ( $anyUnread ) {
+ $markReadSpecialPage = new SpecialNotificationsMarkRead();
+
+ $markAllAsReadText = $this->msg( 'echo-mark-all-as-read' )->text();
+ $markAllAsReadLabelIcon = new EchoOOUI\LabelIconWidget( [
+ 'label' => $markAllAsReadText,
+ 'icon' => 'doubleCheck',
+ ] );
+
+ $markAllAsReadForm = $markReadSpecialPage->getMinimalForm(
+ [ 'ALL' ],
+ $markAllAsReadText,
+ true,
+ $markAllAsReadLabelIcon->toString()
);
+
+ $formHtml = $markAllAsReadForm->prepareForm()->getHTML( /* First submission attempt */ false );
+
+ $markAllAsReadFormWrapper = new OOUI\Tag();
+ $markAllAsReadFormWrapper
+ ->addClasses( [ 'mw-echo-special-markAllReadButton' ] )
+ ->appendContent( new OOUI\HtmlSnippet( $formHtml ) );
}
- $out->addHTML( $html );
- $out->addModules( 'ext.echo.special' );
- $out->addJsConfigVars(
- array(
- 'wgEchoDisplayNum' => self::DISPLAY_NUM,
- 'wgEchoNextContinue' => $nextContinue,
- 'wgEchoDateHeader' => $dateHeader
- )
- );
- // For no-js support
- $out->addModuleStyles( array( 'ext.echo.styles.notifications', 'ext.echo.styles.special' ) );
+ // Build the list
+ $notices = new OOUI\Tag( 'ul' );
+ $notices->addClasses( [ 'mw-echo-special-notifications' ] );
+
+ $markReadSpecialPage = new SpecialNotificationsMarkRead();
+ foreach ( $notifArray as $section => $data ) {
+ // Heading
+ $heading = ( new OOUI\Tag( 'li' ) )->addClasses( [ 'mw-echo-date-section' ] );
+
+ $dateTitle = new OOUI\LabelWidget( [
+ 'classes' => [ 'mw-echo-date-section-text' ],
+ 'label' => $section
+ ] );
+
+ $heading->appendContent( $dateTitle );
+
+ // Mark all read button
+ if ( count( $data[ 'unread' ] ) > 0 ) {
+ // tell the UI to show 'unread' notifications only (instead of 'all')
+ $out->addJsConfigVars( 'wgEchoReadState', 'unread' );
+
+ $markReadSectionText = $this->msg( 'echo-specialpage-section-markread' )->text();
+ $markAsReadLabelIcon = new EchoOOUI\LabelIconWidget( [
+ 'label' => $markReadSectionText,
+ 'icon' => 'doubleCheck',
+ ] );
- DeferredUpdates::addCallableUpdate( function() use ( $user, $echoSeenTime, $unread ) {
- // Mark items as read
- if ( $unread ) {
- MWEchoNotifUser::newFromUser( $user )->markRead( $unread );
+ // There are unread notices. Add the 'mark section as read' button
+ $markSectionAsReadForm = $markReadSpecialPage->getMinimalForm(
+ $data[ 'unread' ],
+ $markReadSectionText,
+ true,
+ $markAsReadLabelIcon->toString()
+ );
+
+ $formHtml = $markSectionAsReadForm->prepareForm()->getHTML( /* First submission attempt */ false );
+
+ $formWrapper = new OOUI\Tag();
+ $formWrapper
+ ->addClasses( [ 'mw-echo-markAsReadSectionButton' ] )
+ ->appendContent( new OOUI\HtmlSnippet( $formHtml ) );
+
+ $heading->appendContent( $formWrapper );
}
- // Record time notifications have been seen
- $echoSeenTime->setTime( wfTimestamp( TS_MW ) );
- } );
+
+ // These two must be separate, because $data[ 'notices' ]
+ // is an array
+ $notices
+ ->appendContent( $heading )
+ ->appendContent( $data[ 'notices' ] );
+ }
+
+ $navBar = $pager->getNavigationBar();
+
+ $navTop = new OOUI\Tag();
+ $navBottom = new OOUI\Tag();
+ $container = new OOUI\Tag();
+
+ $navTop
+ ->addClasses( [ 'mw-echo-special-navbar-top' ] )
+ ->appendContent( new OOUI\HtmlSnippet( $navBar ) );
+ $navBottom
+ ->addClasses( [ 'mw-echo-special-navbar-bottom' ] )
+ ->appendContent( new OOUI\HtmlSnippet( $navBar ) );
+
+ // Put it all together
+ $container
+ ->addClasses( [ 'mw-echo-special-container' ] )
+ ->appendContent(
+ $navTop,
+ $markAllAsReadFormWrapper,
+ $notices,
+ $navBottom
+ );
+
+ // Wrap with nojs div
+ $noJSDiv->appendContent( $container );
+
+ $out->addHTML( $noJSDiv );
+
+ $out->addModules( [ 'ext.echo.special' ] );
+
+ // For no-js support
+ $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'
+ ] );
+
+ // Log visit
+ MWEchoEventLogging::logSpecialPageVisit( $user, $out->getSkin()->getSkinName() );
}
/**
@@ -151,32 +241,20 @@ class SpecialNotifications extends SpecialPage {
* @return string HTML for the subtitle
*/
public function buildSubtitle() {
- global $wgEchoHelpPage;
$lang = $this->getLanguage();
- $subtitleLinks = array();
- // More info link
- $subtitleLinks[] = Html::rawElement(
- 'a',
- array(
- 'href' => $wgEchoHelpPage,
- 'id' => 'mw-echo-moreinfo-link',
- 'class' => 'mw-echo-special-header-link',
- 'title' => $this->msg( 'echo-more-info' )->text(),
- 'target' => '_blank'
- ),
- $this->msg( 'echo-more-info' )->text()
- );
+ $subtitleLinks = [];
// Preferences link
- $subtitleLinks[] = Html::rawElement(
+ $subtitleLinks[] = Html::element(
'a',
- array(
+ [
'href' => SpecialPage::getTitleFor( 'Preferences' )->getLinkURL() . '#mw-prefsection-echo',
'id' => 'mw-echo-pref-link',
'class' => 'mw-echo-special-header-link',
'title' => $this->msg( 'preferences' )->text()
- ),
+ ],
$this->msg( 'preferences' )->text()
);
+ // using pipeList to make it easier to add some links in the future
return $lang->pipeList( $subtitleLinks );
}
diff --git a/Echo/includes/special/SpecialNotificationsMarkRead.php b/Echo/includes/special/SpecialNotificationsMarkRead.php
new file mode 100644
index 00000000..b69f6b02
--- /dev/null
+++ b/Echo/includes/special/SpecialNotificationsMarkRead.php
@@ -0,0 +1,152 @@
+<?php
+
+/**
+ * Form for marking notifications as read by ID.
+ *
+ * This uses the normal HTMLForm handling when receiving POSTs.
+ * However, for a better user no-JS user experience, we integrate
+ * a version of the form into Special:Notifications. Thus, this
+ * page should normally not need to be visited directly.
+ */
+class SpecialNotificationsMarkRead extends FormSpecialPage {
+ protected $eventId;
+
+ public function __construct() {
+ parent::__construct( 'NotificationsMarkRead' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ public function execute( $par ) {
+ parent::execute( $par );
+
+ $out = $this->getOutput();
+ $out->setPageTitle( $this->msg( 'echo-specialpage-markasread' )->text() );
+
+ // Redirect to login page and inform user of the need to login
+ $this->requireLogin( 'echo-notification-loginrequired' );
+ }
+
+ public function isListed() {
+ return false;
+ }
+
+ public function getDisplayFormat() {
+ return 'ooui';
+ }
+
+ /**
+ * Get an HTMLForm descriptor array
+ * @return array
+ */
+ protected function getFormFields() {
+ return [
+ 'id' => [
+ 'type' => 'hidden',
+ 'required' => true,
+ 'default' => $this->par,
+ 'filter-callback' => function ( $value, $alldata ) {
+ // Allow for a single value or a set of values
+ $result = explode( ',', $value );
+ return $result;
+ },
+ 'validation-callback' => function ( $value, $alldata ) {
+ if ( $value === [ 'ALL' ] ) {
+ return true;
+ }
+ if ( (int)$value <= 0 ) {
+ return $this->msg( 'echo-specialpage-markasread-invalid-id' );
+ }
+ foreach ( $value as $val ) {
+ if ( (int)( $val ) <= 0 ) {
+ return $this->msg( 'echo-specialpage-markasread-invalid-id' );
+ }
+ }
+ return true;
+ }
+ ]
+ ];
+ }
+
+ /**
+ * Gets a pre-filled version of the form; this should not have a legend or anything
+ * visible, except the button.
+ *
+ * @param int|array $idValue ID or array of IDs
+ * @param string $submitButtonValue Value attribute for button
+ * @param bool $framed Whether the button should be framed
+ * @param string $submitLabelHtml Raw HTML to use for button label
+ *
+ * @return HTMLForm
+ */
+ public function getMinimalForm( $idValue, $submitButtonValue, $framed, $submitLabelHtml ) {
+ if ( !is_array( $idValue ) ) {
+ $idValue = [ $idValue ];
+ }
+
+ $idString = join( ',', $idValue );
+
+ $this->setParameter( $idString );
+
+ $form = HTMLForm::factory(
+ $this->getDisplayFormat(),
+ $this->getFormFields(),
+ $this->getContext(),
+ $this->getMessagePrefix()
+ );
+
+ // HTMLForm assumes that the main submit button is always 'primary',
+ // which means it is colored. Since this form is being embedded multiple
+ // places on the page, it has to be neutral, so we make the button
+ // manually.
+ $form->suppressDefaultSubmit();
+
+ $form->setAction( $this->getPageTitle()->getLocalURL() );
+
+ $form->addButton( [
+ 'name' => 'submit',
+ 'value' => $submitButtonValue,
+ 'label-raw' => $submitLabelHtml,
+ 'framed' => $framed,
+ ] );
+
+ return $form;
+ }
+
+ /**
+ * Sets a custom label
+ *
+ * This is only called when the form is actually visited directly, which is not the
+ * main intended use.
+ * @param HTMLForm $form
+ */
+ protected function alterForm( HTMLForm $form ) {
+ $form->setSubmitText( $this->msg( 'echo-notification-markasread' )->text() );
+ }
+
+ /**
+ * Process the form on POST submission.
+ * @param array $data
+ * @param HTMLForm $form
+ * @return bool|string|array|Status As documented for HTMLForm::trySubmit.
+ */
+ public function onSubmit( array $data /* $form = null */ ) {
+ $notifUser = MWEchoNotifUser::newFromUser( $this->getUser() );
+
+ // Allow for all IDs
+ if ( $data['id'] === [ 'ALL' ] ) {
+ return $notifUser->markAllRead();
+ }
+
+ // Allow for multiple IDs or a single ID
+ $ids = $data['id'];
+ return $notifUser->markRead( $ids );
+ }
+
+ public function onSuccess() {
+ $page = SpecialPage::getTitleFor( 'Notifications' );
+ $this->getOutput()->redirect( $page->getFullUrl() );
+ }
+}