( function ( mw, $ ) { /** * Notification badge button widget for echo popup. * * @class * @extends OO.ui.ButtonWidget * * @constructor * @param {mw.echo.dm.NotificationsModel} model Notifications view model * @param {Object} [config] Configuration object * @cfg {number} [numItems=0] How many items are in the button display * @cfg {boolean} [hasUnseen=false] Whether there are unseen items * @cfg {boolean} [markReadWhenSeen=false] Mark all notifications as read on open * @cfg {number} [popupWidth=450] The width of the popup * @cfg {string|Object} [badgeIcon] The icons to use for this button. * If this is a string, it will be used as the icon regardless of the state. * If it is an object, it must include * the properties 'unseen' and 'seen' with icons attached to both. For example: * { badgeIcon: { * unseen: 'bellOn', * seen: 'bell' * } } * @cfg {string} [href] URL the badge links to * @cfg {jQuery} [$overlay] A jQuery element functioning as an overlay * for popups. */ mw.echo.ui.NotificationBadgeWidget = function MwEchoUiNotificationBadgeButtonPopupWidget( model, config ) { var buttonFlags, allNotificationsButton, preferencesButton, footerButtonGroupWidget, $footer; config = config || {}; config.links = config.links || {}; // Parent constructor mw.echo.ui.NotificationBadgeWidget.parent.call( this, config ); // Mixin constructors OO.ui.mixin.PendingElement.call( this, config ); this.$overlay = config.$overlay || this.$element; // Create a menu overlay this.$menuOverlay = $( '
' ) .addClass( 'mw-echo-ui-NotificationBadgeWidget-overlay-menu' ); this.$overlay.append( this.$menuOverlay ); // View model this.notificationsModel = model; this.type = this.notificationsModel.getType(); this.numItems = config.numItems || 0; this.markReadWhenSeen = !!config.markReadWhenSeen; this.badgeIcon = config.badgeIcon || {}; this.hasRunFirstTime = false; this.currentUnreadCountInBadge = 0; buttonFlags = [ 'primary' ]; if ( !!config.hasUnseen ) { buttonFlags.push( 'unseen' ); } this.badgeButton = new mw.echo.ui.BadgeLinkWidget( { label: this.numItems, flags: buttonFlags, badgeIcon: config.badgeIcon, // The following messages can be used here: // tooltip-pt-notifications-alert // tooltip-pt-notifications-message title: mw.msg( 'tooltip-pt-notifications-' + this.type ), href: config.href } ); // Notifications widget this.notificationsWidget = new mw.echo.ui.NotificationsWidget( this.notificationsModel, { type: this.type, $overlay: this.$menuOverlay, markReadWhenSeen: this.markReadWhenSeen } ); // Footer allNotificationsButton = new OO.ui.ButtonWidget( { icon: 'next', label: mw.msg( 'echo-overlay-link' ), href: config.links.notifications, classes: [ 'mw-echo-ui-notificationBadgeButtonPopupWidget-footer-allnotifs' ] } ); preferencesButton = new OO.ui.ButtonWidget( { icon: 'advanced', label: mw.msg( 'mypreferences' ), href: config.links.preferences, classes: [ 'mw-echo-ui-notificationBadgeButtonPopupWidget-footer-preferences' ] } ); footerButtonGroupWidget = new OO.ui.ButtonGroupWidget( { items: [ allNotificationsButton, preferencesButton ] } ); $footer = $( '
' ) .addClass( 'mw-echo-ui-notificationBadgeButtonPopupWidget-footer' ) .append( footerButtonGroupWidget.$element ); this.popup = new OO.ui.PopupWidget( { $content: this.notificationsWidget.$element, $footer: $footer, width: config.popupWidth || 500, autoClose: true, // Also ignore clicks from the nested action menu items, that // actually exist in the overlay $autoCloseIgnore: this.$element.add( this.$menuOverlay ), head: true, // The following messages can be used here: // echo-notification-alert-text-only // echo-notification-message-text-only label: mw.msg( 'echo-notification-' + this.type + '-text-only' ), classes: [ 'mw-echo-ui-notificationBadgeButtonPopupWidget-popup' ] } ); // HACK: Add an icon to the popup head label this.popupHeadIcon = new OO.ui.IconWidget(); this.popup.$head.prepend( this.popupHeadIcon.$element ); this.setPendingElement( this.popup.$head ); this.updateIcon( !!config.hasUnseen ); // Mark all as read button this.markAllReadButton = new OO.ui.ButtonWidget( { framed: false, label: mw.msg( 'echo-mark-all-as-read' ), classes: [ 'mw-echo-ui-notificationsWidget-markAllReadButton' ] } ); // Hide the close button this.popup.closeButton.toggle( false ); // Add the 'mark all as read' button to the header this.popup.$head.append( this.markAllReadButton.$element ); this.markAllReadButton.toggle( false ); // Events this.markAllReadButton.connect( this, { click: 'onMarkAllReadButtonClick' } ); this.notificationsModel.connect( this, { updateSeenTime: 'updateBadge', add: 'updateBadge', unseenChange: 'updateBadge', unreadChange: 'updateBadge' } ); this.popup.connect( this, { toggle: 'onPopupToggle' } ); this.badgeButton.connect( this, { click: 'onBadgeButtonClick' } ); this.$element .prop( 'id', 'pt-notifications-' + this.type ) // The following classes can be used here: // mw-echo-ui-notificationBadgeButtonPopupWidget-alert // mw-echo-ui-notificationBadgeButtonPopupWidget-message .addClass( 'mw-echo-ui-notificationBadgeButtonPopupWidget ' + 'mw-echo-ui-notificationBadgeButtonPopupWidget-' + this.type ) .append( this.badgeButton.$element, this.popup.$element ); }; /* Initialization */ OO.inheritClass( mw.echo.ui.NotificationBadgeWidget, OO.ui.Widget ); OO.mixinClass( mw.echo.ui.NotificationBadgeWidget, OO.ui.mixin.PendingElement ); /* Static properties */ mw.echo.ui.NotificationBadgeWidget.static.tagName = 'li'; /* Events */ /** * @event allRead * All notifications were marked as read */ /* Methods */ /** * Respond to badge button click */ mw.echo.ui.NotificationBadgeWidget.prototype.onBadgeButtonClick = function () { this.popup.toggle(); }; /** * Update the badge icon with the read/unread versions if they exist. * * @param {boolean} hasUnseen Widget has unseen notifications */ mw.echo.ui.NotificationBadgeWidget.prototype.updateIcon = function ( hasUnseen ) { var icon = typeof this.badgeIcon === 'string' ? this.badgeIcon : this.badgeIcon[ hasUnseen ? 'unseen' : 'seen' ]; this.badgeButton.setIcon( icon ); this.popupHeadIcon.setIcon( icon ); }; /** * Update the badge state and label based on changes to the model */ mw.echo.ui.NotificationBadgeWidget.prototype.updateBadge = function () { var unseenCount = this.notificationsModel.getUnseenCount(), unreadCount = this.notificationsModel.getUnreadCount(), nonBundledUnreadCount = this.notificationsModel.getNonbundledUnreadCount(), wgEchoMaxNotificationCount, badgeLabel; // Update numbers and seen/unseen state // If the popup is open, only allow a "demotion" of the badge // to grey; ignore change of color to 'unseen' if ( this.popup.isVisible() ) { if ( !unseenCount ) { this.badgeButton.setFlags( { unseen: false } ); this.updateIcon( false ); } } else { this.badgeButton.setFlags( { unseen: !!unseenCount } ); this.updateIcon( !!unseenCount ); } // Update badge count if ( !this.markReadWhenSeen || !this.popup.isVisible() || unreadCount < this.currentUnreadCountInBadge ) { wgEchoMaxNotificationCount = mw.config.get( 'wgEchoMaxNotificationCount' ); if ( unreadCount > wgEchoMaxNotificationCount ) { badgeLabel = mw.message( 'echo-notification-count', wgEchoMaxNotificationCount ).text(); } else { badgeLabel = mw.language.convertNumber( unreadCount ); } this.badgeButton.setLabel( badgeLabel ); } // Check if we need to display the 'mark all unread' button this.markAllReadButton.toggle( !this.markReadWhenSeen && nonBundledUnreadCount > 0 ); this.currentUnreadCountInBadge = unreadCount; }; /** * Respond to 'mark all as read' button click */ mw.echo.ui.NotificationBadgeWidget.prototype.onMarkAllReadButtonClick = function () { this.notificationsModel.markAllRead(); }; /** * Populate notifications from the API. * * @param {jQuery.Promise} [fetchingApiRequest] An existing promise for fetching * notifications from the API. This allows us to start fetching notifications * externally. * @return {jQuery.Promise} Promise that is resolved when the notifications populate */ mw.echo.ui.NotificationBadgeWidget.prototype.populateNotifications = function ( fetchingApiRequest ) { var widget = this; // The model retrieves the ongoing promise or returns the existing one that it // has. When the promise is completed successfuly, it nullifies itself so we can // request for it to be rebuilt and the request to the API resent. // However, in the case of an API failure, the promise does not nullify itself. // In that case we also want the model to rebuild the request, so in this condition // we must check both cases. if ( !this.notificationsModel.isFetchingNotifications() || this.notificationsModel.isFetchingErrorState() ) { this.pushPending(); this.markAllReadButton.toggle( false ); return this.notificationsModel.fetchNotifications( fetchingApiRequest ) .then( function ( idArray ) { // Clip again widget.popup.clip(); // Log impressions mw.echo.logger.logNotificationImpressions( this.type, idArray, mw.echo.Logger.static.context.popup ); } ) .always( function () { // Pop pending widget.popPending(); // Nullify the promise; let the user fetch again widget.fetchNotificationsPromise = null; } ); } else { return this.notificationsModel.getFetchNotificationPromise(); } }; /** * Extend the response to button click so we can also update the notification list. */ mw.echo.ui.NotificationBadgeWidget.prototype.onPopupToggle = function ( isVisible ) { var widget = this; if ( !isVisible ) { // If the popup is closing, remove "initiallyUnseen" and leave this.notificationsWidget.resetNotificationItems(); return; } // Log the click event mw.echo.logger.logInteraction( 'ui-badge-link-click', mw.echo.Logger.static.context.badge, null, this.type ); if ( this.hasRunFirstTime ) { // HACK: Clippable doesn't resize the clippable area when // it calculates the new size. Since the popup contents changed // and the popup is "empty" now, we need to manually set its // size to 1px so the clip calculations will resize it properly. // See bug report: https://phabricator.wikimedia.org/T110759 this.popup.$clippable.css( 'height', '1px' ); this.popup.clip(); } // Always populate on popup open. The model and widget should handle // the case where the promise is already underway. this.populateNotifications() .then( function () { var i, items = widget.notificationsWidget.getItems(); if ( widget.popup.isVisible() ) { // Update seen time widget.notificationsModel.updateSeenTime(); // Mark notifications as 'read' if markReadWhenSeen is set to true if ( widget.markReadWhenSeen ) { widget.notificationsModel.autoMarkAllRead(); } // Log impressions for ( i = 0; i < items.length; i++ ) { if ( items[ i ].getModel ) { mw.echo.logger.logInteraction( mw.echo.Logger.static.actions.notificationImpression, mw.echo.Logger.static.context.popup, items[ i ].getModel().getId(), items[ i ].getModel().getCategory() ); } } } } ); this.hasRunFirstTime = true; }; /** * Get the notifications model attached to this widget * * @return {mw.echo.dm.NotificationsModel} Notifications model */ mw.echo.ui.NotificationBadgeWidget.prototype.getModel = function () { return this.notificationsModel; }; } )( mediaWiki, jQuery );