Extension:Echo/Creating a new notification type

MediaWiki version:
1.45
The following instructions are valid for MediaWiki 1.45 and later. This version introduced several new features and some deprecations as part of the work on the built-in notifications framework. For the MediaWiki 1.43 long-term support release and earlier versions, see Extension:Echo/Creating a new notification type (1.43).

The Notifications system allows logged in users to receive alerts and notices about their interactions with connected wikis. In Wikimedia projects, users receive cross-wiki notifications from almost all wikis in the cluster. However, the Notifications system can also be used by third-parties. Third parties can install it on a single wiki or on a series of connected wikis (using the cross-wiki notification system).

Video tutorial on how to create new notification types from the 2017 Wikimedia Developer Summit.

The system allows extension developers the ability to add new notification types that users can receive under certain circumstances. This document will outline the way to create such notifications, explain best practices and formulate the known pitfalls to avoid.

Introduction to how notifications work

edit

Notifications are, very generally, made of two concepts - the Event and the presentation model. The event stores details of the triggered event and the presentation model defines the way it is presented in the user interface.

Event

edit

The Event class defines general events, collects information about the related page, user and wiki and inserts them into the database. Events are general, and one even may define multiple views. For example, mentioning 4 people will create a single event, that will then be referenced (and 'trigger' a notification) for the four mentioned users. All of their individual notifications, however, will reference the same event.

Presentation model

edit

Every type of notification requires a presentation model, a way to define what the notification will display, where it will link to, and what secondary actions it will present. The front-end components of the notifications system (the notifications popup and the Special:Notifications page) read that information to produce a proper display of the notification.

The presentation of a notification type is always based on the base definition in EchoEventPresentationModel. That class defines the base behavior of all notifications, and each child notification must extend it, and then adjust the details to its needs.

How events work

edit

If you are creating a new notification type, you should first create an event. The logical process is:

  1. When relevant, your code creates the event using Event::create()
  2. The event is stored in the database, and as notifications for the relevant users
  3. When users request their notification list from the API, the system looks for the relevant presentation model and creates the data - title, primary link, secondary links, relevant user, etc.
  4. The front-end uses the structured data to present the notification.
This is a slight generalization of the process for the sake of clarity. Explaining how things work in practice with database table rows (per user, wiki, cross-wiki, etc) is outside the scope of this tutorial.

Definitions

edit

Event definition

edit

Event definitions happen in extension.json, or the BeforeCreateEchoEvent hook handler. You need to separately define notification types (required), notification categories (optional), and notification icons (optional).

Defining the event category

edit

Users are generally allowed to check/uncheck the option of receiving notifications in web or email in the preferences. The category name is used as the key, and is what the event definition then refers to in the "category" parameter. Using NotificationCategories in extension.json, you must define a title message for each category (short message used as a label in Special:Preferences under the Notifications tab, and in batched notification emails), and you may also define a tooltip message (used in preferences only, may contain a more detailed description) Remember to create these messages in your localisation files.

Defining the notification icon

edit

Notifications appear with icons. If an icon is not set up, a default icon will appear. If the icon is a new one, it must be loaded and defined using NotificationIcons in extension.json.

Define the icon using the full url of the icon: "url": "//example.org/icon.svg"

Alternatively, you can use a file path that is relative to $wgExtensionAssetsPath : "path": "extensionPath/to/icon.svg"

Defining the event information

edit
Parameter Required? Description
category Required The category this event belongs to.

This is important for displaying in the Special:Preferences page

section Required Defines the section this notification belongs to.

There are two types, Alerts and Notices. Alerts are for urgent notifications that should be dealt with promptly, e.g. a post on the user's talk page. Notices are for notifications that do not have to be dealt with promptly, e.g. a thank. For Alerts, put 'alert'. For Notices, put 'message'

presentation-model Required Class for the presentation model for this event display
user-locators Optional Defines functions that produce the list of users to notify (optional if specifying recipients directly in the Event::create() call).

Usually refers to functions from the UserLocator class, for example:

"user-locators": [
	[
		"MediaWiki\\Extension\\Notifications\\UserLocator::locateFromEventExtra",
		[
			"my-custom-recipients"
		]
	]
]
group Optional Group this with similar notifications.

Known groups in Echo core are: 'positive', 'negative', 'interactive', 'neutral'. If one is not specified, 'neutral' is used.

user-filters[Pitfalls 1] Optional Defines functions that produce the list of users to not notify, in the same format as user-locators.
immediate Optional Whether to use the job queue or not.

Boolean, default is to use the job queue (i.e. false)

Event creation details

edit

When we create and trigger the event, we need to supply details as well:

Parameter Required? Description
type Required The name of the event, the key we used to define the event
title Optional The related page title, if relevant. When the recipient of a notification visits this page, the notification is marked as read. If this page is deleted, the event is hidden for all recipients.
agent Optional The user that originated this notification (note: This is not the user that is being notified!)
extra Optional An array of extra definitions for Echo and the presentation model to use
Common extra keys
revid Optional If specified, and the revision is marked as a minor edit, the event will skip email notifications for this event (unless the enotifminoredits preference is enabled).

Causes some event details to be hidden if the revision is revision-deleted.

target-page Optional Additional page IDs of pages to be handled like the "title" parameter.
Event::RECIPIENTS_IDX Optional User IDs of users who should receive a notification for this event.
any other key Optional Custom keys can be used by your presentation model to display notification details, and by the user locators/filters to define notification recipients.

Presentation model definition

edit

Presentation models are meant to be very flexible, so as to allow the creation of notification types with specific displays. Unlike event definition and creation, the presentation model is a class on its own, and requires extending it and making the methods specific to the notification case. These are the definitions of the base EchoEventPresentationModel class:

Method Default value Description
canRender[Pitfalls 2] true Defines the conditions under which this notification can be rendered.

For example, you can check if a page has been deleted, and if so, don't show the notification. A common use is if the notification uses $this->title of the page anywhere (for header message or for links) the canRender() must check whether $this->title is set.

getIconType Must be overridden Defines the symbolic name of the icon for this notification (as defined in NotificationIcons in extension.json)
getHeaderMessage A message based on default key notification-header-{$this->type} Defines the i18n message for the header
getCompactHeaderMessage If not defined, uses getHeaderMessage() Defines the i18n message for the header in cases where the notification is "compact" (for example, inside a bundle or a cross-wiki group.)
getSubjectMessage If not defined, uses getHeaderMessage() Defines the i18n message for notification email subject line
getBodyMessage false Defines the i18n message for the notification body.

Optional.

getPrimaryLink Must be overridden Defines the primary link for this notification.

For a primary link definition, use the structure ['url' => (string) url, 'label' => (string) link text (non-escaped)] (the 'label' is used for no-JavaScript versions)

If there is no primary link, return false.

getSecondaryLinks[Pitfalls 3] array(); Defines secondary actions
edit

The secondary links information allows notifications to specify sub-links to actions that are extending the main primary link for the notification. For example, a notification about some revision edit can have its primary link point to the revision diff page, but also have secondary links pointing for the user page of the user who made the change, or a link to view the page that was changed.

Secondary links also allow API-directed actions ('dynamic' actions) for actions like 'stop watching a page' or others that require the front-end to request an AJAX request to the API directly from the notification. Further details about dynamic actions are outside the scope of this tutorial.

The general structure of secondary links:

public function getSecondaryLinks() {
    return [
       [
            '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),
        ],
    ];
);

Walkthrough: Creating a new notification type

edit

Let's say we have an extension that keeps a list of users who are interested in new titles created with specific words in them. The extension allows users to add and remove themselves from a watch-list for a certain list of words in a title, and listens to the hook for creating new pages on the wiki to identify relevant pages. When a relevant page is created, the extension code will create an event with the relevant details, and notify the users that are on the list for that combination of words.

This section will demonstrate how to create this new notification type.

Defining the event

edit

We need to make sure our event is defined in the system. This is done using extension attributes in extension.json, or using the BeforeCreateEchoEvent hook, if the event definition is conditional.

Only the Notifications property is required. NotificationCategories and NotificationIcons are optional.

The categories defined by NotificationCategories appear in Special:Preferences (allowing users to customize delivery per-category) and in batched notification emails.

In Notifications, the presentation-model must be a valid PHP class name, while each entry in user-locators and user-filters must be a valid PHP callable (function name).

In NotificationIcons, either url or path should be specified.

    "attributes": {
        "Echo": {
            "NotificationCategories": {
                "my-ext-topic-word-follow": {
                    "priority": 3,
                    "title": "echo-category-title-my-ext-topic-word-follow",
                    "tooltip": "echo-pref-tooltip-my-ext-topic-word-follow"
                }
            },
            "Notifications": {
                "my-ext-topic-word": {
                    "category": "my-ext-topic-word-follow",
                    "group": "positive",
                    "section": "alert",
                    "presentation-model": "MyExtTopicWordPresentationModel",
                    "bundle": {
                        "web": true,
                        "expandable": true
                    }
                }
            },
            "NotificationIcons": {
                "my-ext-topic-word": {
                    "url": "http://example.org/icon.svg"
                    "path": "MyExt/icons/TopicWordNotification.svg"
                }
            }
        }
    }

The hook receives parameters corresponding to these extension.json properties.

class MyExtHooks {
    ...

    public static function onBeforeCreateEchoEvent( &$notifications, &$notificationCategories, &$icons ) {
        if ( $this->myCondition() ) {
            $notifications["my-ext-topic-word"][...] = ...;
        }
    }

    ...
}

You can see another example of this in the Thanks extension.

Choosing notification recipients

edit

The Notifications system will try to notify relevant users, but it can't guess who those are. The code that creates the event needs to supply that information. There are two ways to do this.

Specified directly

edit

If you already know who should receive the notification when you're creating the event, you can pass the list of recipients as a RecipientSet in the second parameter of Event::create().

Specified by locators and filters

edit

If listing the recipients is more complex, you'll need to specify some user-locators in the event definition. You may also specify user-filters to exclude some results from the locators.

To do that, we should create a function that is then inserted into the definition of the new event.

    "attributes": {
        "Echo": {
            "Notifications": {
                "my-ext-topic-word": {
                    ...
                    "user-locators": [
                        "MyExtensionClass::locateUsersInList"
                    ],
                    "user-filters": [
                        "MyExtensionClass::locateMentionedUsers"
                    ]
                }
            }
        }
    }

In this case, we will want to go over the list of users and add them all:

class MyExtensionClass {
    ...

    /**
     * @param Event $event
     * @return array
     */
    public function locateUsersInList( Event $event ) {
        // Get the list of users
        $userIds = $this->getList( $event )->getUsers();
        
        return array_map( function ( $userId ) {
            return User::newFromId( $userId );
        }, $userIds );
    }

    ...
}

You can see an example of filtering out mentioned users in DiscussionTools comment events.

Adding code to trigger the event

edit

Now that we have a way to identify who we want to notify, we need to actually trigger this event. Our hypothetical extension listens to page creation hook and then checks to see if the new title has words that fit the list. However the extension code does this, once it identifies a title that fits a list, it will create an event and notify the relevant users.

The code below skips the actual operation of listening to new page creation and checking the words, and jumps straight into defining the new event:

// ...
// Some code to listen to new page creation, check words
// and find the relevant list
// ...

// Creating the event
Event::create( [
	'type' => 'my-ext-topic-word',
	'title' => $title,
	'extra' => [
		'revid' => $revision->getId(),
		'excerpt' => DiscussionParser::getEditExcerpt( $revision, $this->getLanguage() ),
	],
	'agent' => $currentUser,
], new RecipientSet( $mentionedUser ) );

Creating a presentation model

edit

Now that everything is set up, we can create our presentation model. We will have to extend EchoEventPresentationModel and implement our methods. You can see a few examples of these type of models in several extensions like DiscussionTools [1], Thanks [2] and inside Echo [3][4][5] itself. Here's an example of a presentation model for our hypothetical extension:

<?php
class MyExtTopicWordPresentationModel extends EchoEventPresentationModel {
	public function canRender() {
	    // Define that we have to have the page this is
	    // refering to as a condition to display this
	    // notification
		return (bool)$this->event->getTitle();
	}
	public function getIconType() {
	    // You can use existing icons in Echo icon folder
	    // or define your own through "NotificationIcons" in extension.json
		return 'someIcon';
	}
	public function getHeaderMessage() {
		if ( $this->isBundled() ) {
		    // This is the header message for the bundle that contains
		    // several notifications of this type
			$msg = $this->msg( 'notification-bundle-myext-topic-word' );
			$msg->params( $this->getBundleCount() );
			$msg->params( $this->getTruncatedTitleText( $this->event->getTitle(), true ) );
			$msg->params( $this->getViewingUserForGender() );
			return $msg;
		} else {
		    // This is the header message for individual non-bundle message
			$msg = $this->getMessageWithAgent( 'notification-myext-topic-word' );
			$msg->params( $this->getTruncatedTitleText( $this->event->getTitle(), true ) );
			$msg->params( $this->getViewingUserForGender() );
			return $msg;
		}
	}
	public function getCompactHeaderMessage() {
	    // This is the header message for individual notifications
	    // *inside* the bundle
		$msg = parent::getCompactHeaderMessage();
		$msg->params( $this->getViewingUserForGender() );
		return $msg;
	}
	public function getBodyMessage() {
	    // This is the body message.
        // We will retrieve the edit summary that we added earlier with Event::create().
		$comment = $this->event->getExtraParam( 'excerpt', false );
		if ( $comment ) {
			// Create a dummy message to contain the excerpt.
			$msg = new RawMessage( '$1' );
			$msg->plaintextParams( $comment );
			return $msg;
		}
	}
	public function getPrimaryLink() {
	    // This is the link to the new page
	    $link = $this->getPageLink( $this->event->getTitle(), '', true );
        return $link;
    }

	public function getSecondaryLinks() {
		if ( $this->isBundled() ) {
		    // For the bundle, we don't need secondary actions
			return [];
		} else {
		    // For individual items, display a link to the user
		    // that created this page
			return [ $this->getAgentLink() ];
		}
	}
}

And that's it! Our new notification is now active and will be displayed to the relevant users with the header and body messages and the primary and secondary links we defined.

How to bundle notifications

edit
  • Make sure your bundle config is set to true in the echo event definition:
    "Notifications": {
        "my-ext-topic-word": {
            ...
            "bundle": {
                "web": true,
                "email": true,
                "expandable": true
            },
            ...
        }
    },
  • Add a hook for bundling rules:
public static function onEchoGetBundleRules( $event, &$bundleString ) {
    switch ( $event->getType() ) {
        // Your notification type. This is the key that you set in "Notifications" in extension.json.
        case 'my-ext-topic-word':
            // Which messages go into which bundle. For the same bundle, return the same $bundleString.
            $bundleString = 'my-ext-topic';
        break;
    }
    return true;
}
  • Hook up the function in extension.json:
    "Hooks": {
        "EchoGetBundleRules": [
            "LoginNotifyHooks::onEchoGetBundleRules"
        ]
        // More stuff here
    }
  • You can use $this->isBundled() in your presentation model code to check if you're in a bundled notification, and vary the header and body messages accordingly:
public function getHeaderMessage() {
    if ( $this->isBundled() ) {
        $msg = $this->msg( 'something' );
        return $msg;
    } else {
        ...
    }
}
  • When an expandable bundle is expanded, each sub-notification is rendered individually. By default getHeaderMessage() is displayed, but it is recommended that you supply a shorter message in your presentation model code for this case using getCompactHeaderMessage():
public function getCompactHeaderMessage() {
    return $this->msg( 'somethingshort' );
}

And you're done! Don't forget to add your messages. Also note that bundles may or may not be expandable.

Pitfalls and warnings

edit
  1. The role of user-locators and user-filters is important to make sure we don't produce double-notification from a single event to users. For example, if a new topic is created in Flow, we want to notify all those who follow the board - but we want to ignore users who were mentioned, because 'mention' will produce its own event.
    When you create new events, try to make sure you filter out potential colliding events for your notified users, otherwise, they will receive two notifications for the same event.
  2. Note that canRender is an incredibly important method. On top of affecting whether the notification is displayed, it is also part of the test the Notifications system uses to moderate notifications. For example, if a notification was created about a new mention for a user, but the revision that triggered this was then deleted or suppressed, the notification should vanish as well. The system will check if the notification can be displayed (using, among other things, 'canRender') - if canRender in this case checks that the revision exists, it will return 'false' (because the revision was deleted) which will make sure the notification is flagged for deletion. Not defining canRender correctly can result in errors, as the notification will try to call for details on a missing title or revision, so please take care to define it if it is needed.
  3. getSecondaryLinks must return an array. If your notification only has secondary links in certain cases, please make sure that all other cases return an array and not 'false' or 'null'; unlike getPrimaryLink that returns false for empty value, getSecondaryLinks must return an array.