Cross-wiki notifications integration

Bug: T121829
Change-Id: Ifb52ad5605a56d27e5951479326689242a49430e
This commit is contained in:
Roan Kattouw 2015-11-24 20:07:54 -08:00 committed by Matthias Mullie
parent 5670a247f0
commit 63eef35026
14 changed files with 318 additions and 42 deletions

View file

@ -434,6 +434,16 @@ $wgEchoNotifications = array(
'title-params' => array( 'agent' ),
'icon' => 'site',
),
'foreign' => array(
'presentation-model' => 'EchoForeignPresentationModel',
'user-locators' => array(
'EchoUserLocator::locateEventAgent'
),
'category' => 'foreign',
'group' => 'positive',
'section' => 'alert',
'icon' => 'site',
),
);
// Enable new talk page messages alert for all logged in users by default

View file

@ -48,7 +48,9 @@ $wgAutoloadClasses += array(
'EchoEventMapperTest' => __DIR__ . '/tests/phpunit/mapper/EventMapperTest.php',
'EchoEventPresentationModel' => __DIR__ . '/includes/formatters/EventPresentationModel.php',
'EchoExecuteFirstArgumentStub' => __DIR__ . '/tests/phpunit/mapper/NotificationMapperTest.php',
'EchoForeignPresentationModel' => __DIR__ . '/includes/formatters/EchoForeignPresentationModel.php',
'EchoFilteredSequentialIterator' => __DIR__ . '/includes/iterator/FilteredSequentialIterator.php',
'EchoForeignNotifications' => __DIR__ . '/includes/ForeignNotifications.php',
'EchoFlyoutFormatter' => __DIR__ . '/includes/formatters/EchoFlyoutFormatter.php',
'EchoHTMLEmailDecorator' => __DIR__ . '/includes/EmailFormatter.php',
'EchoHTMLEmailFormatter' => __DIR__ . '/includes/EmailFormatter.php',

View file

@ -136,6 +136,8 @@
"echo-email-batch-body-intro-weekly": "Hi $1,\nHere's a summary of this week's activity on {{SITENAME}} for you.",
"echo-email-batch-link-text-view-all-notifications": "View all notifications",
"echo-rev-deleted-text-view": "This page revision has been suppressed.",
"notification-header-foreign-alert": "More alerts from $3 {{PLURAL:$4|and one other wiki|and $4 other wikis|0=}}",
"notification-header-foreign-message": "More messages from $3 {{PLURAL:$4|and one other wiki|and $4 other wikis|0=}}",
"apihelp-echomarkread-description": "Mark notifications as read for the current user.",
"apihelp-echomarkread-param-list": "A list of notification IDs to mark as read.",
"apihelp-echomarkread-param-all": "If set, marks all of a user's notifications as read.",
@ -153,6 +155,7 @@
"apihelp-query+notifications-param-format": "If specified, notifications will be returned formatted this way.",
"apihelp-query+notifications-param-limit": "The maximum number of notifications to return.",
"apihelp-query+notifications-param-index": "If specified, a list of notification IDs, in order, will be returned.",
"apihelp-query+notifications-param-noforn": "True to opt out of data about notifications on foreign wikis.",
"apihelp-query+notifications-param-alertcontinue": "When more alert results are available, use this to continue.",
"apihelp-query+notifications-param-alertunreadfirst": "Whether to show unread message notifications first.",
"apihelp-query+notifications-param-messagecontinue": "When more message results are available, use this to continue.",

View file

@ -157,6 +157,8 @@
"echo-email-batch-body-intro-weekly": "Introduction text for weekly email digest. Parameters:\n* $1 - a username\nSee also:\n* {{msg-mw|Echo-email-batch-body-intro-daily}}",
"echo-email-batch-link-text-view-all-notifications": "The link text for the primary action in daily and weekly email digest",
"echo-rev-deleted-text-view": "Short message displayed instead of edit content when revision text is suppressed.",
"notification-header-foreign-alert": "Flyout-specific format for displaying notification header of having alert notifications on foreign wikis.\n\nParameters:\n* $1 - the formatted username of the current user.\n* $2 - the username for GENDER\n* $3 - 1 of the foreign wikis you have notifications on\n* $4 - the amount of remaining other wikis you have notifications on",
"notification-header-foreign-message": "Flyout-specific format for displaying notification header of having message notifications on foreign wikis.\n\nParameters:\n* $1 - the formatted username of the current user.\n* $2 - the username for GENDER\n* $3 - 1 of the foreign wikis you have notifications on\n* $4 - the amount of remaining other wikis you have notifications on",
"apihelp-echomarkread-description": "{{doc-apihelp-description|echomarkread}}",
"apihelp-echomarkread-param-list": "{{doc-apihelp-param|echomarkread|list}}",
"apihelp-echomarkread-param-all": "{{doc-apihelp-param|echomarkread|all}}",
@ -174,6 +176,7 @@
"apihelp-query+notifications-param-format": "{{doc-apihelp-param|query+notifications|format}}",
"apihelp-query+notifications-param-limit": "{{doc-apihelp-param|query+notifications|limit}}",
"apihelp-query+notifications-param-index": "{{doc-apihelp-param|query+notifications|index}}",
"apihelp-query+notifications-param-noforn": "{{doc-apihelp-param|query+notifications|noforn}}",
"apihelp-query+notifications-param-alertcontinue": "{{doc-apihelp-param|query+notifications|alertcontinue}}",
"apihelp-query+notifications-param-alertunreadfirst": "{{doc-apihelp-param|query+notifications|alertunreadfirst}}",
"apihelp-query+notifications-param-messagecontinue": "{{doc-apihelp-param|query+notifications|messagecontinue}}",

View file

@ -0,0 +1,146 @@
<?php
class EchoForeignNotifications {
/**
* @var bool|EchoUnreadWikis
*/
protected $unreadWikis = false;
/**
* @var array [(str) section => (int) count, ...]
*/
protected $counts = array( EchoAttributeManager::ALERT => 0, EchoAttributeManager::MESSAGE => 0 );
/**
* @var array [(str) section => (string[]) wikis, ...]
*/
protected $wikis = array( EchoAttributeManager::ALERT => array(), EchoAttributeManager::MESSAGE => array() );
/**
* @var array [(str) section => (MWTimestamp) timestamp, ...]
*/
protected $timestamps = array( EchoAttributeManager::ALERT => false, EchoAttributeManager::MESSAGE => false );
/**
* @var bool
*/
protected $populated = false;
/**
* @param User $user
*/
public function __construct( User $user ) {
if ( $user->getOption( 'echo-cross-wiki-notifications' ) ) {
$this->unreadWikis = EchoUnreadWikis::newFromUser( $user );
}
}
/**
* @param string|null $section Name of section or null for all
* @return int
*/
public function getCount( $section = null ) {
$this->populate();
if ( $section === null ) {
return array_sum( $this->counts );
}
return isset( $this->counts[$section] ) ? $this->counts[$section] : 0;
}
/**
* @param string|null $section Name of section or null for all
* @return MWTimestamp|false
*/
public function getTimestamp( $section = null ) {
$this->populate();
if ( $section === null ) {
$max = false;
/** @var MWTimestamp $timestamp */
foreach ( $this->timestamps as $timestamp ) {
// $timestamp < $max = invert 0
// $timestamp > $max = invert 1
if ( $max === false || $timestamp->diff( $max )->invert === 1 ) {
$max = $timestamp;
}
}
return $timestamp;
}
return isset( $this->timestamps[$section] ) ? $this->timestamps[$section] : false;
}
/**
* @param string|null $section Name of section or null for all
* @return string[]
*/
public function getWikis( $section = null ) {
$this->populate();
if ( $section === null ) {
$all = array();
foreach ( $this->wikis as $wikis ) {
$all = array_merge( $all, $wikis );
}
return array_unique( $all );
}
return isset( $this->wikis[$section] ) ? $this->wikis[$section] : array();
}
protected function populate() {
if ( $this->populated ) {
return;
}
if ( $this->unreadWikis === false ) {
return;
}
$unreadCounts = $this->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;
$this->timestamps[$section] = new MWTimestamp( $data['ts'] );
}
}
}
$this->populated = true;
}
/**
* @param string[] $wikis
* @return array[] [(string) wiki => (array) data]
*/
public function getApiEndpoints( array $wikis ) {
global $wgConf;
$data = array();
foreach ( $wikis as $wiki ) {
$data[$wiki] = array(
'title' => $wiki,
'url' => $wgConf->get( 'wgServer', $wiki ) .
$wgConf->get( 'wgScriptPath', $wiki ) .
'/api.php'
);
}
return $data;
}
}

View file

@ -35,6 +35,11 @@ class MWEchoNotifUser {
*/
private $targetPageMapper;
/**
* @var EchoForeignNotifications
*/
private $foreignNotifications;
/**
* Usually client code doesn't need to initialize the object directly
* because it could be obtained from factory method newFromUser()
@ -56,6 +61,7 @@ class MWEchoNotifUser {
$this->cache = $cache;
$this->notifMapper = $notifMapper;
$this->targetPageMapper = $targetPageMapper;
$this->foreignNotifications = new EchoForeignNotifications( $user );
}
/**
@ -150,7 +156,10 @@ class MWEchoNotifUser {
* @return int
*/
public function getMessageCount( $cached = true, $dbSource = DB_SLAVE ) {
return $this->getNotificationCount( $cached, $dbSource, EchoAttributeManager::MESSAGE );
$count = $this->getNotificationCount( $cached, $dbSource, EchoAttributeManager::MESSAGE );
$count += $this->foreignNotifications->getCount( EchoAttributeManager::MESSAGE );
return $count;
}
/**
@ -161,7 +170,10 @@ class MWEchoNotifUser {
* @return int
*/
public function getAlertCount( $cached = true, $dbSource = DB_SLAVE ) {
return $this->getNotificationCount( $cached, $dbSource, EchoAttributeManager::ALERT );
$count = $this->getNotificationCount( $cached, $dbSource, EchoAttributeManager::ALERT );
$count += $this->foreignNotifications->getCount( EchoAttributeManager::ALERT );
return $count;
}
/**
@ -256,10 +268,18 @@ class MWEchoNotifUser {
*
* @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)
* @return int
* @return bool|MWTimestamp
*/
public function getLastUnreadAlertTime( $cached = true, $dbSource = DB_SLAVE ) {
return $this->getLastUnreadNotificationTime( $cached, $dbSource, EchoAttributeManager::ALERT );
$time = $this->getLastUnreadNotificationTime( $cached, $dbSource, EchoAttributeManager::ALERT );
$foreignTime = $this->foreignNotifications->getTimestamp( EchoAttributeManager::ALERT );
if ( $foreignTime !== false ) {
$max = max( $time ? $time->getTimestamp( TS_MW ) : 0, $foreignTime->getTimestamp( TS_MW ) );
$time = new MWTimestamp( $max );
}
return $time;
}
/**
@ -267,10 +287,18 @@ class MWEchoNotifUser {
*
* @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)
* @return int
* @return bool|MWTimestamp
*/
public function getLastUnreadMessageTime( $cached = true, $dbSource = DB_SLAVE ) {
return $this->getLastUnreadNotificationTime( $cached, $dbSource, EchoAttributeManager::MESSAGE );
$time = $this->getLastUnreadNotificationTime( $cached, $dbSource, EchoAttributeManager::MESSAGE );
$foreignTime = $this->foreignNotifications->getTimestamp( EchoAttributeManager::MESSAGE );
if ( $foreignTime !== false ) {
$max = max( $time ? $time->getTimestamp( TS_MW ) : 0, $foreignTime->getTimestamp( TS_MW ) );
$time = new MWTimestamp( $max );
}
return $time;
}
/**

View file

@ -5,10 +5,6 @@
*
*/
class EchoUnreadWikis {
const ALERT = 'alert';
const MESSAGE = 'message';
/**
* @var int
*/
@ -78,11 +74,11 @@ class EchoUnreadWikis {
continue;
}
$wikis[$row->euw_wiki] = array(
self::ALERT => array(
EchoAttributeManager::ALERT => array(
'count' => $row->euw_alerts,
'ts' => $row->euw_alerts_ts,
),
self::MESSAGE => array(
EchoAttributeManager::MESSAGE => array(
'count' => $row->euw_messages,
'ts' => $row->euw_messages_ts,
),

View file

@ -18,6 +18,8 @@ class ApiEchoNotifications extends ApiQueryBase {
$params = $this->extractRequestParams();
$prop = $params['prop'];
$foreignNotifications = new EchoForeignNotifications( $user );
$result = array();
if ( in_array( 'list', $prop ) ) {
// Group notification results by section
@ -27,6 +29,12 @@ class ApiEchoNotifications extends ApiQueryBase {
$user, $section, $params['filter'], $params['limit'],
$params[$section . 'continue'], $params['format'], $params[$section . 'unreadfirst']
);
if ( $foreignNotifications->getCount( $section ) > 0 ) {
// insert fake notification for foreign notifications
$result[$section]['list'][-1] = $this->makeForeignNotification( $user, $params['format'], $foreignNotifications, $section );
}
$this->getResult()->setIndexedTagName( $result[$section]['list'], 'notification' );
// 'index' is built on top of 'list'
if ( in_array( 'index', $prop ) ) {
@ -41,6 +49,14 @@ class ApiEchoNotifications extends ApiQueryBase {
$attributeManager->getUserEnabledEventsbySections( $user, 'web', $params['sections'] ),
$params['filter'], $params['limit'], $params['continue'], $params['format']
);
// insert fake notifications for foreign notifications
foreach ( EchoAttributeManager::$sections as $i => $section ) {
if ( $foreignNotifications->getCount( $section ) > 0 ) {
$result['list'][-$i-1] = $this->makeForeignNotification( $user, $params['format'], $foreignNotifications, $section );
}
}
$this->getResult()->setIndexedTagName( $result['list'], 'notification' );
// 'index' is built on top of 'list'
if ( in_array( 'index', $prop ) ) {
@ -48,12 +64,16 @@ class ApiEchoNotifications extends ApiQueryBase {
$this->getResult()->setIndexedTagName( $result['index'], 'id' );
}
}
// add API endpoint for each of the wikis where notification data
// can be queried from
$result['sources'] = $foreignNotifications->getApiEndpoints( $foreignNotifications->getWikis() );
}
if ( in_array( 'count', $prop ) ) {
$result = array_merge_recursive(
$result,
$this->getPropcount( $user, $params['sections'], $params['groupbysection'] )
$this->getPropCount( $user, $params['sections'], $params['groupbysection'], $foreignNotifications )
);
}
@ -194,19 +214,22 @@ class ApiEchoNotifications extends ApiQueryBase {
* @param User $user
* @param string[] $sections
* @param boolean $groupBySection
* @param EchoForeignNotifications $foreignNotifications
* @return array
*/
protected function getPropCount( User $user, array $sections, $groupBySection ) {
protected function getPropCount( User $user, array $sections, $groupBySection, EchoForeignNotifications $foreignNotifications ) {
$result = array();
$notifUser = MWEchoNotifUser::newFromUser( $user );
// Always get total count
$rawCount = $notifUser->getNotificationCount();
$rawCount += $foreignNotifications->getCount();
$result['rawcount'] = $rawCount;
$result['count'] = EchoNotificationController::formatNotificationCount( $rawCount );
if ( $groupBySection ) {
foreach ( $sections as $section ) {
$rawCount = $notifUser->getNotificationCount( /* $tryCache = */true, DB_SLAVE, $section );
$rawCount += $foreignNotifications->getCount( $section );
$result[$section]['rawcount'] = $rawCount;
$result[$section]['count'] = EchoNotificationController::formatNotificationCount( $rawCount );
}
@ -232,6 +255,42 @@ class ApiEchoNotifications extends ApiQueryBase {
return $result;
}
protected function makeForeignNotification( User $user, $format, EchoForeignNotifications $foreignNotifications, $section ) {
$wikis = $foreignNotifications->getWikis( $section );
$count = $foreignNotifications->getCount( $section );
$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( array(
'section' => $section,
'wikis' => $wikis,
'count' => $count
) );
$row->notification_user = $user->getId();
$row->notification_timestamp = $foreignNotifications->getTimestamp( $section );
$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['sources'] = $wikis;
$output['count'] = $count;
return $output;
}
public function getAllowedParams() {
$sections = EchoAttributeManager::$sections;
$params = array(
@ -270,6 +329,7 @@ class ApiEchoNotifications extends ApiQueryBase {
'special',
),
),
'noforn' => false,
'limit' => array(
ApiBase::PARAM_TYPE => 'limit',
ApiBase::PARAM_DFLT => 20,

View file

@ -0,0 +1,28 @@
<?php
class EchoForeignPresentationModel extends EchoEventPresentationModel {
public function getIconType() {
return 'site';
}
public function getPrimaryLink() {
return false;
}
protected function getHeaderMessageKey() {
$data = $this->event->getExtra();
$section = $data['section'];
return "notification-header-{$this->type}-{$section}";
}
public function getHeaderMessage() {
$msg = parent::getHeaderMessage();
$data = $this->event->getExtra();
$msg->params( reset( $data['wikis'] ) );
$msg->numParams( count( $data['wikis'] ) - 1 );
return $msg;
}
}

View file

@ -40,7 +40,7 @@
* Fetch notifications from the API.
*
* @param {jQuery.Promise} [apiPromise] An existing promise querying the API for notifications.
* This allows us to send an API request external to the DM and have the model
* This allows us to send an API request foreign to the DM and have the model
* handle the operation as if it asked for the request itself, updating all that
* needs to be updated and emitting all proper events.
* @return {jQuery.Promise} A promise that resolves with an object containing the

View file

@ -95,18 +95,18 @@
*
* @param {string} name Symbolic name
* @param {Object} config Configuration details
* @param {boolean} isExternal Is an external API
* @param {boolean} isForeign Is a foreign API
* @throws {Error} If no URL was given for a foreign API
*/
mw.echo.dm.NetworkHandler.prototype.addApiHandler = function ( name, config, isExternal ) {
mw.echo.dm.NetworkHandler.prototype.addApiHandler = function ( name, config, isForeign ) {
var apiConfig;
if ( !this.handlers[ name ] ) {
apiConfig = $.extend( true, {}, { baseParams: this.baseParams, type: this.getType() }, config );
if ( isExternal ) {
if ( isForeign ) {
if ( !config.url ) {
throw new Error( 'External APIs must have a valid url.' );
throw new Error( 'Foreign APIs must have a valid url.' );
}
this.addCustomApiHandler( name, new mw.echo.dm.ForeignAPIHandler( config.url, apiConfig ) );
} else {

View file

@ -54,8 +54,8 @@
// Create notification models for each source
for ( source in this.sources ) {
// Add external API handler
this.networkHandler.addApiHandler( source, { url: this.sources[ source ].url }, true );
// Add foreign API handler
this.networkHandler.addApiHandler( source, { url: this.sources[ source ].url, baseParams: { notnoforn: 1, notfilter: '!read' } }, true );
// Create a notifications model
item = new mw.echo.dm.NotificationsModel(
@ -63,7 +63,7 @@
{
type: this.getType(),
source: source,
external: this.external,
foreign: this.foreign,
title: this.sources[ source ].title,
removeReadNotifications: this.removeReadNotifications
}

View file

@ -21,8 +21,8 @@
* @cfg {boolean} [seen=false] State the seen state of the option
* @cfg {string} [timestamp] Notification timestamp in Mediawiki timestamp format
* @cfg {string} [primaryUrl] Notification primary link in raw url format
* @cfg {boolean} [external=false] This notification is from an external source
* @cfg {string} [source] The source this notification is coming from, if it is external
* @cfg {boolean} [foreign=false] This notification is from a foreign source
* @cfg {string} [source] The source this notification is coming from, if it is foreign
* @cfg {Object[]} [secondaryUrls] An array of objects defining the secondary URLs
* for this notification. The secondary URLs are expected to have this structure:
* {
@ -55,7 +55,7 @@
this.category = config.category || '';
this.type = config.type || 'alert';
this.external = !!config.external;
this.foreign = !!config.foreign;
this.source = config.source || '';
this.iconType = config.iconType;
this.iconURL = config.iconURL;
@ -146,11 +146,11 @@
};
/**
* Check whether this notification item is external
* @return {boolean} Notification item is external
* Check whether this notification item is foreign
* @return {boolean} Notification item is foreign
*/
mw.echo.dm.NotificationItem.prototype.isExternal = function () {
return this.external;
mw.echo.dm.NotificationItem.prototype.isForeign = function () {
return this.foreign;
};
/**

View file

@ -18,7 +18,7 @@
* the source of the notification items for the network handler.
* @cfg {number} [limit=25] Notification limit
* @cfg {string} [userLang] User language
* @cfg {boolean} [external] The model's source is external
* @cfg {boolean} [foreign] The model's source is foreign
* @cfg {boolean} [removeReadNotifications=false] Remove read notifications completely. This
* means the model will only contain unread notifications. This is useful for
* cross-wiki bundled notifications.
@ -39,7 +39,7 @@
this.markingAllAsRead = false;
this.removeReadNotifications = !!config.removeReadNotifications;
this.external = !!config.external;
this.foreign = !!config.foreign;
this.networkHandler = networkHandler;
@ -329,7 +329,7 @@
this.emit( 'updateSeenTime' );
// Only update seenTime in the API locally
if ( !this.isExternal() ) {
if ( !this.isForeign() ) {
promise = this.getApi().updateSeenTime( type );
} else {
promise = $.Deferred().resolve();
@ -365,7 +365,7 @@
this.markingAllAsRead = true;
for ( i = 0, len = items.length; i < len; i++ ) {
if ( !items[ i ].isExternal() ) {
if ( !items[ i ].isForeign() ) {
items[ i ].toggleRead( true );
items[ i ].toggleSeen( true );
}
@ -448,13 +448,13 @@
iconURL: content.iconUrl,
iconType: content.icon,
type: model.getType(),
external: model.isExternal(),
foreign: model.isForeign(),
source: model.getSource(),
primaryUrl: OO.getProp( content.links, 'primary', 'url' ),
secondaryUrls: OO.getProp( content.links, 'secondary' ) || []
};
if ( notifData.type === 'external' ) {
if ( notifData.type === 'foreign' ) {
// Define sources
sources = {};
for ( s = 0; s < notifData.sources.length; s++ ) {
@ -471,11 +471,11 @@
// type. Some types don't have read messages, but
// some do
removeReadNotifications: true,
// Override the external flag to 'true' for cross-wiki
// Override the foreign flag to 'true' for cross-wiki
// notifications.
// For bundles that are not external (like regular
// For bundles that are not foreign (like regular
// bundles of notifications) this flag should be false
external: true,
foreign: true,
count: notifData.count
} )
);
@ -654,12 +654,12 @@
};
/**
* This model is external
* This model is foreign
*
* @return {boolean} Model is external
* @return {boolean} Model is foreign
*/
mw.echo.dm.NotificationsModel.prototype.isExternal = function () {
return this.external;
mw.echo.dm.NotificationsModel.prototype.isForeign = function () {
return this.foreign;
};
} )( mediaWiki, jQuery );