Localisation updates from https://translatewiki.net.
[mediawiki.git] / resources / src / mediawiki.page.watch.ajax / watch-ajax.js
blob6edc2dd556ce03da632246c65905d32f3fc12f0e
1 ( function () {
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 );
9         /**
10          * Update the link text, link href attribute and (if applicable) "loading" class.
11          *
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.
16          * @private
17          */
18         function updateWatchLinkAttributes( $link, action, state, expiry ) {
19                 // A valid but empty jQuery object shouldn't throw a TypeError
20                 if ( !$link.length ) {
21                         return;
22                 }
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' );
29                 }
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 ] );
39                 }
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';
49                         } else {
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';
58                                 } else {
59                                         // Resolves to tooltip-ca-unwatch-expiring-hours message
60                                         tooltipAction = 'unwatch-expiring-hours';
61                                 }
62                                 watchExpiry = expiryDate.toISOString();
63                         }
64                 }
66                 const msgKey = state === 'loading' ? action + 'ing' : action;
67                 // The following messages can be used here:
68                 // * watch
69                 // * watching
70                 // * unwatch
71                 // * unwatching
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;
78                 } else {
79                         link.textContent = msg;
80                 }
82                 $link.toggleClass( 'loading', state === 'loading' )
83                         // The following messages can be used here:
84                         // * tooltip-ca-watch
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 );
98                 }
99         }
101         /**
102          * Notify hooks listeners of the new page watch status
103          *
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
106          * title is changed.
107          *
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.
111          *
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.
115          *
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'
119          * @private
120          */
121         function notifyPageWatchStatus( isWatched, expiry, expirySelected ) {
122                 expiry = expiry || 'infinity';
123                 expirySelected = expirySelected || 'infinite';
125                 /**
126                  * Fires when the page watch status has changed.
127                  *
128                  * @event ~'wikipage.watchlistChange'
129                  * @memberof Hooks
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'
133                  * @example
134                  * mw.hook( 'wikipage.watchlistChange' ).add( ( isWatched, expiry, expirySelected ) => {
135                  *     // Do things
136                  * } );
137                  */
138                 mw.hook( 'wikipage.watchlistChange' ).fire(
139                         isWatched,
140                         expiry,
141                         expirySelected
142                 );
143         }
145         /**
146          * Update the page watch status.
147          *
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'
153          * @stable
154          */
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 );
159                 } );
161                 notifyPageWatchStatus( isWatched, expiry, expirySelected );
162         }
164         /**
165          * Update the link text, link `href` attribute and (if applicable) "loading" class.
166          *
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.
171          *
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'
179          * @stable
180          */
181         function updateWatchLink( titleOrLink, action, state, expiry, expirySelected ) {
182                 if ( titleOrLink instanceof $ ) {
183                         updateWatchLinkAttributes( titleOrLink, action, state, expiry );
184                 } else {
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 );
190                         } );
191                         if ( normalizedTitle === pageTitle ) {
192                                 notifyPageWatchStatus( isWatched, expiry, expirySelected );
193                         }
194                 }
195         }
197         /**
198          * TODO: This should be moved somewhere more accessible.
199          *
200          * @param {string} url
201          * @return {string} The extracted action, defaults to 'view'
202          * @private
203          */
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 ) {
209                         return action;
210                 }
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 );
218                         if ( m && m[ 1 ] ) {
219                                 return key;
220                         }
221                 }
223                 return 'view';
224         }
226         /**
227          * @private
228          */
229         function init() {
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 * )' );
236                 }
237                 if ( $pageWatchLinks.length ) {
238                         watchstar( $pageWatchLinks, pageTitle );
239                 }
240         }
242         /**
243          * Class representing an individual watchstar
244          *
245          * @param {jQuery} $link Watch element
246          * @param {mw.Title} title Title
247          * @param {module:mediawiki.page.watch.ajax~callback} [callback]
248          * @private
249          */
250         function Watchstar( $link, title, callback ) {
251                 this.$link = $link;
252                 this.title = title;
253                 this.callback = callback;
254         }
256         /**
257          * Update the watchstar
258          *
259          * @param {boolean} isWatched The page is watched
260          * @param {string} [expiry='infinity'] The expiry date if a page is being watched temporarily.
261          * @private
262          */
263         Watchstar.prototype.update = function ( isWatched, expiry ) {
264                 expiry = expiry || 'infinity';
265                 updateWatchLinkAttributes( this.$link, isWatched ? 'unwatch' : 'watch', 'idle', expiry );
266                 if ( this.callback ) {
267                         /**
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})
273                          */
274                         this.callback( this.$link, isWatched, expiry );
275                 }
276         };
278         /**
279          * Bind a given watchstar element to make it interactive.
280          *
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.
287          *
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.
295          * @stable
296          */
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 );
303                 if ( !mwTitle ) {
304                         return;
305                 }
307                 const normalizedTitle = mwTitle.getPrefixedDb();
308                 watchstarsByTitle[ normalizedTitle ] = watchstarsByTitle[ normalizedTitle ] || [];
310                 $links.each( function () {
311                         watchstarsByTitle[ normalizedTitle ].push(
312                                 new Watchstar( $( this ), mwTitle, callback )
313                         );
314                 } );
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
324                                 return true;
325                         }
326                         e.preventDefault();
327                         e.stopPropagation();
329                         const $link = $( this );
331                         // eslint-disable-next-line no-jquery/no-class-state
332                         if ( $link.hasClass( 'loading' ) ) {
333                                 return;
334                         }
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' );
344                         }
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() ) {
355                                                 message += '-talk';
356                                         }
358                                         let notifyPromise;
359                                         let watchlistPopup;
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';
365                                                 }
367                                                 notifyPromise = mw.loader.using( 'mediawiki.watchstar.widgets' ).then( ( require ) => {
368                                                         const WatchlistExpiryWidget = require( 'mediawiki.watchstar.widgets' );
370                                                         if ( !watchlistPopup ) {
371                                                                 watchlistPopup = new WatchlistExpiryWidget(
372                                                                         action,
373                                                                         title,
374                                                                         updateWatchLink,
375                                                                         {
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(),
382                                                                                 $link: $link
383                                                                         } );
384                                                         }
386                                                         mw.notify( watchlistPopup.$element, {
387                                                                 tag: 'watch-self',
388                                                                 id: notificationId,
389                                                                 autoHideSeconds: 'short'
390                                                         } );
391                                                 } );
392                                         } else {
393                                                 // The following messages can be used here:
394                                                 // * addedwatchtext-talk
395                                                 // * addedwatchtext
396                                                 // * removedwatchtext-talk
397                                                 // * removedwatchtext
398                                                 notifyPromise = mw.notify(
399                                                         mw.message( message, mwTitle.getPrefixedText() ).parseDom(), {
400                                                                 tag: 'watch-self',
401                                                                 id: notificationId
402                                                         }
403                                                 );
404                                         }
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 );
414                                                 } );
416                                                 // For the current page, also trigger the hook
417                                                 if ( normalizedTitle === pageTitle ) {
418                                                         notifyPageWatchStatus( isWatched );
419                                                 }
420                                         } );
421                                 } )
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
430                                         mw.notify( $msg, {
431                                                 tag: 'watch-self',
432                                                 type: 'error',
433                                                 id: notificationId
434                                         } );
435                                 } );
436                 } );
437         }
439         $( init );
441         /**
442          * Animate watch/unwatch links to use asynchronous API requests to
443          * watch pages, rather than navigating to a different URI.
444          *
445          * @example
446          * var watch = require( 'mediawiki.page.watch.ajax' );
447          * watch.updateWatchLink(
448          *     $node,
449          *     'watch',
450          *     'loading'
451          * );
452          * // When the watch status of the page has been updated:
453          * watch.updatePageWatchStatus( true );
454          *
455          * @exports mediawiki.page.watch.ajax
456          */
457         module.exports = {
458                 watchstar: watchstar,
459                 updateWatchLink: updateWatchLink,
460                 updatePageWatchStatus: updatePageWatchStatus
461         };
463 }() );