( function () { // The name of the page to watch or unwatch const pageTitle = mw.config.get( 'wgRelevantPageName' ), isWatchlistExpiryEnabled = require( './config.json' ).WatchlistExpiry, // Use Object.create( null ) instead of {} to get an Object without predefined properties. // This avoids problems if the title is 'hasOwnPropery' or similar. Bug: T342137 watchstarsByTitle = Object.create( null ); /** * Update the link text, link href attribute and (if applicable) "loading" class. * * @param {jQuery} $link Anchor tag of (un)watch link * @param {string} action One of 'watch', 'unwatch' * @param {string} [state='idle'] 'idle' or 'loading'. Default is 'idle' * @param {string} [expiry='infinity'] The expiry date if a page is being watched temporarily. * @private */ function updateWatchLinkAttributes( $link, action, state, expiry ) { // A valid but empty jQuery object shouldn't throw a TypeError if ( !$link.length ) { return; } expiry = expiry || 'infinity'; // Invalid actions shouldn't silently turn the page in an unrecoverable state if ( action !== 'watch' && action !== 'unwatch' ) { throw new Error( 'Invalid action' ); } const otherAction = action === 'watch' ? 'unwatch' : 'watch'; const $li = $link.closest( 'li' ); if ( state !== 'loading' ) { // jQuery event, @deprecated in 1.38 // Trigger a 'watchpage' event for this List item. // NB: A expiry of 'infinity' is cast to null here, but not above $li.trigger( 'watchpage.mw', [ otherAction, mw.util.isInfinity( expiry ) ? null : expiry ] ); } let tooltipAction = action; let daysLeftExpiry = null; let watchExpiry = null; // Checking to see what if the expiry is set or indefinite to display the correct message if ( isWatchlistExpiryEnabled && action === 'unwatch' ) { if ( mw.util.isInfinity( expiry ) ) { // Resolves to tooltip-ca-unwatch message tooltipAction = 'unwatch'; } else { const expiryDate = new Date( expiry ); const currentDate = new Date(); // Using the Math.ceil function instead of floor so when, for example, a user selects one week // the tooltip shows 7 days instead of 6 days (see Phab ticket T253936) daysLeftExpiry = Math.ceil( ( expiryDate - currentDate ) / ( 1000 * 60 * 60 * 24 ) ); if ( daysLeftExpiry > 0 ) { // Resolves to tooltip-ca-unwatch-expiring message tooltipAction = 'unwatch-expiring'; } else { // Resolves to tooltip-ca-unwatch-expiring-hours message tooltipAction = 'unwatch-expiring-hours'; } watchExpiry = expiryDate.toISOString(); } } const msgKey = state === 'loading' ? action + 'ing' : action; // The following messages can be used here: // * watch // * watching // * unwatch // * unwatching const msg = mw.msg( msgKey ); const link = $link.get( 0 ); if ( link.children.length > 1 && link.lastElementChild.tagName === 'SPAN' ) { // Handle updated button markup, // where the watchstar contains an icon element and a span element containing the text link.lastElementChild.textContent = msg; } else { link.textContent = msg; } $link.toggleClass( 'loading', state === 'loading' ) // The following messages can be used here: // * tooltip-ca-watch // * tooltip-ca-unwatch // * tooltip-ca-unwatch-expiring // * tooltip-ca-unwatch-expiring-hours .attr( 'title', mw.msg( 'tooltip-ca-' + tooltipAction, daysLeftExpiry ) ) .updateTooltipAccessKeys() .attr( 'href', mw.util.getUrl( pageTitle, { action: action } ) ) .attr( 'data-mw-expiry', watchExpiry ); $li.toggleClass( 'mw-watchlink-temp', expiry !== null && expiry !== 'infinity' ); // Most common ID style if ( state !== 'loading' && $li.prop( 'id' ) === 'ca-' + otherAction ) { $li.prop( 'id', 'ca-' + action ); } } /** * Notify hooks listeners of the new page watch status * * Watchstars should not need to use this hook, as they are updated via * callback, and automatically kept in sync if a watchstar with the same * title is changed. * * This hook should by used by other interfaces that care if the watch * status of the page has changed, e.g. an edit form which wants to * update a 'watch this page' checkbox. * * Users which change the watch status of the page without using a * watchstar (e.g. edit forms again) should use the updatePageWatchStatus * method to ensure watchstars are updated and this hook is fired. * * @param {boolean} isWatched The page is watched * @param {string} [expiry='infinity'] The expiry date if a page is being watched temporarily. * @param {string} [expirySelected='infinite'] The expiry length that was just selected from a dropdown, e.g. '1 week' * @private */ function notifyPageWatchStatus( isWatched, expiry, expirySelected ) { expiry = expiry || 'infinity'; expirySelected = expirySelected || 'infinite'; /** * Fires when the page watch status has changed. * * @event ~'wikipage.watchlistChange' * @memberof Hooks * @param {boolean} isWatched * @param {string} expiry The expiry date if the page is being watched temporarily. * @param {string} expirySelected The expiry length that was selected from a dropdown, e.g. '1 week' * @example * mw.hook( 'wikipage.watchlistChange' ).add( ( isWatched, expiry, expirySelected ) => { * // Do things * } ); */ mw.hook( 'wikipage.watchlistChange' ).fire( isWatched, expiry, expirySelected ); } /** * Update the page watch status. * * @memberof module:mediawiki.page.watch.ajax * @param {boolean} isWatched The page is watched * @param {string} [expiry='infinity'] The expiry date if a page is being watched temporarily. * @param {string} [expirySelected='infinite'] The expiry length that was just selected from a dropdown, e.g. '1 week' * @fires Hooks~'wikipage.watchlistChange' * @stable */ function updatePageWatchStatus( isWatched, expiry, expirySelected ) { // Update all watchstars associated with the current page ( watchstarsByTitle[ pageTitle ] || [] ).forEach( ( w ) => { w.update( isWatched, expiry ); } ); notifyPageWatchStatus( isWatched, expiry, expirySelected ); } /** * Update the link text, link `href` attribute and (if applicable) "loading" class. * * For an individual link being set to 'loading', the first * argument can be a jQuery collection. When updating to an * "idle" state, an {@link mw.Title} object should be passed to that * all watchstars associated with that title are updated. * * @memberof module:mediawiki.page.watch.ajax * @param {mw.Title|jQuery} titleOrLink Title of watchlinks to update (when state is idle), or an individual watchlink * @param {string} action One of 'watch', 'unwatch' * @param {string} [state="idle"] 'idle' or 'loading'. Default is 'idle' * @param {string} [expiry='infinity'] The expiry date if a page is being watched temporarily. * @param {string} [expirySelected='infinite'] The expiry length that was just selected from a dropdown, e.g. '1 week' * @fires Hooks~'wikipage.watchlistChange' * @stable */ function updateWatchLink( titleOrLink, action, state, expiry, expirySelected ) { if ( titleOrLink instanceof $ ) { updateWatchLinkAttributes( titleOrLink, action, state, expiry ); } else { // Assumed state is 'idle' when update a group of watchstars by title const isWatched = action === 'unwatch'; const normalizedTitle = titleOrLink.getPrefixedDb(); ( watchstarsByTitle[ normalizedTitle ] || [] ).forEach( ( w ) => { w.update( isWatched, expiry, expirySelected ); } ); if ( normalizedTitle === pageTitle ) { notifyPageWatchStatus( isWatched, expiry, expirySelected ); } } } /** * TODO: This should be moved somewhere more accessible. * * @param {string} url * @return {string} The extracted action, defaults to 'view' * @private */ function mwUriGetAction( url ) { // TODO: Does MediaWiki give action path or query param // precedence? If the former, move this to the bottom const action = mw.util.getParamValue( 'action', url ); if ( action !== null ) { return action; } const actionPaths = mw.config.get( 'wgActionPaths' ); for ( const key in actionPaths ) { let parts = actionPaths[ key ].split( '$1' ); parts = parts.map( mw.util.escapeRegExp ); const m = new RegExp( parts.join( '(.+)' ) ).exec( url ); if ( m && m[ 1 ] ) { return key; } } return 'view'; } /** * @private */ function init() { let $pageWatchLinks = $( '.mw-watchlink a[data-mw="interface"], a.mw-watchlink[data-mw="interface"]' ); if ( !$pageWatchLinks.length ) { // Fallback to the class-based exclusion method for backwards-compatibility $pageWatchLinks = $( '.mw-watchlink a, a.mw-watchlink' ); // Restrict to core interfaces, ignore user-generated content $pageWatchLinks = $pageWatchLinks.filter( ':not( #bodyContent *, #content * )' ); } if ( $pageWatchLinks.length ) { watchstar( $pageWatchLinks, pageTitle ); } } /** * Class representing an individual watchstar * * @param {jQuery} $link Watch element * @param {mw.Title} title Title * @param {module:mediawiki.page.watch.ajax~callback} [callback] * @private */ function Watchstar( $link, title, callback ) { this.$link = $link; this.title = title; this.callback = callback; } /** * Update the watchstar * * @param {boolean} isWatched The page is watched * @param {string} [expiry='infinity'] The expiry date if a page is being watched temporarily. * @private */ Watchstar.prototype.update = function ( isWatched, expiry ) { expiry = expiry || 'infinity'; updateWatchLinkAttributes( this.$link, isWatched ? 'unwatch' : 'watch', 'idle', expiry ); if ( this.callback ) { /** * @callback module:mediawiki.page.watch.ajax~callback * @param {jQuery} $link The element being manipulated. * @param {boolean} isWatched Whether the page is now watched. * @param {string} expiry The expiry date if the page is being watched temporarily, * or an 'infinity'-like value (see [mw.util.isIninity()]{@link module:mediawiki.util.isInfinity}) */ this.callback( this.$link, isWatched, expiry ); } }; /** * Bind a given watchstar element to make it interactive. * * This is meant to allow binding of watchstars for arbitrary page titles, * especially if different from the currently viewed page. As such, this function * will *not* synchronise its state with any "Watch this page" checkbox such as * found on the "Edit page" and "Publish changes" forms. The caller should either make * "current page" watchstars picked up by init (and not use this function) or sync it manually * from the callback this function provides. * * @memberof module:mediawiki.page.watch.ajax * @param {jQuery} $links One or more anchor elements that must have an href * with a URL containing a `action=watch` or `action=unwatch` query parameter, * from which the current state will be learned (e.g. link to unwatch is currently watched) * @param {string} title Title of page that this watchstar will affect * @param {module:mediawiki.page.watch.ajax~callback} [callback] Callback to run after the action has been * processed and API request completed. * @stable */ function watchstar( $links, title, callback ) { // Set up the ARIA connection between the watch link and the notification. // This is set outside the click handler so that it's already present when the user clicks. const notificationId = 'mw-watchlink-notification'; const mwTitle = mw.Title.newFromText( title ); if ( !mwTitle ) { return; } const normalizedTitle = mwTitle.getPrefixedDb(); watchstarsByTitle[ normalizedTitle ] = watchstarsByTitle[ normalizedTitle ] || []; $links.each( function () { watchstarsByTitle[ normalizedTitle ].push( new Watchstar( $( this ), mwTitle, callback ) ); } ); $links.attr( 'aria-controls', notificationId ); // Add click handler. $links.on( 'click', function ( e ) { const action = mwUriGetAction( this.href ); if ( !mwTitle || ( action !== 'watch' && action !== 'unwatch' ) ) { // Let native browsing handle the link return true; } e.preventDefault(); e.stopPropagation(); const $link = $( this ); // eslint-disable-next-line no-jquery/no-class-state if ( $link.hasClass( 'loading' ) ) { return; } updateWatchLinkAttributes( $link, action, 'loading' ); // Preload the notification module for mw.notify const modulesToLoad = [ 'mediawiki.notification' ]; // Preload watchlist expiry widget so it runs in parallel with the api call if ( isWatchlistExpiryEnabled ) { modulesToLoad.push( 'mediawiki.watchstar.widgets' ); } mw.loader.load( modulesToLoad ); const api = new mw.Api(); api[ action ]( title ) .done( ( watchResponse ) => { const isWatched = watchResponse.watched === true; let message = isWatched ? 'addedwatchtext' : 'removedwatchtext'; if ( mwTitle.isTalkPage() ) { message += '-talk'; } let notifyPromise; let watchlistPopup; // @since 1.35 - pop up notification will be loaded with OOUI // only if Watchlist Expiry is enabled if ( isWatchlistExpiryEnabled ) { if ( isWatched ) { // The message should include `infinite` watch period message = mwTitle.isTalkPage() ? 'addedwatchindefinitelytext-talk' : 'addedwatchindefinitelytext'; } notifyPromise = mw.loader.using( 'mediawiki.watchstar.widgets' ).then( ( require ) => { const WatchlistExpiryWidget = require( 'mediawiki.watchstar.widgets' ); if ( !watchlistPopup ) { watchlistPopup = new WatchlistExpiryWidget( action, title, updateWatchLink, { // The following messages can be used here: // * addedwatchindefinitelytext-talk // * addedwatchindefinitelytext // * removedwatchtext-talk // * removedwatchtext message: mw.message( message, mwTitle.getPrefixedText() ).parseDom(), $link: $link } ); } mw.notify( watchlistPopup.$element, { tag: 'watch-self', id: notificationId, autoHideSeconds: 'short' } ); } ); } else { // The following messages can be used here: // * addedwatchtext-talk // * addedwatchtext // * removedwatchtext-talk // * removedwatchtext notifyPromise = mw.notify( mw.message( message, mwTitle.getPrefixedText() ).parseDom(), { tag: 'watch-self', id: notificationId } ); } // The notifications are stored as a promise and the watch link is only updated // once it is resolved. Otherwise, if $wgWatchlistExpiry set, the loading of // OOUI could cause a race condition and the link is updated before the popup // actually is shown. See T263135 notifyPromise.always( () => { // Update all watchstars associated with this title watchstarsByTitle[ normalizedTitle ].forEach( ( w ) => { w.update( isWatched ); } ); // For the current page, also trigger the hook if ( normalizedTitle === pageTitle ) { notifyPageWatchStatus( isWatched ); } } ); } ) .fail( ( code, data ) => { // Reset link to non-loading mode updateWatchLinkAttributes( $link, action ); // Format error message const $msg = api.getErrorMessage( data ); // Report to user about the error mw.notify( $msg, { tag: 'watch-self', type: 'error', id: notificationId } ); } ); } ); } $( init ); /** * Animate watch/unwatch links to use asynchronous API requests to * watch pages, rather than navigating to a different URI. * * @example * var watch = require( 'mediawiki.page.watch.ajax' ); * watch.updateWatchLink( * $node, * 'watch', * 'loading' * ); * // When the watch status of the page has been updated: * watch.updatePageWatchStatus( true ); * * @exports mediawiki.page.watch.ajax */ module.exports = { watchstar: watchstar, updateWatchLink: updateWatchLink, updatePageWatchStatus: updatePageWatchStatus }; }() );