jenkins-bot has submitted this change and it was merged.

Change subject: Add dynamic secondary actions to notification items
......................................................................


Add dynamic secondary actions to notification items

Allow extensions to add dynamic actions that perform some
API request and display a confirmation message.

Bug: T132975
Change-Id: Ib16d57c3f1a11a9749564c6e2112bf1ca32c55e8
---
M Resources.php
M i18n/en.json
M i18n/qqq.json
M includes/formatters/EventPresentationModel.php
M modules/api/mw.echo.api.APIHandler.js
M modules/api/mw.echo.api.EchoApi.js
M modules/controller/mw.echo.Controller.js
M modules/styles/mw.echo.ui.MenuItemWidget.less
M modules/styles/mw.echo.ui.NotificationItemWidget.less
M modules/ui/mw.echo.ui.ActionMenuPopupWidget.js
M modules/ui/mw.echo.ui.MenuItemWidget.js
M modules/ui/mw.echo.ui.NotificationItemWidget.js
12 files changed, 331 insertions(+), 14 deletions(-)

Approvals:
  Catrope: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/Resources.php b/Resources.php
index e3d4579..bef23fb 100644
--- a/Resources.php
+++ b/Resources.php
@@ -112,6 +112,7 @@
                        'mediawiki.jqueryMsg',
                        'mediawiki.language',
                        'mediawiki.Title',
+                       'mediawiki.notify',
                        // OOJS-UI icons
                        // TODO: We are only using 1-2 icons from each
                        // bundle; split them up to our own bundle so we
@@ -120,6 +121,7 @@
                        'oojs-ui.styles.icons-alerts',
                        'oojs-ui.styles.icons-content',
                        'oojs-ui.styles.icons-interactions',
+                       'oojs-ui.styles.icons-moderation',
                ),
                'messages' => array(
                        'echo-badge-count',
diff --git a/i18n/en.json b/i18n/en.json
index d74363b..9519071 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -121,6 +121,12 @@
        "echo-notification-markasunread": "Mark as unread",
        "echo-notification-markasread-tooltip": "Mark as read",
        "echo-notification-more-options-tooltip": "More options",
+       "notification-dynamic-actions-unwatch": "Stop watching new activity on 
\"$1\"",
+       "notification-dynamic-actions-unwatch-confirmation": "You are no longer 
watching the page \"$1\"",
+       "notification-dynamic-actions-unwatch-confirmation-description": "You 
can watch [$2 this page] anytime.",
+       "notification-dynamic-actions-watch": "Follow new activity on \"$1\"",
+       "notification-dynamic-actions-watch-confirmation": "You are now 
watching the page \"$1\"",
+       "notification-dynamic-actions-watch-confirmation-description": "You can 
stop watching [$2 this page] anytime.",
        "notification-link-text-expand-all": "Expand",
        "notification-link-text-expand-alert-count": "View {{PLURAL:$1|$1 
alert|$1 alerts}}",
        "notification-link-text-expand-notice-count": "View {{PLURAL:$1|$1 
notice|$1 notices}}",
diff --git a/i18n/qqq.json b/i18n/qqq.json
index 2f6403d..1f35e3b 100644
--- a/i18n/qqq.json
+++ b/i18n/qqq.json
@@ -114,6 +114,12 @@
        "echo-notification-markasunread": "Label for the button to mark the 
notification as unread.",
        "echo-notification-markasread-tooltip": "Tooltip for the button to mark 
the notification as read.",
        "echo-notification-more-options-tooltip": "Tooltip for the button to 
show the hidden secondary actions.",
+       "notification-dynamic-actions-watch": "Text for the action offering the 
user to watch a page from within a notification.\n\nParameters:\n* $1 - Page 
name\n* $2 - Page URL",
+       "notification-dynamic-actions-watch-confirmation": "Title for the 
confirmation text for watching a page from within a 
notification.\n\nParameters:\n* $1 - Page name\n* $2 - Page URL",
+       "notification-dynamic-actions-watch-confirmation-description": 
"Description for the confirmation text for watching a page from within a 
notification.\n\nParameters:\n* $1 - Page name\n* $2 - Page URL",
+       "notification-dynamic-actions-unwatch": "Text for the action offering 
the user to unwatch a page from within a notification.\n\nParameters:\n* $1 - 
Page name\n* $2 - Page URL",
+       "notification-dynamic-actions-unwatch-confirmation": "Title for the 
confirmation text for unwatching a page from within a 
notification.\n\nParameters:\n* $1 - Page name\n* $2 - Page URL",
+       "notification-dynamic-actions-unwatch-confirmation-description": 
"Description for the confirmation text for unwatching a page from within a 
notification.\n\nParameters:\n* $1 - Page name\n* $2 - Page URL",
        "notification-link-text-expand-all": "Label for the button that expands 
a bundled notification.\n{{Identical|Expand}}",
        "notification-link-text-expand-alert-count": "Label for the button that 
expands a bundled alert notification.\n\nParameters:\n* $1 - The count of 
messages that the bundle holds.\n{{Identical|View alert}}",
        "notification-link-text-expand-notice-count": "Label for the button 
that expands a bundled notice notification.\n\nParameters:\n* $1 - The count of 
messages that the bundle holds.",
diff --git a/includes/formatters/EventPresentationModel.php 
b/includes/formatters/EventPresentationModel.php
index cb692e6..0b4b44a 100644
--- a/includes/formatters/EventPresentationModel.php
+++ b/includes/formatters/EventPresentationModel.php
@@ -107,6 +107,15 @@
        }
 
        /**
+        * Get the user receiving the notification
+        *
+        * @return User
+        */
+       final public function getUser() {
+               return $this->user;
+       }
+
+       /**
         * Get the category of event
         *
         * @return string
@@ -433,7 +442,26 @@
         *               [['url' => (string) url,
         *                 'label' => (string) link text (non-escaped),
         *                 'description' => (string) descriptive text 
(non-escaped),
-        *                 'icon' => (bool|string) symbolic icon name (or false 
if there is none),
+        *                 '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 exmaple:
+        *                 'data' => [
+        *                     'tokenType' => 'watch',
+        *                     'params' => [
+        *                         'action' => 'watch',
+        *                         'titles' => 'Namespace:SomeTitle'
+        *                     ],
+        *                     'messages' => [
+        *                         'item' => [
+        *                              'title' => 'message (parsed as HTML)',
+        *                              'description' => 'optional message 
(parsed as HTML)'
+        *                         ],
+        *                         '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)],
         *                ...]
@@ -548,4 +576,88 @@
                        '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 array $data Action data
+        * @param array $query
+        * @return array Array compatible with the structure of
+        *  secondary links
+        */
+       final protected function getDynamicActionLink( Title $title, $icon, 
$data = array(), $query = array() ) {
+               if ( !$icon && $title->getNamespace() === NS_USER_TALK ) {
+                       $icon = 'userSpeechBubble';
+               } elseif ( !$icon && $title->isTalkPage() ) {
+                       $icon = 'speechBubbles';
+               } elseif( !$icon ) {
+                       $icon = 'article';
+               }
+
+               return array(
+                       'type' => 'dynamic-action',
+                       '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 = array(
+                       'tokenType' => 'watch',
+                       'params' => array(
+                               'action' => 'watch',
+                               'titles' => $title->getPrefixedText(),
+                       ),
+                       'messages' => array(
+                               'item' => array(
+                                       // notification-dynamic-actions-watch
+                                       // notification-dynamic-actions-unwatch
+                                       'title' => $this
+                                               ->msg( 
'notification-dynamic-actions-' . $availableAction )
+                                               ->params(
+                                                       
$title->getPrefixedText(),
+                                                       $title->getFullURL( 
$query )
+                                               ),
+                               ),
+                               'confirmation' => array(
+                                       // 
notification-dynamic-actions-watch-confirmation
+                                       // 
notification-dynamic-actions-unwatch-confirmation
+                                       'title' => $this
+                                               ->msg( 
'notification-dynamic-actions-' . $availableAction . '-confirmation' ),
+                                       // 
notification-dynamic-actions-watch-confirmation-description
+                                       // 
notification-dynamic-actions-unwatch-confirmation-description
+                                       'description' => $this
+                                               ->msg( 
'notification-dynamic-actions-' . $availableAction . 
'-confirmation-description' )
+                                               ->params(
+                                                       
$title->getPrefixedText(),
+                                                       $title->getFullURL( 
$query )
+                                               ),
+                               ),
+                       ),
+               );
+
+               // "Unwatching" action requires another parameter
+               if ( $isTitleWatched ) {
+                       $data[ 'params' ][ 'unwatch' ] = 1;
+               }
+
+               return $this->getDynamicActionLink(
+                       $title,
+                       $isTitleWatched ? 'unStar' : 'star',
+                       $data,
+                       array( 'action' => $availableAction )
+               );
+       }
 }
diff --git a/modules/api/mw.echo.api.APIHandler.js 
b/modules/api/mw.echo.api.APIHandler.js
index 7186c7d..db7aee6 100644
--- a/modules/api/mw.echo.api.APIHandler.js
+++ b/modules/api/mw.echo.api.APIHandler.js
@@ -57,6 +57,21 @@
        mw.echo.api.APIHandler.prototype.fetchNotifications = null;
 
        /**
+        * Send a general query to the API. This is mostly for dynamic actions
+        * where other extensions may set up API actions that are unique and
+        * unanticipated.
+        *
+        * @param {Object} data Data object about the operation.
+        * @param {string} [data.tokenType=csrf] Token type, 'csrf', 'watch', 
etc
+        * @param {Object} [data.params] Parameters to pass to the API call
+        * @return {jQuery.Promise} Promise that is resolved when the action
+        *  is complete
+        */
+       mw.echo.api.APIHandler.prototype.queryAPI = function ( data ) {
+               return this.api.postWithToken( data.tokenType || 'csrf', 
data.params );
+       };
+
+       /**
         * Fetch all pages with unread notifications in them per wiki
         *
         * @param {string|string[]} [sources=*] Requested sources. If not given
diff --git a/modules/api/mw.echo.api.EchoApi.js 
b/modules/api/mw.echo.api.EchoApi.js
index a2d219f..3ea96b7 100644
--- a/modules/api/mw.echo.api.EchoApi.js
+++ b/modules/api/mw.echo.api.EchoApi.js
@@ -284,6 +284,22 @@
        };
 
        /**
+        * Send a general query to the API. This is mostly for dynamic actions
+        * where other extensions may set up API actions that are unique and
+        * unanticipated.
+        *
+        * @param {Object} params API parameters
+        * @param {string} [source='local'] Requested source to query
+        * @return {jQuery.Promise} Promise that is resolved when the action
+        *  is complete
+        */
+       mw.echo.api.EchoApi.prototype.queryAPI = function ( params, source ) {
+               source = source || 'local';
+               return this.network.getApiHandler( source )
+                       .queryAPI( params );
+       };
+
+       /**
         * Check whether the API promise for fetch notification is in an error
         * state for the given source and notification type.
         *
diff --git a/modules/controller/mw.echo.Controller.js 
b/modules/controller/mw.echo.Controller.js
index b2fdd70..29bb92f 100644
--- a/modules/controller/mw.echo.Controller.js
+++ b/modules/controller/mw.echo.Controller.js
@@ -774,6 +774,20 @@
 
                return this.updateSeenTime( currSource );
        };
+
+       /**
+        * Perform a dynamic action
+        *
+        * @param {Object} data Action data for the network
+        * @param {string} [source] Requested source to query. Defaults to 
currently
+        *  selected source.
+        * @return {jQuery.Promise} jQuery promise that resolves when the 
action is done
+        */
+       mw.echo.Controller.prototype.performDynamicAction = function ( data, 
source ) {
+               source = source || 
this.manager.getFiltersModel().getSourcePagesModel().getCurrentSource();
+               return this.api.queryAPI( data, source );
+       };
+
        /**
         * Get the types associated with the controller and model
         *
diff --git a/modules/styles/mw.echo.ui.MenuItemWidget.less 
b/modules/styles/mw.echo.ui.MenuItemWidget.less
index ed55ce7..044bffd 100644
--- a/modules/styles/mw.echo.ui.MenuItemWidget.less
+++ b/modules/styles/mw.echo.ui.MenuItemWidget.less
@@ -37,6 +37,24 @@
                padding: 0;
        }
 
+       &-dynamic-action {
+               padding: 1.5em;
+               &:hover {
+                       background: white;
+               }
+
+               .mw-echo-ui-menuItemWidget-content 
span.oo-ui-labelElement-label {
+                       white-space: normal;
+                       overflow: visible;
+               }
+
+               .mw-echo-ui-menuItemWidget-content {
+                       &-description {
+                               padding-top: 1em;
+                       }
+               }
+       }
+
        // Correct for when inside the popup menu
        .mw-echo-ui-actionMenuPopupWidget-menu & {
                display: block;
diff --git a/modules/styles/mw.echo.ui.NotificationItemWidget.less 
b/modules/styles/mw.echo.ui.NotificationItemWidget.less
index 89dc7e9..3953a49 100644
--- a/modules/styles/mw.echo.ui.NotificationItemWidget.less
+++ b/modules/styles/mw.echo.ui.NotificationItemWidget.less
@@ -19,6 +19,19 @@
                text-decoration: none;
        }
 
+       // Dynamic-action messages in mw.notify
+       &-notify {
+               &-title {
+                       font-weight: bold;
+                       font-size: 1.2em;
+               }
+
+               &-description {
+                       font-size: 1em;
+                       color: @grey-medium;
+               }
+       }
+
        &-icon {
                img {
                        height: 30px;
diff --git a/modules/ui/mw.echo.ui.ActionMenuPopupWidget.js 
b/modules/ui/mw.echo.ui.ActionMenuPopupWidget.js
index 1cbbd94..fda2a3a 100644
--- a/modules/ui/mw.echo.ui.ActionMenuPopupWidget.js
+++ b/modules/ui/mw.echo.ui.ActionMenuPopupWidget.js
@@ -40,7 +40,11 @@
 
                // Events
                this.connect( this, { click: 'onAction' } );
-
+               this.getMenu().connect( this, {
+                       remove: 'decideToggle',
+                       add: 'decideToggle',
+                       clear: 'decideToggle'
+               } );
                // Initialization
                this.$element
                        .addClass( 'mw-echo-ui-actionMenuPopupWidget' );
@@ -67,6 +71,14 @@
        };
 
        /**
+        * Decide whether the menu should be visible, based on whether it is
+        * empty or not.
+        */
+       mw.echo.ui.ActionMenuPopupWidget.prototype.decideToggle = function () {
+               this.toggle( !this.getMenu().isEmpty() );
+       };
+
+       /**
         * Get the widget's action menu
         *
         * @return {OO.ui.FloatingMenuSelectWidget} Menu
diff --git a/modules/ui/mw.echo.ui.MenuItemWidget.js 
b/modules/ui/mw.echo.ui.MenuItemWidget.js
index 8fccce7..9ccb338 100644
--- a/modules/ui/mw.echo.ui.MenuItemWidget.js
+++ b/modules/ui/mw.echo.ui.MenuItemWidget.js
@@ -4,6 +4,8 @@
         *
         * @class
         * @extends OO.ui.Widget
+        * @mixins OO.ui.mixin.IconElement
+        * @mixins OO.ui.mixin.PendingElement
         *
         * @constructor
         * @param {Object} [config] Configuration object
@@ -20,20 +22,36 @@
 
                // Mixin constructors
                OO.ui.mixin.IconElement.call( this, config );
+               OO.ui.mixin.PendingElement.call( this, config );
 
+               this.dynamic = config.type === 'dynamic-action';
                this.prioritized = !!config.prioritized;
+               this.messages = this.isDynamicAction() ?
+                       config.actionData.messages :
+                       {
+                               item: {
+                                       title: config.label,
+                                       description: config.description
+                               }
+                       };
+
+               this.actionData = config.actionData || {};
+
+               // Label
+               this.setLabel( this.messages.item.title );
 
                // Optional description
                this.descriptionLabel = new OO.ui.LabelWidget( {
                        classes: [ 
'mw-echo-ui-menuItemWidget-content-description' ],
-                       label: config.description || ''
+                       label: this.messages.item.description || ''
                } );
-               this.descriptionLabel.toggle( !this.prioritized );
+               this.descriptionLabel.toggle( !this.prioritized && 
this.messages.item.description );
 
                // Build the option
                this.$element
                        .addClass( 'mw-echo-ui-menuItemWidget' )
                        .toggleClass( 'mw-echo-ui-menuItemWidget-prioritized', 
this.prioritized )
+                       .toggleClass( 
'mw-echo-ui-menuItemWidget-dynamic-action', this.isDynamicAction() )
                        .append(
                                this.$icon
                                        .addClass( 
'mw-echo-ui-menuItemWidget-icon' ),
@@ -46,7 +64,7 @@
                                        )
                        );
 
-               if ( config.url ) {
+               if ( config.url && !this.isDynamicAction() ) {
                        this.hasLink = true;
                        this.$element.contents()
                                .wrapAll(
@@ -67,6 +85,7 @@
 
        OO.inheritClass( mw.echo.ui.MenuItemWidget, OO.ui.OptionWidget );
        OO.mixinClass( mw.echo.ui.MenuItemWidget, OO.ui.mixin.IconElement );
+       OO.mixinClass( mw.echo.ui.MenuItemWidget, OO.ui.mixin.PendingElement );
 
        /* Static Properties */
 
@@ -76,9 +95,52 @@
        /* Methods */
 
        mw.echo.ui.MenuItemWidget.prototype.isSelectable = function () {
-               // If we have a link, force selectability to false, otherwise 
defer to parent method
+               // If we have a link force selectability to false, otherwise 
defer to parent method
+               // Without a link (for dynamic actions or specific internal 
actions) we need this widget
+               // to be selectable so it emits the 'choose' event
                return !this.hasLink && 
mw.echo.ui.MenuItemWidget.parent.prototype.isSelectable.apply( this, arguments 
);
        };
 
+       /**
+        * Check whether this item is prioritized
+        *
+        * @return {boolean} Item is prioritized
+        */
+       mw.echo.ui.MenuItemWidget.prototype.isPrioritized = function () {
+               return this.prioritized;
+       };
+
+       /**
+        * Get the messages for the confirmation dialog
+        * We expect optionally two messages - title and description.
+        *
+        * NOTE: The messages are parsed as HTML. If user-input is expected
+        * please make sure to properly escape it.
+        *
+        * @return {Object} Messages for the confirmation dialog
+        * @return {string} return.title Title for the confirmation dialog
+        * @return {string} return.description Description for the confirmation 
dialog
+        */
+       mw.echo.ui.MenuItemWidget.prototype.getConfirmationMessages = function 
() {
+               return this.messages.confirmation;
+       };
+
+       /**
+        * Get the action data associated with this item
+        *
+        * @return {Object} Action data
+        */
+       mw.echo.ui.MenuItemWidget.prototype.getActionData = function () {
+               return this.actionData;
+       };
+
+       /**
+        * This item is a dynamic action
+        *
+        * @return {boolean} Item is a dynamic action
+        */
+       mw.echo.ui.MenuItemWidget.prototype.isDynamicAction = function () {
+               return this.dynamic;
+       };
 } )( mediaWiki, jQuery );
 
diff --git a/modules/ui/mw.echo.ui.NotificationItemWidget.js 
b/modules/ui/mw.echo.ui.NotificationItemWidget.js
index b2e4cc0..9f6d736 100644
--- a/modules/ui/mw.echo.ui.NotificationItemWidget.js
+++ b/modules/ui/mw.echo.ui.NotificationItemWidget.js
@@ -133,6 +133,8 @@
                        isOutsideMenu = !this.bundle && urlObj.prioritized !== 
undefined && outsideMenuItemCounter < mw.echo.config.maxPrioritizedActions;
 
                        linkButton = new mw.echo.ui.MenuItemWidget( {
+                               type: urlObj.type,
+                               actionData: urlObj.data,
                                icon: urlObj.icon || 'next',
                                label: urlObj.label,
                                tooltip: urlObj.tooltip,
@@ -211,17 +213,56 @@
        };
 
        /**
-        * Respond to selecting an item from the popup button widget
+        * Manage a click on a dynamic secondary link.
+        * We can't know what the link intends us to do in the API, so we trust 
the 'apiParams'
+        * to tell the controller. When the link is clicked, we will pass the 
information on
+        * to the controller, which will manage whatever promise and action is 
needed.
+        *
+        * NOTE: The messages are parsed as HTML. If user-input is expected
+        * please make sure to properly escape it.
+        *
+        * @param {mw.echo.ui.MenuItemWidget} item The selected item
         */
        mw.echo.ui.NotificationItemWidget.prototype.onPopupButtonWidgetChoose = 
function ( item ) {
-               var action = item && item.getData();
+               var actionData = item && item.getActionData(),
+                       messages = item && item.getConfirmationMessages(),
+                       widget = this;
 
-               if ( action === 'toggleRead' ) {
-                       // If we're marking read or unread, the notification 
was already seen.
-                       // Remove the animation class
-                       this.$element.removeClass( 
'mw-echo-ui-notificationItemWidget-initiallyUnseen' );
-                       this.markRead( !this.model.isRead() );
-               }
+               // Send to controller
+               item.pushPending();
+               this.controller.performDynamicAction( actionData, 
this.getModel().getSource() )
+                       .then( function () {
+                               var $title = $( '<p>' )
+                                               .addClass( 
'mw-echo-ui-notificationItemWidget-notify-title' )
+                                               .append( $.parseHTML( 
messages.title ) ),
+                                       $description = $( '<p>' )
+                                               .addClass( 
'mw-echo-ui-notificationItemWidget-notify-description' )
+                                               .append( $.parseHTML( 
messages.description ) ),
+                                       $confirmation;
+
+                               // Get rid of the button
+                               item.disconnect( this );
+                               if ( item.isPrioritized() ) {
+                                       
widget.actionsButtonSelectWidget.removeItems( [ item ] );
+                               } else {
+                                       // It's inside the popup menu
+                                       
widget.menuPopupButtonWidget.getMenu().removeItems( [ item ] );
+                               }
+
+                               // Make sure to hide either piece if it is empty
+                               $title.toggle( !!$title.text() );
+                               $description.toggle( !!$description.text() );
+
+                               // Display confirmation
+                               $confirmation = $( '<div>' )
+                                       .append( $title, $description );
+
+                               // Send to mw.notify
+                               mw.notify( $confirmation );
+                       } );
+
+               // Prevent the click propagation
+               return false;
        };
 
        /**

-- 
To view, visit https://gerrit.wikimedia.org/r/303314
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: Ib16d57c3f1a11a9749564c6e2112bf1ca32c55e8
Gerrit-PatchSet: 20
Gerrit-Project: mediawiki/extensions/Echo
Gerrit-Branch: master
Gerrit-Owner: Mooeypoo <[email protected]>
Gerrit-Reviewer: Catrope <[email protected]>
Gerrit-Reviewer: Mattflaschen <[email protected]>
Gerrit-Reviewer: Mooeypoo <[email protected]>
Gerrit-Reviewer: Sbisson <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to