1<?php
2/**
3 * Implements Special:Recentchanges
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup SpecialPage
22 */
23
24use MediaWiki\MediaWikiServices;
25use MediaWiki\User\UserOptionsLookup;
26use Wikimedia\Rdbms\IDatabase;
27use Wikimedia\Rdbms\ILoadBalancer;
28use Wikimedia\Rdbms\IResultWrapper;
29
30/**
31 * A special page that lists last changes made to the wiki
32 *
33 * @ingroup SpecialPage
34 */
35class SpecialRecentChanges extends ChangesListSpecialPage {
36
37	protected static $savedQueriesPreferenceName = 'rcfilters-saved-queries';
38	protected static $daysPreferenceName = 'rcdays'; // Use general RecentChanges preference
39	protected static $limitPreferenceName = 'rcfilters-limit'; // Use RCFilters-specific preference
40	protected static $collapsedPreferenceName = 'rcfilters-rc-collapsed';
41
42	private $watchlistFilterGroupDefinition;
43
44	/** @var WatchedItemStoreInterface */
45	private $watchedItemStore;
46
47	/** @var MessageCache */
48	private $messageCache;
49
50	/** @var ILoadBalancer */
51	private $loadBalancer;
52
53	/** @var UserOptionsLookup */
54	private $userOptionsLookup;
55
56	/**
57	 * @param WatchedItemStoreInterface|null $watchedItemStore
58	 * @param MessageCache|null $messageCache
59	 * @param ILoadBalancer|null $loadBalancer
60	 * @param UserOptionsLookup|null $userOptionsLookup
61	 */
62	public function __construct(
63		WatchedItemStoreInterface $watchedItemStore = null,
64		MessageCache $messageCache = null,
65		ILoadBalancer $loadBalancer = null,
66		UserOptionsLookup $userOptionsLookup = null
67	) {
68		parent::__construct( 'Recentchanges', '' );
69		// This class is extended and therefor fallback to global state - T265310
70		$services = MediaWikiServices::getInstance();
71		$this->watchedItemStore = $watchedItemStore ?? $services->getWatchedItemStore();
72		$this->messageCache = $messageCache ?? $services->getMessageCache();
73		$this->loadBalancer = $loadBalancer ?? $services->getDBLoadBalancer();
74		$this->userOptionsLookup = $userOptionsLookup ?? $services->getUserOptionsLookup();
75
76		$this->watchlistFilterGroupDefinition = [
77			'name' => 'watchlist',
78			'title' => 'rcfilters-filtergroup-watchlist',
79			'class' => ChangesListStringOptionsFilterGroup::class,
80			'priority' => -9,
81			'isFullCoverage' => true,
82			'filters' => [
83				[
84					'name' => 'watched',
85					'label' => 'rcfilters-filter-watchlist-watched-label',
86					'description' => 'rcfilters-filter-watchlist-watched-description',
87					'cssClassSuffix' => 'watched',
88					'isRowApplicableCallable' => static function ( $ctx, $rc ) {
89						return $rc->getAttribute( 'wl_user' );
90					}
91				],
92				[
93					'name' => 'watchednew',
94					'label' => 'rcfilters-filter-watchlist-watchednew-label',
95					'description' => 'rcfilters-filter-watchlist-watchednew-description',
96					'cssClassSuffix' => 'watchednew',
97					'isRowApplicableCallable' => static function ( $ctx, $rc ) {
98						return $rc->getAttribute( 'wl_user' ) &&
99							$rc->getAttribute( 'rc_timestamp' ) &&
100							$rc->getAttribute( 'wl_notificationtimestamp' ) &&
101							$rc->getAttribute( 'rc_timestamp' ) >= $rc->getAttribute( 'wl_notificationtimestamp' );
102					},
103				],
104				[
105					'name' => 'notwatched',
106					'label' => 'rcfilters-filter-watchlist-notwatched-label',
107					'description' => 'rcfilters-filter-watchlist-notwatched-description',
108					'cssClassSuffix' => 'notwatched',
109					'isRowApplicableCallable' => static function ( $ctx, $rc ) {
110						return $rc->getAttribute( 'wl_user' ) === null;
111					},
112				]
113			],
114			'default' => ChangesListStringOptionsFilterGroup::NONE,
115			'queryCallable' => function ( $specialPageClassName, $context, IDatabase $dbr,
116				&$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedValues ) {
117				sort( $selectedValues );
118				$notwatchedCond = 'wl_user IS NULL';
119				$watchedCond = 'wl_user IS NOT NULL';
120				if ( $this->getConfig()->get( 'WatchlistExpiry' ) ) {
121					// Expired watchlist items stay in the DB after their expiry time until they're purged,
122					// so it's not enough to only check for wl_user.
123					$quotedNow = $dbr->addQuotes( $dbr->timestamp() );
124					$notwatchedCond = "wl_user IS NULL OR ( we_expiry IS NOT NULL AND we_expiry < $quotedNow )";
125					$watchedCond = "wl_user IS NOT NULL AND ( we_expiry IS NULL OR we_expiry >= $quotedNow )";
126				}
127				$newCond = 'rc_timestamp >= wl_notificationtimestamp';
128
129				if ( $selectedValues === [ 'notwatched' ] ) {
130					$conds[] = $notwatchedCond;
131					return;
132				}
133
134				if ( $selectedValues === [ 'watched' ] ) {
135					$conds[] = $watchedCond;
136					return;
137				}
138
139				if ( $selectedValues === [ 'watchednew' ] ) {
140					$conds[] = $dbr->makeList( [
141						$watchedCond,
142						$newCond
143					], LIST_AND );
144					return;
145				}
146
147				if ( $selectedValues === [ 'notwatched', 'watched' ] ) {
148					// no filters
149					return;
150				}
151
152				if ( $selectedValues === [ 'notwatched', 'watchednew' ] ) {
153					$conds[] = $dbr->makeList( [
154						$notwatchedCond,
155						$dbr->makeList( [
156							$watchedCond,
157							$newCond
158						], LIST_AND )
159					], LIST_OR );
160					return;
161				}
162
163				if ( $selectedValues === [ 'watched', 'watchednew' ] ) {
164					$conds[] = $watchedCond;
165					return;
166				}
167
168				if ( $selectedValues === [ 'notwatched', 'watched', 'watchednew' ] ) {
169					// no filters
170					return;
171				}
172			}
173		];
174	}
175
176	/**
177	 * @param string|null $subpage
178	 */
179	public function execute( $subpage ) {
180		// Backwards-compatibility: redirect to new feed URLs
181		$feedFormat = $this->getRequest()->getVal( 'feed' );
182		if ( !$this->including() && $feedFormat ) {
183			$query = $this->getFeedQuery();
184			$query['feedformat'] = $feedFormat === 'atom' ? 'atom' : 'rss';
185			$this->getOutput()->redirect( wfAppendQuery( wfScript( 'api' ), $query ) );
186
187			return;
188		}
189
190		// 10 seconds server-side caching max
191		$out = $this->getOutput();
192		$out->setCdnMaxage( 10 );
193		// Check if the client has a cached version
194		$lastmod = $this->checkLastModified();
195		if ( $lastmod === false ) {
196			return;
197		}
198
199		$this->addHelpLink(
200			'https://meta.wikimedia.org/wiki/Special:MyLanguage/Help:Recent_changes',
201			true
202		);
203		parent::execute( $subpage );
204	}
205
206	/**
207	 * @inheritDoc
208	 */
209	protected function transformFilterDefinition( array $filterDefinition ) {
210		if ( isset( $filterDefinition['showHideSuffix'] ) ) {
211			$filterDefinition['showHide'] = 'rc' . $filterDefinition['showHideSuffix'];
212		}
213
214		return $filterDefinition;
215	}
216
217	/**
218	 * Whether or not the current query needs to use watchlist data: check that the current user can
219	 * use their watchlist and that this special page isn't being transcluded.
220	 *
221	 * @return bool
222	 */
223	private function needsWatchlistFeatures(): bool {
224		return !$this->including()
225			&& $this->getUser()->isRegistered()
226			&& $this->getAuthority()->isAllowed( 'viewmywatchlist' );
227	}
228
229	/**
230	 * @inheritDoc
231	 */
232	protected function registerFilters() {
233		parent::registerFilters();
234
235		if ( $this->needsWatchlistFeatures() ) {
236			$this->registerFiltersFromDefinitions( [ $this->watchlistFilterGroupDefinition ] );
237			$watchlistGroup = $this->getFilterGroup( 'watchlist' );
238			$watchlistGroup->getFilter( 'watched' )->setAsSupersetOf(
239				$watchlistGroup->getFilter( 'watchednew' )
240			);
241		}
242
243		$user = $this->getUser();
244
245		$significance = $this->getFilterGroup( 'significance' );
246		/** @var ChangesListBooleanFilter $hideMinor */
247		$hideMinor = $significance->getFilter( 'hideminor' );
248		'@phan-var ChangesListBooleanFilter $hideMinor';
249		$hideMinor->setDefault( $this->userOptionsLookup->getBoolOption( $user, 'hideminor' ) );
250
251		$automated = $this->getFilterGroup( 'automated' );
252		/** @var ChangesListBooleanFilter $hideBots */
253		$hideBots = $automated->getFilter( 'hidebots' );
254		'@phan-var ChangesListBooleanFilter $hideBots';
255		$hideBots->setDefault( true );
256
257		/** @var ChangesListStringOptionsFilterGroup|null $reviewStatus */
258		$reviewStatus = $this->getFilterGroup( 'reviewStatus' );
259		'@phan-var ChangesListStringOptionsFilterGroup|null $reviewStatus';
260		if ( $reviewStatus !== null ) {
261			// Conditional on feature being available and rights
262			if ( $this->userOptionsLookup->getBoolOption( $user, 'hidepatrolled' ) ) {
263				$reviewStatus->setDefault( 'unpatrolled' );
264				$legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' );
265				/** @var ChangesListBooleanFilter $legacyHidePatrolled */
266				$legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' );
267				'@phan-var ChangesListBooleanFilter $legacyHidePatrolled';
268				$legacyHidePatrolled->setDefault( true );
269			}
270		}
271
272		$changeType = $this->getFilterGroup( 'changeType' );
273		/** @var ChangesListBooleanFilter $hideCategorization */
274		$hideCategorization = $changeType->getFilter( 'hidecategorization' );
275		'@phan-var ChangesListBooleanFilter $hideCategorization';
276		if ( $hideCategorization !== null ) {
277			// Conditional on feature being available
278			$hideCategorization->setDefault( $this->userOptionsLookup->getBoolOption( $user, 'hidecategorization' ) );
279		}
280	}
281
282	/**
283	 * Process $par and put options found in $opts. Used when including the page.
284	 *
285	 * @param string $par
286	 * @param FormOptions $opts
287	 */
288	public function parseParameters( $par, FormOptions $opts ) {
289		parent::parseParameters( $par, $opts );
290
291		$bits = preg_split( '/\s*,\s*/', trim( $par ) );
292		foreach ( $bits as $bit ) {
293			if ( is_numeric( $bit ) ) {
294				$opts['limit'] = $bit;
295			}
296
297			$m = [];
298			if ( preg_match( '/^limit=(\d+)$/', $bit, $m ) ) {
299				$opts['limit'] = $m[1];
300			}
301			if ( preg_match( '/^days=(\d+(?:\.\d+)?)$/', $bit, $m ) ) {
302				$opts['days'] = $m[1];
303			}
304			if ( preg_match( '/^namespace=(.*)$/', $bit, $m ) ) {
305				$opts['namespace'] = $m[1];
306			}
307			if ( preg_match( '/^tagfilter=(.*)$/', $bit, $m ) ) {
308				$opts['tagfilter'] = $m[1];
309			}
310		}
311	}
312
313	/**
314	 * Add required values to a query's $tables, $fields, $joinConds, and $conds arrays to join to
315	 * the watchlist and watchlist_expiry tables where appropriate.
316	 *
317	 * @param IDatabase $dbr
318	 * @param string[] &$tables
319	 * @param string[] &$fields
320	 * @param mixed[] &$joinConds
321	 * @param mixed[] &$conds
322	 */
323	protected function addWatchlistJoins( IDatabase $dbr, &$tables, &$fields, &$joinConds, &$conds ) {
324		if ( !$this->needsWatchlistFeatures() ) {
325			return;
326		}
327
328		// Join on watchlist table.
329		$tables[] = 'watchlist';
330		$fields[] = 'wl_user';
331		$fields[] = 'wl_notificationtimestamp';
332		$joinConds['watchlist'] = [ 'LEFT JOIN', [
333			'wl_user' => $this->getUser()->getId(),
334			'wl_title=rc_title',
335			'wl_namespace=rc_namespace'
336		] ];
337
338		// Exclude expired watchlist items.
339		if ( $this->getConfig()->get( 'WatchlistExpiry' ) ) {
340			$tables[] = 'watchlist_expiry';
341			$fields[] = 'we_expiry';
342			$joinConds['watchlist_expiry'] = [ 'LEFT JOIN', 'wl_id = we_item' ];
343		}
344	}
345
346	/**
347	 * @inheritDoc
348	 */
349	protected function doMainQuery( $tables, $fields, $conds, $query_options,
350		$join_conds, FormOptions $opts
351	) {
352		$dbr = $this->getDB();
353
354		$rcQuery = RecentChange::getQueryInfo();
355		$tables = array_merge( $tables, $rcQuery['tables'] );
356		$fields = array_merge( $rcQuery['fields'], $fields );
357		$join_conds = array_merge( $join_conds, $rcQuery['joins'] );
358
359		// Join with watchlist and watchlist_expiry tables to highlight watched rows.
360		$this->addWatchlistJoins( $dbr, $tables, $fields, $join_conds, $conds );
361
362		// JOIN on page, used for 'last revision' filter highlight
363		$tables[] = 'page';
364		$fields[] = 'page_latest';
365		$join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
366
367		$tagFilter = $opts['tagfilter'] ? explode( '|', $opts['tagfilter'] ) : [];
368		ChangeTags::modifyDisplayQuery(
369			$tables,
370			$fields,
371			$conds,
372			$join_conds,
373			$query_options,
374			$tagFilter
375		);
376
377		if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
378			$opts )
379		) {
380			return false;
381		}
382
383		if ( $this->areFiltersInConflict() ) {
384			return false;
385		}
386
387		$orderByAndLimit = [
388			'ORDER BY' => 'rc_timestamp DESC',
389			'LIMIT' => $opts['limit']
390		];
391		if ( in_array( 'DISTINCT', $query_options ) ) {
392			// ChangeTags::modifyDisplayQuery() adds DISTINCT when filtering on multiple tags.
393			// In order to prevent DISTINCT from causing query performance problems,
394			// we have to GROUP BY the primary key. This in turn requires us to add
395			// the primary key to the end of the ORDER BY, and the old ORDER BY to the
396			// start of the GROUP BY
397			$orderByAndLimit['ORDER BY'] = 'rc_timestamp DESC, rc_id DESC';
398			$orderByAndLimit['GROUP BY'] = 'rc_timestamp, rc_id';
399		}
400		// array_merge() is used intentionally here so that hooks can, should
401		// they so desire, override the ORDER BY / LIMIT condition(s); prior to
402		// MediaWiki 1.26 this used to use the plus operator instead, which meant
403		// that extensions weren't able to change these conditions
404		$query_options = array_merge( $orderByAndLimit, $query_options );
405		$rows = $dbr->select(
406			$tables,
407			$fields,
408			// rc_new is not an ENUM, but adding a redundant rc_new IN (0,1) gives mysql enough
409			// knowledge to use an index merge if it wants (it may use some other index though).
410			$conds + [ 'rc_new' => [ 0, 1 ] ],
411			__METHOD__,
412			$query_options,
413			$join_conds
414		);
415
416		return $rows;
417	}
418
419	protected function getDB() {
420		return $this->loadBalancer->getConnectionRef( ILoadBalancer::DB_REPLICA, 'recentchanges' );
421	}
422
423	public function outputFeedLinks() {
424		$this->addFeedLinks( $this->getFeedQuery() );
425	}
426
427	/**
428	 * Get URL query parameters for action=feedrecentchanges API feed of current recent changes view.
429	 *
430	 * @return array
431	 */
432	protected function getFeedQuery() {
433		$query = array_filter( $this->getOptions()->getAllValues(), static function ( $value ) {
434			// API handles empty parameters in a different way
435			return $value !== '';
436		} );
437		$query['action'] = 'feedrecentchanges';
438		$feedLimit = $this->getConfig()->get( 'FeedLimit' );
439		if ( $query['limit'] > $feedLimit ) {
440			$query['limit'] = $feedLimit;
441		}
442
443		return $query;
444	}
445
446	/**
447	 * Build and output the actual changes list.
448	 *
449	 * @param IResultWrapper $rows Database rows
450	 * @param FormOptions $opts
451	 */
452	public function outputChangesList( $rows, $opts ) {
453		$limit = $opts['limit'];
454
455		$showWatcherCount = $this->getConfig()->get( 'RCShowWatchingUsers' )
456			&& $this->userOptionsLookup->getBoolOption( $this->getUser(), 'shownumberswatching' );
457		$watcherCache = [];
458
459		$counter = 1;
460		$list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups );
461		$list->initChangesListRows( $rows );
462
463		$userShowHiddenCats = $this->userOptionsLookup->getBoolOption( $this->getUser(), 'showhiddencats' );
464		$rclistOutput = $list->beginRecentChangesList();
465		if ( $this->isStructuredFilterUiEnabled() ) {
466			$rclistOutput .= $this->makeLegend();
467		}
468
469		foreach ( $rows as $obj ) {
470			if ( $limit == 0 ) {
471				break;
472			}
473			$rc = RecentChange::newFromRow( $obj );
474
475			# Skip CatWatch entries for hidden cats based on user preference
476			if (
477				$rc->getAttribute( 'rc_type' ) == RC_CATEGORIZE &&
478				!$userShowHiddenCats &&
479				$rc->getParam( 'hidden-cat' )
480			) {
481				continue;
482			}
483
484			$rc->counter = $counter++;
485			# Check if the page has been updated since the last visit
486			if ( $this->getConfig()->get( 'ShowUpdatedMarker' )
487				&& !empty( $obj->wl_notificationtimestamp )
488			) {
489				$rc->notificationtimestamp = ( $obj->rc_timestamp >= $obj->wl_notificationtimestamp );
490			} else {
491				$rc->notificationtimestamp = false; // Default
492			}
493			# Check the number of users watching the page
494			$rc->numberofWatchingusers = 0; // Default
495			if ( $showWatcherCount && $obj->rc_namespace >= 0 ) {
496				if ( !isset( $watcherCache[$obj->rc_namespace][$obj->rc_title] ) ) {
497					$watcherCache[$obj->rc_namespace][$obj->rc_title] =
498						$this->watchedItemStore->countWatchers(
499							new TitleValue( (int)$obj->rc_namespace, $obj->rc_title )
500						);
501				}
502				$rc->numberofWatchingusers = $watcherCache[$obj->rc_namespace][$obj->rc_title];
503			}
504
505			$watched = !empty( $obj->wl_user );
506			if ( $watched && $this->getConfig()->get( 'WatchlistExpiry' ) ) {
507				$notExpired = $obj->we_expiry === null
508					|| MWTimestamp::convert( TS_UNIX, $obj->we_expiry ) > wfTimestamp();
509				$watched = $watched && $notExpired;
510			}
511			$changeLine = $list->recentChangesLine( $rc, $watched, $counter );
512			if ( $changeLine !== false ) {
513				$rclistOutput .= $changeLine;
514				--$limit;
515			}
516		}
517		$rclistOutput .= $list->endRecentChangesList();
518
519		if ( $rows->numRows() === 0 ) {
520			$this->outputNoResults();
521			if ( !$this->including() ) {
522				$this->getOutput()->setStatusCode( 404 );
523			}
524		} else {
525			$this->getOutput()->addHTML( $rclistOutput );
526		}
527	}
528
529	/**
530	 * Set the text to be displayed above the changes
531	 *
532	 * @param FormOptions $opts
533	 * @param int $numRows Number of rows in the result to show after this header
534	 */
535	public function doHeader( $opts, $numRows ) {
536		$this->setTopText( $opts );
537
538		$defaults = $opts->getAllValues();
539		$nondefaults = $opts->getChangedValues();
540
541		$panel = [];
542		if ( !$this->isStructuredFilterUiEnabled() ) {
543			$panel[] = $this->makeLegend();
544		}
545		$panel[] = $this->optionsPanel( $defaults, $nondefaults, $numRows );
546		$panel[] = '<hr />';
547
548		$extraOpts = $this->getExtraOptions( $opts );
549		$extraOptsCount = count( $extraOpts );
550		$count = 0;
551		$submit = ' ' . Xml::submitButton( $this->msg( 'recentchanges-submit' )->text() );
552
553		$out = Xml::openElement( 'table', [ 'class' => 'mw-recentchanges-table' ] );
554		foreach ( $extraOpts as $name => $optionRow ) {
555			# Add submit button to the last row only
556			++$count;
557			$addSubmit = ( $count === $extraOptsCount ) ? $submit : '';
558
559			$out .= Xml::openElement( 'tr', [ 'class' => $name . 'Form' ] );
560			if ( is_array( $optionRow ) ) {
561				$out .= Xml::tags(
562					'td',
563					[ 'class' => 'mw-label mw-' . $name . '-label' ],
564					$optionRow[0]
565				);
566				$out .= Xml::tags(
567					'td',
568					[ 'class' => 'mw-input' ],
569					$optionRow[1] . $addSubmit
570				);
571			} else {
572				$out .= Xml::tags(
573					'td',
574					[ 'class' => 'mw-input', 'colspan' => 2 ],
575					$optionRow . $addSubmit
576				);
577			}
578			$out .= Xml::closeElement( 'tr' );
579		}
580		$out .= Xml::closeElement( 'table' );
581
582		$unconsumed = $opts->getUnconsumedValues();
583		foreach ( $unconsumed as $key => $value ) {
584			$out .= Html::hidden( $key, $value );
585		}
586
587		$t = $this->getPageTitle();
588		$out .= Html::hidden( 'title', $t->getPrefixedText() );
589		$form = Xml::tags( 'form', [ 'action' => wfScript() ], $out );
590		$panel[] = $form;
591		$panelString = implode( "\n", $panel );
592
593		$rcoptions = Xml::fieldset(
594			$this->msg( 'recentchanges-legend' )->text(),
595			$panelString,
596			[ 'class' => 'rcoptions cloptions' ]
597		);
598
599		// Insert a placeholder for RCFilters
600		if ( $this->isStructuredFilterUiEnabled() ) {
601			$rcfilterContainer = Html::element(
602				'div',
603				// TODO: Remove deprecated rcfilters-container class
604				[ 'class' => 'rcfilters-container mw-rcfilters-container' ]
605			);
606
607			$loadingContainer = Html::rawElement(
608				'div',
609				[ 'class' => 'mw-rcfilters-spinner' ],
610				Html::element(
611					'div',
612					[ 'class' => 'mw-rcfilters-spinner-bounce' ]
613				)
614			);
615
616			// Wrap both with rcfilters-head
617			$this->getOutput()->addHTML(
618				Html::rawElement(
619					'div',
620					// TODO: Remove deprecated rcfilters-head class
621					[ 'class' => 'rcfilters-head mw-rcfilters-head' ],
622					$rcfilterContainer . $rcoptions
623				)
624			);
625
626			// Add spinner
627			$this->getOutput()->addHTML( $loadingContainer );
628		} else {
629			$this->getOutput()->addHTML( $rcoptions );
630		}
631
632		$this->setBottomText( $opts );
633	}
634
635	/**
636	 * Send the text to be displayed above the options
637	 *
638	 * @param FormOptions $opts Unused
639	 */
640	public function setTopText( FormOptions $opts ) {
641		$message = $this->msg( 'recentchangestext' )->inContentLanguage();
642		if ( !$message->isDisabled() ) {
643			$contLang = $this->getContentLanguage();
644			// Parse the message in this weird ugly way to preserve the ability to include interlanguage
645			// links in it (T172461). In the future when T66969 is resolved, perhaps we can just use
646			// $message->parse() instead. This code is copied from Message::parseText().
647			$parserOutput = $this->messageCache->parse(
648				$message->plain(),
649				$this->getPageTitle(),
650				/*linestart*/true,
651				// Message class sets the interface flag to false when parsing in a language different than
652				// user language, and this is wiki content language
653				/*interface*/false,
654				$contLang
655			);
656			$content = $parserOutput->getText( [
657				'enableSectionEditLinks' => false,
658			] );
659			// Add only metadata here (including the language links), text is added below
660			$this->getOutput()->addParserOutputMetadata( $parserOutput );
661
662			$langAttributes = [
663				'lang' => $contLang->getHtmlCode(),
664				'dir' => $contLang->getDir(),
665			];
666
667			$topLinksAttributes = [ 'class' => 'mw-recentchanges-toplinks' ];
668
669			if ( $this->isStructuredFilterUiEnabled() ) {
670				// Check whether the widget is already collapsed or expanded
671				$collapsedState = $this->getRequest()->getCookie( 'rcfilters-toplinks-collapsed-state' );
672				// Note that an empty/unset cookie means collapsed, so check for !== 'expanded'
673				$topLinksAttributes[ 'class' ] .= $collapsedState !== 'expanded' ?
674					' mw-recentchanges-toplinks-collapsed' : '';
675
676				$this->getOutput()->enableOOUI();
677				$contentTitle = new OOUI\ButtonWidget( [
678					'classes' => [ 'mw-recentchanges-toplinks-title' ],
679					'label' => new OOUI\HtmlSnippet( $this->msg( 'rcfilters-other-review-tools' )->parse() ),
680					'framed' => false,
681					'indicator' => $collapsedState !== 'expanded' ? 'down' : 'up',
682					'flags' => [ 'progressive' ],
683				] );
684
685				$contentWrapper = Html::rawElement( 'div',
686					array_merge(
687						[ 'class' => 'mw-recentchanges-toplinks-content mw-collapsible-content' ],
688						$langAttributes
689					),
690					$content
691				);
692				$content = $contentTitle . $contentWrapper;
693			} else {
694				// Language direction should be on the top div only
695				// if the title is not there. If it is there, it's
696				// interface direction, and the language/dir attributes
697				// should be on the content itself
698				$topLinksAttributes = array_merge( $topLinksAttributes, $langAttributes );
699			}
700
701			$this->getOutput()->addHTML(
702				Html::rawElement( 'div', $topLinksAttributes, $content )
703			);
704		}
705	}
706
707	/**
708	 * Get options to be displayed in a form
709	 *
710	 * @param FormOptions $opts
711	 * @return array
712	 */
713	public function getExtraOptions( $opts ) {
714		$opts->consumeValues( [
715			'namespace', 'invert', 'associated', 'tagfilter'
716		] );
717
718		$extraOpts = [];
719		$extraOpts['namespace'] = $this->namespaceFilterForm( $opts );
720
721		$tagFilter = ChangeTags::buildTagFilterSelector(
722			$opts['tagfilter'], false, $this->getContext() );
723		if ( count( $tagFilter ) ) {
724			$extraOpts['tagfilter'] = $tagFilter;
725		}
726
727		// Don't fire the hook for subclasses. (Or should we?)
728		if ( $this->getName() === 'Recentchanges' ) {
729			$this->getHookRunner()->onSpecialRecentChangesPanel( $extraOpts, $opts );
730		}
731
732		return $extraOpts;
733	}
734
735	/**
736	 * Add page-specific modules.
737	 */
738	protected function addModules() {
739		parent::addModules();
740		$out = $this->getOutput();
741		$out->addModules( 'mediawiki.special.recentchanges' );
742	}
743
744	/**
745	 * Get last modified date, for client caching
746	 * Don't use this if we are using the patrol feature, patrol changes don't
747	 * update the timestamp
748	 *
749	 * @return string|bool
750	 */
751	public function checkLastModified() {
752		$dbr = $this->getDB();
753		$lastmod = $dbr->selectField( 'recentchanges', 'MAX(rc_timestamp)', '', __METHOD__ );
754
755		return $lastmod;
756	}
757
758	/**
759	 * Creates the choose namespace selection
760	 *
761	 * @param FormOptions $opts
762	 * @return string[]
763	 */
764	protected function namespaceFilterForm( FormOptions $opts ) {
765		$nsSelect = Html::namespaceSelector(
766			[ 'selected' => $opts['namespace'], 'all' => '', 'in-user-lang' => true ],
767			[ 'name' => 'namespace', 'id' => 'namespace' ]
768		);
769		$nsLabel = Xml::label( $this->msg( 'namespace' )->text(), 'namespace' );
770		$attribs = [ 'class' => [ 'mw-input-with-label' ] ];
771		// Hide the checkboxes when the namespace filter is set to 'all'.
772		if ( $opts['namespace'] === '' ) {
773			$attribs['class'][] = 'mw-input-hidden';
774		}
775		$invert = Html::rawElement( 'span', $attribs, Xml::checkLabel(
776			$this->msg( 'invert' )->text(), 'invert', 'nsinvert',
777			$opts['invert'],
778			[ 'title' => $this->msg( 'tooltip-invert' )->text() ]
779		) );
780		$associated = Html::rawElement( 'span', $attribs, Xml::checkLabel(
781			$this->msg( 'namespace_association' )->text(), 'associated', 'nsassociated',
782			$opts['associated'],
783			[ 'title' => $this->msg( 'tooltip-namespace_association' )->text() ]
784		) );
785
786		return [ $nsLabel, "$nsSelect $invert $associated" ];
787	}
788
789	/**
790	 * Makes change an option link which carries all the other options
791	 *
792	 * @param string $title
793	 * @param array $override Options to override
794	 * @param array $options Current options
795	 * @param bool $active Whether to show the link in bold
796	 * @return string
797	 * Annotations needed to tell taint about HtmlArmor
798	 * @param-taint $title escapes_html
799	 */
800	private function makeOptionsLink( $title, $override, $options, $active = false ) {
801		$params = $this->convertParamsForLink( $override + $options );
802
803		if ( $active ) {
804			$title = new HtmlArmor( '<strong>' . htmlspecialchars( $title ) . '</strong>' );
805		}
806
807		return $this->getLinkRenderer()->makeKnownLink( $this->getPageTitle(), $title, [
808			'data-params' => json_encode( $override ),
809			'data-keys' => implode( ',', array_keys( $override ) ),
810		], $params );
811	}
812
813	/**
814	 * Creates the options panel.
815	 *
816	 * @param array $defaults
817	 * @param array $nondefaults
818	 * @param int $numRows Number of rows in the result to show after this header
819	 * @return string
820	 */
821	private function optionsPanel( $defaults, $nondefaults, $numRows ) {
822		$options = $nondefaults + $defaults;
823
824		$note = '';
825		$msg = $this->msg( 'rclegend' );
826		if ( !$msg->isDisabled() ) {
827			$note .= Html::rawElement(
828				'div',
829				[ 'class' => 'mw-rclegend' ],
830				$msg->parse()
831			);
832		}
833
834		$lang = $this->getLanguage();
835		$user = $this->getUser();
836		$config = $this->getConfig();
837		if ( $options['from'] ) {
838			$resetLink = $this->makeOptionsLink( $this->msg( 'rclistfromreset' )->text(),
839				[ 'from' => '' ], $nondefaults );
840
841			$noteFromMsg = $this->msg( 'rcnotefrom' )
842				->numParams( $options['limit'] )
843				->params(
844					$lang->userTimeAndDate( $options['from'], $user ),
845					$lang->userDate( $options['from'], $user ),
846					$lang->userTime( $options['from'], $user )
847				)
848				->numParams( $numRows );
849			$note .= Html::rawElement(
850					'span',
851					[ 'class' => 'rcnotefrom' ],
852					$noteFromMsg->parse()
853				) .
854				' ' .
855				Html::rawElement(
856					'span',
857					[ 'class' => 'rcoptions-listfromreset' ],
858					$this->msg( 'parentheses' )->rawParams( $resetLink )->parse()
859				) .
860				'<br />';
861		}
862
863		# Sort data for display and make sure it's unique after we've added user data.
864		$linkLimits = $config->get( 'RCLinkLimits' );
865		$linkLimits[] = $options['limit'];
866		sort( $linkLimits );
867		$linkLimits = array_unique( $linkLimits );
868
869		$linkDays = $this->getLinkDays();
870		$linkDays[] = $options['days'];
871		sort( $linkDays );
872		$linkDays = array_unique( $linkDays );
873
874		// limit links
875		$cl = [];
876		foreach ( $linkLimits as $value ) {
877			$cl[] = $this->makeOptionsLink( $lang->formatNum( $value ),
878				[ 'limit' => $value ], $nondefaults, $value == $options['limit'] );
879		}
880		$cl = $lang->pipeList( $cl );
881
882		// day links, reset 'from' to none
883		$dl = [];
884		foreach ( $linkDays as $value ) {
885			$dl[] = $this->makeOptionsLink( $lang->formatNum( $value ),
886				[ 'days' => $value, 'from' => '' ], $nondefaults, $value == $options['days'] );
887		}
888		$dl = $lang->pipeList( $dl );
889
890		$showhide = [ 'show', 'hide' ];
891
892		$links = [];
893
894		foreach ( $this->getLegacyShowHideFilters() as $key => $filter ) {
895			$msg = $filter->getShowHide();
896			$linkMessage = $this->msg( $msg . '-' . $showhide[1 - $options[$key]] );
897			// Extensions can define additional filters, but don't need to define the corresponding
898			// messages. If they don't exist, just fall back to 'show' and 'hide'.
899			if ( !$linkMessage->exists() ) {
900				$linkMessage = $this->msg( $showhide[1 - $options[$key]] );
901			}
902
903			$link = $this->makeOptionsLink( $linkMessage->text(),
904				[ $key => 1 - $options[$key] ], $nondefaults );
905
906			$attribs = [
907				'class' => "$msg rcshowhideoption clshowhideoption",
908				'data-filter-name' => $filter->getName(),
909			];
910
911			if ( $filter->isFeatureAvailableOnStructuredUi() ) {
912				$attribs['data-feature-in-structured-ui'] = true;
913			}
914
915			$links[] = Html::rawElement(
916				'span',
917				$attribs,
918				$this->msg( $msg )->rawParams( $link )->parse()
919			);
920		}
921
922		// show from this onward link
923		$timestamp = wfTimestampNow();
924		$now = $lang->userTimeAndDate( $timestamp, $user );
925		$timenow = $lang->userTime( $timestamp, $user );
926		$datenow = $lang->userDate( $timestamp, $user );
927		$pipedLinks = '<span class="rcshowhide">' . $lang->pipeList( $links ) . '</span>';
928
929		$rclinks = Html::rawElement(
930			'span',
931			[ 'class' => 'rclinks' ],
932			$this->msg( 'rclinks' )->rawParams( $cl, $dl, '' )->parse()
933		);
934
935		$rclistfrom = Html::rawElement(
936			'span',
937			[ 'class' => 'rclistfrom' ],
938			$this->makeOptionsLink(
939				$this->msg( 'rclistfrom' )->plaintextParams( $now, $timenow, $datenow )->text(),
940				[ 'from' => $timestamp, 'fromFormatted' => $now ],
941				$nondefaults
942			)
943		);
944
945		return "{$note}$rclinks<br />$pipedLinks<br />$rclistfrom";
946	}
947
948	public function isIncludable() {
949		return true;
950	}
951
952	protected function getCacheTTL() {
953		return 60 * 5;
954	}
955
956	public function getDefaultLimit() {
957		$systemPrefValue = $this->userOptionsLookup->getIntOption( $this->getUser(), 'rclimit' );
958		// Prefer the RCFilters-specific preference if RCFilters is enabled
959		if ( $this->isStructuredFilterUiEnabled() ) {
960			return $this->userOptionsLookup->getIntOption(
961				$this->getUser(), static::$limitPreferenceName, $systemPrefValue
962			);
963		}
964
965		// Otherwise, use the system rclimit preference value
966		return $systemPrefValue;
967	}
968}
969