aboutsummaryrefslogtreecommitdiffstats
path: root/resources/src/mediawiki.page.watch.ajax/watch-ajax.js
blob: 6edc2dd556ce03da632246c65905d32f3fc12f0e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
( 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
	};

}() );