2 // The name of the page to watch or unwatch
3 const pageTitle = mw.config.get( 'wgRelevantPageName' ),
4 isWatchlistExpiryEnabled = require( './config.json' ).WatchlistExpiry,
5 // Use Object.create( null ) instead of {} to get an Object without predefined properties.
6 // This avoids problems if the title is 'hasOwnPropery' or similar. Bug: T342137
7 watchstarsByTitle = Object.create( null );
10 * Update the link text, link href attribute and (if applicable) "loading" class.
12 * @param {jQuery} $link Anchor tag of (un)watch link
13 * @param {string} action One of 'watch', 'unwatch'
14 * @param {string} [state='idle'] 'idle' or 'loading'. Default is 'idle'
15 * @param {string} [expiry='infinity'] The expiry date if a page is being watched temporarily.
18 function updateWatchLinkAttributes( $link, action, state, expiry ) {
19 // A valid but empty jQuery object shouldn't throw a TypeError
20 if ( !$link.length ) {
24 expiry = expiry || 'infinity';
26 // Invalid actions shouldn't silently turn the page in an unrecoverable state
27 if ( action !== 'watch' && action !== 'unwatch' ) {
28 throw new Error( 'Invalid action' );
31 const otherAction = action === 'watch' ? 'unwatch' : 'watch';
32 const $li = $link.closest( 'li' );
34 if ( state !== 'loading' ) {
35 // jQuery event, @deprecated in 1.38
36 // Trigger a 'watchpage' event for this List item.
37 // NB: A expiry of 'infinity' is cast to null here, but not above
38 $li.trigger( 'watchpage.mw', [ otherAction, mw.util.isInfinity( expiry ) ? null : expiry ] );
41 let tooltipAction = action;
42 let daysLeftExpiry = null;
43 let watchExpiry = null;
44 // Checking to see what if the expiry is set or indefinite to display the correct message
45 if ( isWatchlistExpiryEnabled && action === 'unwatch' ) {
46 if ( mw.util.isInfinity( expiry ) ) {
47 // Resolves to tooltip-ca-unwatch message
48 tooltipAction = 'unwatch';
50 const expiryDate = new Date( expiry );
51 const currentDate = new Date();
52 // Using the Math.ceil function instead of floor so when, for example, a user selects one week
53 // the tooltip shows 7 days instead of 6 days (see Phab ticket T253936)
54 daysLeftExpiry = Math.ceil( ( expiryDate - currentDate ) / ( 1000 * 60 * 60 * 24 ) );
55 if ( daysLeftExpiry > 0 ) {
56 // Resolves to tooltip-ca-unwatch-expiring message
57 tooltipAction = 'unwatch-expiring';
59 // Resolves to tooltip-ca-unwatch-expiring-hours message
60 tooltipAction = 'unwatch-expiring-hours';
62 watchExpiry = expiryDate.toISOString();
66 const msgKey = state === 'loading' ? action + 'ing' : action;
67 // The following messages can be used here:
72 const msg = mw.msg( msgKey );
73 const link = $link.get( 0 );
74 if ( link.children.length > 1 && link.lastElementChild.tagName === 'SPAN' ) {
75 // Handle updated button markup,
76 // where the watchstar contains an icon element and a span element containing the text
77 link.lastElementChild.textContent = msg;
79 link.textContent = msg;
82 $link.toggleClass( 'loading', state === 'loading' )
83 // The following messages can be used here:
85 // * tooltip-ca-unwatch
86 // * tooltip-ca-unwatch-expiring
87 // * tooltip-ca-unwatch-expiring-hours
88 .attr( 'title', mw.msg( 'tooltip-ca-' + tooltipAction, daysLeftExpiry ) )
89 .updateTooltipAccessKeys()
90 .attr( 'href', mw.util.getUrl( pageTitle, { action: action } ) )
91 .attr( 'data-mw-expiry', watchExpiry );
93 $li.toggleClass( 'mw-watchlink-temp', expiry !== null && expiry !== 'infinity' );
95 // Most common ID style
96 if ( state !== 'loading' && $li.prop( 'id' ) === 'ca-' + otherAction ) {
97 $li.prop( 'id', 'ca-' + action );
102 * Notify hooks listeners of the new page watch status
104 * Watchstars should not need to use this hook, as they are updated via
105 * callback, and automatically kept in sync if a watchstar with the same
108 * This hook should by used by other interfaces that care if the watch
109 * status of the page has changed, e.g. an edit form which wants to
110 * update a 'watch this page' checkbox.
112 * Users which change the watch status of the page without using a
113 * watchstar (e.g. edit forms again) should use the updatePageWatchStatus
114 * method to ensure watchstars are updated and this hook is fired.
116 * @param {boolean} isWatched The page is watched
117 * @param {string} [expiry='infinity'] The expiry date if a page is being watched temporarily.
118 * @param {string} [expirySelected='infinite'] The expiry length that was just selected from a dropdown, e.g. '1 week'
121 function notifyPageWatchStatus( isWatched, expiry, expirySelected ) {
122 expiry = expiry || 'infinity';
123 expirySelected = expirySelected || 'infinite';
126 * Fires when the page watch status has changed.
128 * @event ~'wikipage.watchlistChange'
130 * @param {boolean} isWatched
131 * @param {string} expiry The expiry date if the page is being watched temporarily.
132 * @param {string} expirySelected The expiry length that was selected from a dropdown, e.g. '1 week'
134 * mw.hook( 'wikipage.watchlistChange' ).add( ( isWatched, expiry, expirySelected ) => {
138 mw.hook( 'wikipage.watchlistChange' ).fire(
146 * Update the page watch status.
148 * @memberof module:mediawiki.page.watch.ajax
149 * @param {boolean} isWatched The page is watched
150 * @param {string} [expiry='infinity'] The expiry date if a page is being watched temporarily.
151 * @param {string} [expirySelected='infinite'] The expiry length that was just selected from a dropdown, e.g. '1 week'
152 * @fires Hooks~'wikipage.watchlistChange'
155 function updatePageWatchStatus( isWatched, expiry, expirySelected ) {
156 // Update all watchstars associated with the current page
157 ( watchstarsByTitle[ pageTitle ] || [] ).forEach( ( w ) => {
158 w.update( isWatched, expiry );
161 notifyPageWatchStatus( isWatched, expiry, expirySelected );
165 * Update the link text, link `href` attribute and (if applicable) "loading" class.
167 * For an individual link being set to 'loading', the first
168 * argument can be a jQuery collection. When updating to an
169 * "idle" state, an {@link mw.Title} object should be passed to that
170 * all watchstars associated with that title are updated.
172 * @memberof module:mediawiki.page.watch.ajax
173 * @param {mw.Title|jQuery} titleOrLink Title of watchlinks to update (when state is idle), or an individual watchlink
174 * @param {string} action One of 'watch', 'unwatch'
175 * @param {string} [state="idle"] 'idle' or 'loading'. Default is 'idle'
176 * @param {string} [expiry='infinity'] The expiry date if a page is being watched temporarily.
177 * @param {string} [expirySelected='infinite'] The expiry length that was just selected from a dropdown, e.g. '1 week'
178 * @fires Hooks~'wikipage.watchlistChange'
181 function updateWatchLink( titleOrLink, action, state, expiry, expirySelected ) {
182 if ( titleOrLink instanceof $ ) {
183 updateWatchLinkAttributes( titleOrLink, action, state, expiry );
185 // Assumed state is 'idle' when update a group of watchstars by title
186 const isWatched = action === 'unwatch';
187 const normalizedTitle = titleOrLink.getPrefixedDb();
188 ( watchstarsByTitle[ normalizedTitle ] || [] ).forEach( ( w ) => {
189 w.update( isWatched, expiry, expirySelected );
191 if ( normalizedTitle === pageTitle ) {
192 notifyPageWatchStatus( isWatched, expiry, expirySelected );
198 * TODO: This should be moved somewhere more accessible.
200 * @param {string} url
201 * @return {string} The extracted action, defaults to 'view'
204 function mwUriGetAction( url ) {
205 // TODO: Does MediaWiki give action path or query param
206 // precedence? If the former, move this to the bottom
207 const action = mw.util.getParamValue( 'action', url );
208 if ( action !== null ) {
212 const actionPaths = mw.config.get( 'wgActionPaths' );
213 for ( const key in actionPaths ) {
214 let parts = actionPaths[ key ].split( '$1' );
215 parts = parts.map( mw.util.escapeRegExp );
217 const m = new RegExp( parts.join( '(.+)' ) ).exec( url );
230 let $pageWatchLinks = $( '.mw-watchlink a[data-mw="interface"], a.mw-watchlink[data-mw="interface"]' );
231 if ( !$pageWatchLinks.length ) {
232 // Fallback to the class-based exclusion method for backwards-compatibility
233 $pageWatchLinks = $( '.mw-watchlink a, a.mw-watchlink' );
234 // Restrict to core interfaces, ignore user-generated content
235 $pageWatchLinks = $pageWatchLinks.filter( ':not( #bodyContent *, #content * )' );
237 if ( $pageWatchLinks.length ) {
238 watchstar( $pageWatchLinks, pageTitle );
243 * Class representing an individual watchstar
245 * @param {jQuery} $link Watch element
246 * @param {mw.Title} title Title
247 * @param {module:mediawiki.page.watch.ajax~callback} [callback]
250 function Watchstar( $link, title, callback ) {
253 this.callback = callback;
257 * Update the watchstar
259 * @param {boolean} isWatched The page is watched
260 * @param {string} [expiry='infinity'] The expiry date if a page is being watched temporarily.
263 Watchstar.prototype.update = function ( isWatched, expiry ) {
264 expiry = expiry || 'infinity';
265 updateWatchLinkAttributes( this.$link, isWatched ? 'unwatch' : 'watch', 'idle', expiry );
266 if ( this.callback ) {
268 * @callback module:mediawiki.page.watch.ajax~callback
269 * @param {jQuery} $link The element being manipulated.
270 * @param {boolean} isWatched Whether the page is now watched.
271 * @param {string} expiry The expiry date if the page is being watched temporarily,
272 * or an 'infinity'-like value (see [mw.util.isIninity()]{@link module:mediawiki.util.isInfinity})
274 this.callback( this.$link, isWatched, expiry );
279 * Bind a given watchstar element to make it interactive.
281 * This is meant to allow binding of watchstars for arbitrary page titles,
282 * especially if different from the currently viewed page. As such, this function
283 * will *not* synchronise its state with any "Watch this page" checkbox such as
284 * found on the "Edit page" and "Publish changes" forms. The caller should either make
285 * "current page" watchstars picked up by init (and not use this function) or sync it manually
286 * from the callback this function provides.
288 * @memberof module:mediawiki.page.watch.ajax
289 * @param {jQuery} $links One or more anchor elements that must have an href
290 * with a URL containing a `action=watch` or `action=unwatch` query parameter,
291 * from which the current state will be learned (e.g. link to unwatch is currently watched)
292 * @param {string} title Title of page that this watchstar will affect
293 * @param {module:mediawiki.page.watch.ajax~callback} [callback] Callback to run after the action has been
294 * processed and API request completed.
297 function watchstar( $links, title, callback ) {
298 // Set up the ARIA connection between the watch link and the notification.
299 // This is set outside the click handler so that it's already present when the user clicks.
300 const notificationId = 'mw-watchlink-notification';
301 const mwTitle = mw.Title.newFromText( title );
307 const normalizedTitle = mwTitle.getPrefixedDb();
308 watchstarsByTitle[ normalizedTitle ] = watchstarsByTitle[ normalizedTitle ] || [];
310 $links.each( function () {
311 watchstarsByTitle[ normalizedTitle ].push(
312 new Watchstar( $( this ), mwTitle, callback )
316 $links.attr( 'aria-controls', notificationId );
318 // Add click handler.
319 $links.on( 'click', function ( e ) {
320 const action = mwUriGetAction( this.href );
322 if ( !mwTitle || ( action !== 'watch' && action !== 'unwatch' ) ) {
323 // Let native browsing handle the link
329 const $link = $( this );
331 // eslint-disable-next-line no-jquery/no-class-state
332 if ( $link.hasClass( 'loading' ) ) {
336 updateWatchLinkAttributes( $link, action, 'loading' );
338 // Preload the notification module for mw.notify
339 const modulesToLoad = [ 'mediawiki.notification' ];
341 // Preload watchlist expiry widget so it runs in parallel with the api call
342 if ( isWatchlistExpiryEnabled ) {
343 modulesToLoad.push( 'mediawiki.watchstar.widgets' );
346 mw.loader.load( modulesToLoad );
348 const api = new mw.Api();
349 api[ action ]( title )
350 .done( ( watchResponse ) => {
351 const isWatched = watchResponse.watched === true;
353 let message = isWatched ? 'addedwatchtext' : 'removedwatchtext';
354 if ( mwTitle.isTalkPage() ) {
360 // @since 1.35 - pop up notification will be loaded with OOUI
361 // only if Watchlist Expiry is enabled
362 if ( isWatchlistExpiryEnabled ) {
363 if ( isWatched ) { // The message should include `infinite` watch period
364 message = mwTitle.isTalkPage() ? 'addedwatchindefinitelytext-talk' : 'addedwatchindefinitelytext';
367 notifyPromise = mw.loader.using( 'mediawiki.watchstar.widgets' ).then( ( require ) => {
368 const WatchlistExpiryWidget = require( 'mediawiki.watchstar.widgets' );
370 if ( !watchlistPopup ) {
371 watchlistPopup = new WatchlistExpiryWidget(
376 // The following messages can be used here:
377 // * addedwatchindefinitelytext-talk
378 // * addedwatchindefinitelytext
379 // * removedwatchtext-talk
380 // * removedwatchtext
381 message: mw.message( message, mwTitle.getPrefixedText() ).parseDom(),
386 mw.notify( watchlistPopup.$element, {
389 autoHideSeconds: 'short'
393 // The following messages can be used here:
394 // * addedwatchtext-talk
396 // * removedwatchtext-talk
397 // * removedwatchtext
398 notifyPromise = mw.notify(
399 mw.message( message, mwTitle.getPrefixedText() ).parseDom(), {
406 // The notifications are stored as a promise and the watch link is only updated
407 // once it is resolved. Otherwise, if $wgWatchlistExpiry set, the loading of
408 // OOUI could cause a race condition and the link is updated before the popup
409 // actually is shown. See T263135
410 notifyPromise.always( () => {
411 // Update all watchstars associated with this title
412 watchstarsByTitle[ normalizedTitle ].forEach( ( w ) => {
413 w.update( isWatched );
416 // For the current page, also trigger the hook
417 if ( normalizedTitle === pageTitle ) {
418 notifyPageWatchStatus( isWatched );
422 .fail( ( code, data ) => {
423 // Reset link to non-loading mode
424 updateWatchLinkAttributes( $link, action );
426 // Format error message
427 const $msg = api.getErrorMessage( data );
429 // Report to user about the error
442 * Animate watch/unwatch links to use asynchronous API requests to
443 * watch pages, rather than navigating to a different URI.
446 * var watch = require( 'mediawiki.page.watch.ajax' );
447 * watch.updateWatchLink(
452 * // When the watch status of the page has been updated:
453 * watch.updatePageWatchStatus( true );
455 * @exports mediawiki.page.watch.ajax
458 watchstar: watchstar,
459 updateWatchLink: updateWatchLink,
460 updatePageWatchStatus: updatePageWatchStatus