Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / specials / SpecialWatchlist.php
blob8bb973cbd5d6b3e79f9d53cbac3d1dbf27050f57
1 <?php
2 /**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
18 * @file
21 namespace MediaWiki\Specials;
23 use ChangesList;
24 use ChangesListBooleanFilterGroup;
25 use ChangesListStringOptionsFilterGroup;
26 use EnhancedChangesList;
27 use LogPage;
28 use MediaWiki\ChangeTags\ChangeTagsStore;
29 use MediaWiki\Context\IContextSource;
30 use MediaWiki\Html\FormOptions;
31 use MediaWiki\Html\Html;
32 use MediaWiki\MainConfigNames;
33 use MediaWiki\MediaWikiServices;
34 use MediaWiki\Request\DerivativeRequest;
35 use MediaWiki\SpecialPage\ChangesListSpecialPage;
36 use MediaWiki\SpecialPage\SpecialPage;
37 use MediaWiki\Title\TitleValue;
38 use MediaWiki\User\Options\UserOptionsLookup;
39 use MediaWiki\User\TempUser\TempUserConfig;
40 use MediaWiki\User\UserIdentity;
41 use MediaWiki\User\UserIdentityUtils;
42 use MediaWiki\Watchlist\WatchedItem;
43 use MediaWiki\Watchlist\WatchedItemStoreInterface;
44 use MediaWiki\Watchlist\WatchlistManager;
45 use MediaWiki\Xml\Xml;
46 use MediaWiki\Xml\XmlSelect;
47 use RecentChange;
48 use UserNotLoggedIn;
49 use Wikimedia\Message\MessageValue;
50 use Wikimedia\Rdbms\IReadableDatabase;
51 use Wikimedia\Rdbms\IResultWrapper;
52 use Wikimedia\Rdbms\RawSQLExpression;
53 use Wikimedia\Rdbms\RawSQLValue;
55 /**
56 * @defgroup Watchlist Users watchlist handling
59 /**
60 * A special page that lists last changes made to the wiki,
61 * limited to user-defined list of titles.
63 * @ingroup SpecialPage
64 * @ingroup Watchlist
66 class SpecialWatchlist extends ChangesListSpecialPage {
68 public const WATCHLIST_TAB_PATHS = [
69 'Special:Watchlist',
70 'Special:EditWatchlist',
71 'Special:EditWatchlist/raw',
72 'Special:EditWatchlist/clear'
75 private WatchedItemStoreInterface $watchedItemStore;
76 private WatchlistManager $watchlistManager;
77 private UserOptionsLookup $userOptionsLookup;
79 /**
80 * @var int|false where the value is one of the SpecialEditWatchlist:EDIT_ prefixed
81 * constants (e.g. EDIT_NORMAL)
83 private $currentMode;
84 private ChangeTagsStore $changeTagsStore;
86 /**
87 * @param WatchedItemStoreInterface $watchedItemStore
88 * @param WatchlistManager $watchlistManager
89 * @param UserOptionsLookup $userOptionsLookup
91 public function __construct(
92 WatchedItemStoreInterface $watchedItemStore,
93 WatchlistManager $watchlistManager,
94 UserOptionsLookup $userOptionsLookup,
95 ChangeTagsStore $changeTagsStore,
96 UserIdentityUtils $userIdentityUtils,
97 TempUserConfig $tempUserConfig
98 ) {
99 parent::__construct(
100 'Watchlist',
101 'viewmywatchlist',
102 $userIdentityUtils,
103 $tempUserConfig
106 $this->watchedItemStore = $watchedItemStore;
107 $this->watchlistManager = $watchlistManager;
108 $this->userOptionsLookup = $userOptionsLookup;
109 $this->changeTagsStore = $changeTagsStore;
112 public function doesWrites() {
113 return true;
117 * Main execution point
119 * @param string|null $subpage
121 public function execute( $subpage ) {
122 $user = $this->getUser();
123 if (
124 // Anons don't get a watchlist
125 !$user->isRegistered()
126 // Redirect temp users to login if they're not allowed
127 || ( $user->isTemp() && !$user->isAllowed( 'viewmywatchlist' ) )
129 throw new UserNotLoggedIn( 'watchlistanontext' );
132 $output = $this->getOutput();
133 $request = $this->getRequest();
134 $this->addHelpLink( 'Help:Watching pages' );
135 $output->addModuleStyles( [ 'mediawiki.special' ] );
136 $output->addModules( [ 'mediawiki.special.watchlist' ] );
138 $mode = SpecialEditWatchlist::getMode( $request, $subpage );
139 $this->currentMode = $mode;
141 if ( $mode !== false ) {
142 if ( $mode === SpecialEditWatchlist::EDIT_RAW ) {
143 $title = SpecialPage::getTitleFor( 'EditWatchlist', 'raw' );
144 } elseif ( $mode === SpecialEditWatchlist::EDIT_CLEAR ) {
145 $title = SpecialPage::getTitleFor( 'EditWatchlist', 'clear' );
146 } else {
147 $title = SpecialPage::getTitleFor( 'EditWatchlist' );
150 $output->redirect( $title->getLocalURL() );
152 return;
155 $this->checkPermissions();
157 $opts = $this->getOptions();
159 $config = $this->getConfig();
160 if ( ( $config->get( MainConfigNames::EnotifWatchlist ) ||
161 $config->get( MainConfigNames::ShowUpdatedMarker ) )
162 && $request->getVal( 'reset' )
163 && $request->wasPosted()
164 && $user->matchEditToken( $request->getVal( 'token' ) )
166 $this->watchlistManager->clearAllUserNotifications( $user );
167 $output->redirect( $this->getPageTitle()->getFullURL( $opts->getChangedValues() ) );
169 return;
172 parent::execute( $subpage );
174 if ( $this->isStructuredFilterUiEnabled() ) {
175 $output->addModuleStyles( [ 'mediawiki.rcfilters.highlightCircles.seenunseen.styles' ] );
180 * @inheritDoc
182 public static function checkStructuredFilterUiEnabled( UserIdentity $user ) {
183 return !MediaWikiServices::getInstance()
184 ->getUserOptionsLookup()
185 ->getOption( $user, 'wlenhancedfilters-disable' );
189 * Return an array of subpages that this special page will accept.
191 * @see also SpecialEditWatchlist::getSubpagesForPrefixSearch
192 * @return string[] subpages
194 public function getSubpagesForPrefixSearch() {
195 return [
196 'edit',
197 'raw',
198 'clear',
203 * @inheritDoc
205 protected function transformFilterDefinition( array $filterDefinition ) {
206 if ( isset( $filterDefinition['showHideSuffix'] ) ) {
207 $filterDefinition['showHide'] = 'wl' . $filterDefinition['showHideSuffix'];
210 return $filterDefinition;
214 * @inheritDoc
215 * @suppress PhanUndeclaredMethod
217 protected function registerFilters() {
218 parent::registerFilters();
220 // legacy 'extended' filter
221 $this->registerFilterGroup( new ChangesListBooleanFilterGroup( [
222 'name' => 'extended-group',
223 'filters' => [
225 'name' => 'extended',
226 'isReplacedInStructuredUi' => true,
227 'activeValue' => false,
228 'default' => $this->userOptionsLookup->getBoolOption( $this->getUser(), 'extendwatchlist' ),
229 'queryCallable' => function ( string $specialClassName, IContextSource $ctx,
230 IReadableDatabase $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
232 $nonRevisionTypes = [ RC_LOG ];
233 $this->getHookRunner()->onSpecialWatchlistGetNonRevisionTypes( $nonRevisionTypes );
234 if ( $nonRevisionTypes ) {
235 $conds[] = $dbr->expr( 'rc_this_oldid', '=', new RawSQLValue( 'page_latest' ) )
236 ->or( 'rc_type', '=', $nonRevisionTypes );
242 ] ) );
244 if ( $this->isStructuredFilterUiEnabled() ) {
245 $this->getFilterGroup( 'lastRevision' )
246 ->getFilter( 'hidepreviousrevisions' )
247 ->setDefault( !$this->userOptionsLookup->getBoolOption( $this->getUser(), 'extendwatchlist' ) );
250 $this->registerFilterGroup( new ChangesListStringOptionsFilterGroup( [
251 'name' => 'watchlistactivity',
252 'title' => 'rcfilters-filtergroup-watchlistactivity',
253 'class' => ChangesListStringOptionsFilterGroup::class,
254 'priority' => 3,
255 'isFullCoverage' => true,
256 'filters' => [
258 'name' => 'unseen',
259 'label' => 'rcfilters-filter-watchlistactivity-unseen-label',
260 'description' => 'rcfilters-filter-watchlistactivity-unseen-description',
261 'cssClassSuffix' => 'watchedunseen',
262 'isRowApplicableCallable' => function ( IContextSource $ctx, RecentChange $rc ) {
263 return !$this->isChangeEffectivelySeen( $rc );
267 'name' => 'seen',
268 'label' => 'rcfilters-filter-watchlistactivity-seen-label',
269 'description' => 'rcfilters-filter-watchlistactivity-seen-description',
270 'cssClassSuffix' => 'watchedseen',
271 'isRowApplicableCallable' => function ( IContextSource $ctx, RecentChange $rc ) {
272 return $this->isChangeEffectivelySeen( $rc );
276 'default' => ChangesListStringOptionsFilterGroup::NONE,
277 'queryCallable' => static function (
278 string $specialPageClassName,
279 IContextSource $context,
280 IReadableDatabase $dbr,
281 &$tables,
282 &$fields,
283 &$conds,
284 &$query_options,
285 &$join_conds,
286 $selectedValues
288 if ( $selectedValues === [ 'seen' ] ) {
289 $conds[] = $dbr->expr( 'wl_notificationtimestamp', '=', null )
290 ->orExpr( new RawSQLExpression( 'rc_timestamp < wl_notificationtimestamp' ) );
291 } elseif ( $selectedValues === [ 'unseen' ] ) {
292 $conds[] = $dbr->expr( 'wl_notificationtimestamp', '!=', null )
293 ->andExpr( new RawSQLExpression( 'rc_timestamp >= wl_notificationtimestamp' ) );
296 ] ) );
298 $user = $this->getUser();
300 $significance = $this->getFilterGroup( 'significance' );
301 $hideMinor = $significance->getFilter( 'hideminor' );
302 $hideMinor->setDefault( $this->userOptionsLookup->getBoolOption( $user, 'watchlisthideminor' ) );
304 $automated = $this->getFilterGroup( 'automated' );
305 $hideBots = $automated->getFilter( 'hidebots' );
306 $hideBots->setDefault( $this->userOptionsLookup->getBoolOption( $user, 'watchlisthidebots' ) );
308 $registration = $this->getFilterGroup( 'registration' );
309 $hideAnons = $registration->getFilter( 'hideanons' );
310 $hideAnons->setDefault( $this->userOptionsLookup->getBoolOption( $user, 'watchlisthideanons' ) );
311 $hideLiu = $registration->getFilter( 'hideliu' );
312 $hideLiu->setDefault( $this->userOptionsLookup->getBoolOption( $user, 'watchlisthideliu' ) );
314 // Selecting both hideanons and hideliu on watchlist preferences
315 // gives mutually exclusive filters, so those are ignored
316 if ( $this->userOptionsLookup->getBoolOption( $user, 'watchlisthideanons' ) &&
317 !$this->userOptionsLookup->getBoolOption( $user, 'watchlisthideliu' )
319 $this->getFilterGroup( 'userExpLevel' )
320 ->setDefault( 'registered' );
323 if ( $this->userOptionsLookup->getBoolOption( $user, 'watchlisthideliu' ) &&
324 !$this->userOptionsLookup->getBoolOption( $user, 'watchlisthideanons' )
326 $this->getFilterGroup( 'userExpLevel' )
327 ->setDefault( 'unregistered' );
330 $reviewStatus = $this->getFilterGroup( 'reviewStatus' );
331 if ( $reviewStatus !== null ) {
332 // Conditional on feature being available and rights
333 if ( $this->userOptionsLookup->getBoolOption( $user, 'watchlisthidepatrolled' ) ) {
334 $reviewStatus->setDefault( 'unpatrolled' );
335 $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' );
336 $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' );
337 $legacyHidePatrolled->setDefault( true );
341 $authorship = $this->getFilterGroup( 'authorship' );
342 $hideMyself = $authorship->getFilter( 'hidemyself' );
343 $hideMyself->setDefault( $this->userOptionsLookup->getBoolOption( $user, 'watchlisthideown' ) );
345 $changeType = $this->getFilterGroup( 'changeType' );
346 $hideCategorization = $changeType->getFilter( 'hidecategorization' );
347 if ( $hideCategorization !== null ) {
348 // Conditional on feature being available
349 $hideCategorization->setDefault(
350 $this->userOptionsLookup->getBoolOption( $user, 'watchlisthidecategorization' )
356 * Fetch values for a FormOptions object from the WebRequest associated with this instance.
358 * Maps old pre-1.23 request parameters Watchlist used to use (different from Recentchanges' ones)
359 * to the current ones.
361 * @param FormOptions $opts
362 * @return FormOptions
364 protected function fetchOptionsFromRequest( $opts ) {
365 static $compatibilityMap = [
366 'hideMinor' => 'hideminor',
367 'hideBots' => 'hidebots',
368 'hideAnons' => 'hideanons',
369 'hideLiu' => 'hideliu',
370 'hidePatrolled' => 'hidepatrolled',
371 'hideOwn' => 'hidemyself',
374 $params = $this->getRequest()->getValues();
375 foreach ( $compatibilityMap as $from => $to ) {
376 if ( isset( $params[$from] ) ) {
377 $params[$to] = $params[$from];
378 unset( $params[$from] );
382 if ( $this->getRequest()->getRawVal( 'action' ) == 'submit' ) {
383 $allBooleansFalse = [];
385 // If the user submitted the form, start with a baseline of "all
386 // booleans are false", then change the ones they checked. This
387 // means we ignore the defaults.
389 // This is how we handle the fact that HTML forms don't submit
390 // unchecked boxes.
391 foreach ( $this->getLegacyShowHideFilters() as $filter ) {
392 $allBooleansFalse[ $filter->getName() ] = false;
395 $params += $allBooleansFalse;
398 // Not the prettiest way to achieve this… FormOptions internally depends on data sanitization
399 // methods defined on WebRequest and removing this dependency would cause some code duplication.
400 $request = new DerivativeRequest( $this->getRequest(), $params );
401 $opts->fetchValuesFromRequest( $request );
403 return $opts;
407 * @inheritDoc
409 protected function doMainQuery( $tables, $fields, $conds, $query_options,
410 $join_conds, FormOptions $opts
412 $dbr = $this->getDB();
413 $user = $this->getUser();
415 $rcQuery = RecentChange::getQueryInfo();
416 $tables = array_merge( $rcQuery['tables'], $tables, [ 'watchlist' ] );
417 $fields = array_merge( $rcQuery['fields'], $fields );
419 $join_conds = array_merge(
421 'watchlist' => [
422 'JOIN',
424 'wl_user' => $user->getId(),
425 'wl_namespace=rc_namespace',
426 'wl_title=rc_title'
430 $rcQuery['joins'],
431 $join_conds
434 if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) ) {
435 $tables[] = 'watchlist_expiry';
436 $fields[] = 'we_expiry';
437 $join_conds['watchlist_expiry'] = [ 'LEFT JOIN', 'wl_id = we_item' ];
438 $conds[] = $dbr->expr( 'we_expiry', '=', null )->or( 'we_expiry', '>', $dbr->timestamp() );
441 $tables[] = 'page';
442 $fields[] = 'page_latest';
443 $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
445 $fields[] = 'wl_notificationtimestamp';
447 // Log entries with DELETED_ACTION must not show up unless the user has
448 // the necessary rights.
449 $authority = $this->getAuthority();
450 if ( !$authority->isAllowed( 'deletedhistory' ) ) {
451 $bitmask = LogPage::DELETED_ACTION;
452 } elseif ( !$authority->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
453 $bitmask = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED;
454 } else {
455 $bitmask = 0;
457 if ( $bitmask ) {
458 $conds[] = $dbr->expr( 'rc_type', '!=', RC_LOG )
459 ->orExpr( new RawSQLExpression( $dbr->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask" ) );
462 $tagFilter = $opts['tagfilter'] !== '' ? explode( '|', $opts['tagfilter'] ) : [];
463 $this->changeTagsStore->modifyDisplayQuery(
464 $tables,
465 $fields,
466 $conds,
467 $join_conds,
468 $query_options,
469 $tagFilter,
470 $opts['inverttags']
473 $this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds, $opts );
475 if ( $this->areFiltersInConflict() ) {
476 return false;
479 $orderByAndLimit = [
480 'ORDER BY' => 'rc_timestamp DESC',
481 'LIMIT' => $opts['limit']
483 if ( in_array( 'DISTINCT', $query_options ) ) {
484 // ChangeTagsStore::modifyDisplayQuery() adds DISTINCT when filtering on multiple tags.
485 // In order to prevent DISTINCT from causing query performance problems,
486 // we have to GROUP BY the primary key. This in turn requires us to add
487 // the primary key to the end of the ORDER BY, and the old ORDER BY to the
488 // start of the GROUP BY
489 $orderByAndLimit['ORDER BY'] = 'rc_timestamp DESC, rc_id DESC';
490 $orderByAndLimit['GROUP BY'] = 'rc_timestamp, rc_id';
492 // array_merge() is used intentionally here so that hooks can, should
493 // they so desire, override the ORDER BY / LIMIT condition(s)
494 $query_options = array_merge( $orderByAndLimit, $query_options );
495 $query_options['MAX_EXECUTION_TIME'] =
496 $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries );
497 $queryBuilder = $dbr->newSelectQueryBuilder()
498 ->tables( $tables )
499 ->conds( $conds )
500 ->fields( $fields )
501 ->options( $query_options )
502 ->joinConds( $join_conds )
503 ->caller( __METHOD__ );
505 return $queryBuilder->fetchResultSet();
508 public function outputFeedLinks() {
509 $user = $this->getUser();
510 $wlToken = $user->getTokenFromOption( 'watchlisttoken' );
511 if ( $wlToken ) {
512 $this->addFeedLinks( [
513 'action' => 'feedwatchlist',
514 'allrev' => 1,
515 'wlowner' => $user->getName(),
516 'wltoken' => $wlToken,
517 ] );
522 * Build and output the actual changes list.
524 * @param IResultWrapper $rows Database rows
525 * @param FormOptions $opts
527 public function outputChangesList( $rows, $opts ) {
528 $dbr = $this->getDB();
529 $user = $this->getUser();
530 $output = $this->getOutput();
532 // Show a message about replica DB lag, if applicable
533 $lag = $dbr->getSessionLagStatus()['lag'];
534 if ( $lag > 0 ) {
535 $output->showLagWarning( $lag );
538 // If there are no rows to display, show message before trying to render the list
539 if ( $rows->numRows() == 0 ) {
540 $output->wrapWikiMsg(
541 "<div class='mw-changeslist-empty'>\n$1\n</div>", 'recentchanges-noresult'
543 return;
546 $rows->seek( 0 );
548 $list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups );
549 $list->setWatchlistDivs();
550 $list->initChangesListRows( $rows );
552 if ( $this->userOptionsLookup->getBoolOption( $user, 'watchlistunwatchlinks' ) ) {
553 $list->setChangeLinePrefixer( function ( RecentChange $rc, ChangesList $cl, $grouped ) {
554 $unwatch = $this->msg( 'watchlist-unwatch' )->text();
555 // Don't show unwatch link if the line is a grouped log entry using EnhancedChangesList,
556 // since EnhancedChangesList groups log entries by performer rather than by target article
557 if ( $rc->mAttribs['rc_type'] == RC_LOG && $cl instanceof EnhancedChangesList &&
558 $grouped ) {
559 return "<span style='visibility:hidden'>$unwatch</span>\u{00A0}";
560 } else {
561 $unwatchTooltipMessage = 'tooltip-ca-unwatch';
562 // Check if the watchlist expiry flag is enabled to show new tooltip message
563 if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) ) {
564 $watchedItem = $this->watchedItemStore->getWatchedItem( $this->getUser(), $rc->getTitle() );
565 if ( $watchedItem instanceof WatchedItem && $watchedItem->getExpiry() !== null ) {
566 $diffInDays = $watchedItem->getExpiryInDays();
568 if ( $diffInDays > 0 ) {
569 $unwatchTooltipMessage = MessageValue::new( 'tooltip-ca-unwatch-expiring' )
570 ->numParams( $diffInDays );
571 } else {
572 $unwatchTooltipMessage = 'tooltip-ca-unwatch-expiring-hours';
577 return $this->getLinkRenderer()
578 ->makeKnownLink( $rc->getTitle(),
579 $unwatch, [
580 'class' => 'mw-unwatch-link',
581 'title' => $this->msg( $unwatchTooltipMessage )->text()
582 ], [ 'action' => 'unwatch' ] ) . "\u{00A0}";
584 } );
586 $rows->seek( 0 );
588 $s = $list->beginRecentChangesList();
590 if ( $this->isStructuredFilterUiEnabled() ) {
591 $s .= $this->makeLegend();
594 $userShowHiddenCats = $this->userOptionsLookup->getBoolOption( $user, 'showhiddencats' );
595 $counter = 1;
596 foreach ( $rows as $obj ) {
597 // Make RC entry
598 $rc = RecentChange::newFromRow( $obj );
600 // Skip CatWatch entries for hidden cats based on user preference
601 if (
602 $rc->getAttribute( 'rc_type' ) == RC_CATEGORIZE &&
603 !$userShowHiddenCats &&
604 $rc->getParam( 'hidden-cat' )
606 continue;
609 $rc->counter = $counter++;
611 if ( $this->getConfig()->get( MainConfigNames::ShowUpdatedMarker ) ) {
612 $unseen = !$this->isChangeEffectivelySeen( $rc );
613 } else {
614 $unseen = false;
617 if ( $this->getConfig()->get( MainConfigNames::RCShowWatchingUsers )
618 && $this->userOptionsLookup->getBoolOption( $user, 'shownumberswatching' )
620 $rcTitleValue = new TitleValue( (int)$obj->rc_namespace, $obj->rc_title );
621 $rc->numberofWatchingusers = $this->watchedItemStore->countWatchers( $rcTitleValue );
622 } else {
623 $rc->numberofWatchingusers = 0;
626 // XXX: this treats pages with no unseen changes as "not on the watchlist" since
627 // everything is on the watchlist and it is an easy way to make pages with unseen
628 // changes appear bold. @TODO: clean this up.
629 $changeLine = $list->recentChangesLine( $rc, $unseen, $counter );
630 if ( $changeLine !== false ) {
631 $s .= $changeLine;
634 $s .= $list->endRecentChangesList();
636 $output->addHTML( $s );
640 * @inheritDoc
642 public function getAssociatedNavigationLinks() {
643 return self::WATCHLIST_TAB_PATHS;
647 * @param SpecialPage $specialPage
648 * @param string $path
649 * @return string
651 public static function getShortDescriptionHelper( SpecialPage $specialPage, string $path = '' ): string {
652 switch ( $path ) {
653 case 'Watchlist':
654 return $specialPage->msg( 'watchlisttools-view' )->text();
655 case 'EditWatchlist':
656 return $specialPage->msg( 'watchlisttools-edit' )->text();
657 case 'EditWatchlist/raw':
658 return $specialPage->msg( 'watchlisttools-raw' )->text();
659 case 'EditWatchlist/clear':
660 return $specialPage->msg( 'watchlisttools-clear' )->text();
661 default:
662 return $path;
667 * @inheritDoc
669 public function getShortDescription( string $path = '' ): string {
670 return self::getShortDescriptionHelper( $this, $path );
674 * Set the text to be displayed above the changes
676 * @param FormOptions $opts
677 * @param int $numRows Number of rows in the result to show after this header
679 public function doHeader( $opts, $numRows ) {
680 $user = $this->getUser();
681 $out = $this->getOutput();
682 $skin = $this->getSkin();
683 // For legacy skins render the tabs in the subtitle
684 $subpageSubtitle = $skin->supportsMenu( 'associated-pages' ) ? '' :
685 ' ' .
686 SpecialEditWatchlist::buildTools(
687 null,
688 $this->getLinkRenderer(),
689 $this->currentMode
692 $out->addSubtitle(
693 Html::element(
694 'span',
696 'class' => 'mw-watchlist-owner'
698 // Previously the watchlistfor2 message took 2 parameters.
699 // It now only takes 1 so empty string is passed.
700 // Empty string parameter can be removed when all messages
701 // are updated to not use $2
702 $this->msg( 'watchlistfor2', $this->getUser()->getName(), '' )->text()
703 ) . $subpageSubtitle
706 $this->setTopText( $opts );
708 $form = '';
710 $form .= Xml::openElement( 'form', [
711 'method' => 'get',
712 'action' => wfScript(),
713 'id' => 'mw-watchlist-form'
714 ] );
715 $form .= Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() );
716 $form .= Xml::openElement(
717 'fieldset',
718 [ 'id' => 'mw-watchlist-options', 'class' => 'cloptions' ]
720 $form .= Xml::element(
721 'legend', null, $this->msg( 'watchlist-options' )->text()
724 if ( !$this->isStructuredFilterUiEnabled() ) {
725 $form .= $this->makeLegend();
728 $lang = $this->getLanguage();
729 $timestamp = wfTimestampNow();
730 $now = $lang->userTimeAndDate( $timestamp, $user );
731 $wlInfo = Html::rawElement(
732 'span',
734 'class' => 'wlinfo',
735 'data-params' => json_encode( [ 'from' => $timestamp, 'fromFormatted' => $now ] ),
737 $this->msg( 'wlnote' )->numParams( $numRows, round( $opts['days'] * 24 ) )->params(
738 $lang->userDate( $timestamp, $user ), $lang->userTime( $timestamp, $user )
739 )->parse()
740 ) . "<br />\n";
742 $nondefaults = $opts->getChangedValues();
743 $cutofflinks = Html::rawElement(
744 'span',
745 [ 'class' => 'cldays cloption' ],
746 $this->msg( 'wlshowtime' ) . ' ' . $this->cutoffselector( $opts )
749 // Spit out some control panel links
750 $links = [];
751 $namesOfDisplayedFilters = [];
752 foreach ( $this->getLegacyShowHideFilters() as $filterName => $filter ) {
753 $namesOfDisplayedFilters[] = $filterName;
754 $links[] = $this->showHideCheck(
755 $nondefaults,
756 $filter->getShowHide(),
757 $filterName,
758 $opts[ $filterName ],
759 $filter->isFeatureAvailableOnStructuredUi()
763 $hiddenFields = $nondefaults;
764 $hiddenFields['action'] = 'submit';
765 unset( $hiddenFields['namespace'] );
766 unset( $hiddenFields['invert'] );
767 unset( $hiddenFields['associated'] );
768 unset( $hiddenFields['days'] );
769 foreach ( $namesOfDisplayedFilters as $filterName ) {
770 unset( $hiddenFields[$filterName] );
773 // Namespace filter and put the whole form together.
774 $form .= $wlInfo;
775 $form .= $cutofflinks;
776 $form .= Html::rawElement(
777 'span',
778 [ 'class' => 'clshowhide' ],
779 $this->msg( 'watchlist-hide' ) .
780 $this->msg( 'colon-separator' )->escaped() .
781 implode( ' ', $links )
783 $form .= "\n<br />\n";
785 $namespaceForm = Html::namespaceSelector(
787 'selected' => $opts['namespace'],
788 'all' => '',
789 'label' => $this->msg( 'namespace' )->text(),
790 'in-user-lang' => true,
791 ], [
792 'name' => 'namespace',
793 'id' => 'namespace',
794 'class' => 'namespaceselector',
796 ) . "\n";
797 $namespaceForm .= Html::rawElement( 'label', [
798 'class' => 'mw-input-with-label', 'title' => $this->msg( 'tooltip-invert' )->text(),
799 ], Html::element( 'input', [
800 'type' => 'checkbox', 'name' => 'invert', 'value' => '1', 'checked' => $opts['invert'],
801 ] ) . '&nbsp;' . $this->msg( 'invert' )->escaped() ) . "\n";
802 $namespaceForm .= Html::rawElement( 'label', [
803 'class' => 'mw-input-with-label', 'title' => $this->msg( 'tooltip-namespace_association' )->text(),
804 ], Html::element( 'input', [
805 'type' => 'checkbox', 'name' => 'associated', 'value' => '1', 'checked' => $opts['associated'],
806 ] ) . '&nbsp;' . $this->msg( 'namespace_association' )->escaped() ) . "\n";
807 $form .= Html::rawElement(
808 'span',
809 [ 'class' => 'namespaceForm cloption' ],
810 $namespaceForm
813 $form .= Xml::submitButton(
814 $this->msg( 'watchlist-submit' )->text(),
815 [ 'class' => 'cloption-submit' ]
816 ) . "\n";
817 foreach ( $hiddenFields as $key => $value ) {
818 $form .= Html::hidden( $key, $value ) . "\n";
820 $form .= Xml::closeElement( 'fieldset' ) . "\n";
821 $form .= Xml::closeElement( 'form' ) . "\n";
823 // Insert a placeholder for RCFilters
824 if ( $this->isStructuredFilterUiEnabled() ) {
825 $rcfilterContainer = Html::element(
826 'div',
827 [ 'class' => 'mw-rcfilters-container' ]
830 $loadingContainer = Html::rawElement(
831 'div',
832 [ 'class' => 'mw-rcfilters-spinner' ],
833 Html::element(
834 'div',
835 [ 'class' => 'mw-rcfilters-spinner-bounce' ]
839 // Wrap both with mw-rcfilters-head
840 $this->getOutput()->addHTML(
841 Html::rawElement(
842 'div',
843 [ 'class' => 'mw-rcfilters-head' ],
844 $rcfilterContainer . $form
848 // Add spinner
849 $this->getOutput()->addHTML( $loadingContainer );
850 } else {
851 $this->getOutput()->addHTML( $form );
854 $this->setBottomText( $opts );
857 private function cutoffselector( $options ) {
858 $selected = (float)$options['days'];
859 $maxDays = $this->getConfig()->get( MainConfigNames::RCMaxAge ) / ( 3600 * 24 );
860 if ( $selected <= 0 ) {
861 $selected = $maxDays;
864 $selectedHours = round( $selected * 24 );
866 $hours = array_unique( array_filter( [
873 168,
874 24 * (float)$this->userOptionsLookup->getOption( $this->getUser(), 'watchlistdays', 0 ),
875 24 * $maxDays,
876 $selectedHours
877 ] ) );
878 asort( $hours );
880 $select = new XmlSelect( 'days', 'days', $selectedHours / 24 );
882 foreach ( $hours as $value ) {
883 if ( $value < 24 ) {
884 $name = $this->msg( 'hours' )->numParams( $value )->text();
885 } else {
886 $name = $this->msg( 'days' )->numParams( $value / 24 )->text();
888 $select->addOption( $name, (float)( $value / 24 ) );
891 return $select->getHTML() . "\n<br />\n";
894 public function setTopText( FormOptions $opts ) {
895 $nondefaults = $opts->getChangedValues();
896 $form = '';
897 $user = $this->getUser();
899 $numItems = $this->countItems();
900 $showUpdatedMarker = $this->getConfig()->get( MainConfigNames::ShowUpdatedMarker );
902 // Show watchlist header
903 $watchlistHeader = '';
904 if ( $numItems == 0 ) {
905 $watchlistHeader = $this->msg( 'nowatchlist' )->parse();
906 } else {
907 $watchlistHeader .= $this->msg( 'watchlist-details' )->numParams( $numItems )->parse()
908 . $this->msg( 'word-separator' )->escaped();
909 if ( $this->getConfig()->get( MainConfigNames::EnotifWatchlist )
910 && $this->userOptionsLookup->getBoolOption( $user, 'enotifwatchlistpages' )
912 $watchlistHeader .= $this->msg( 'wlheader-enotif' )->parse()
913 . $this->msg( 'word-separator' )->escaped();
915 if ( $showUpdatedMarker ) {
916 $watchlistHeader .= $this->msg(
917 $this->isStructuredFilterUiEnabled() ?
918 'rcfilters-watchlist-showupdated' :
919 'wlheader-showupdated'
920 )->parse() . $this->msg( 'word-separator' )->escaped();
923 $form .= Html::rawElement(
924 'div',
925 [ 'class' => 'watchlistDetails' ],
926 $watchlistHeader
929 if ( $numItems > 0 && $showUpdatedMarker ) {
930 $form .= Xml::openElement( 'form', [ 'method' => 'post',
931 'action' => $this->getPageTitle()->getLocalURL(),
932 'id' => 'mw-watchlist-resetbutton' ] ) . "\n" .
933 Xml::submitButton( $this->msg( 'enotif_reset' )->text(),
934 [ 'name' => 'mw-watchlist-reset-submit' ] ) . "\n" .
935 Html::hidden( 'token', $user->getEditToken() ) . "\n" .
936 Html::hidden( 'reset', 'all' ) . "\n";
937 foreach ( $nondefaults as $key => $value ) {
938 $form .= Html::hidden( $key, $value ) . "\n";
940 $form .= Xml::closeElement( 'form' ) . "\n";
943 $this->getOutput()->addHTML( $form );
946 protected function showHideCheck( $options, $message, $name, $value, $inStructuredUi ) {
947 $options[$name] = 1 - (int)$value;
949 $attribs = [ 'class' => 'mw-input-with-label clshowhideoption cloption' ];
950 if ( $inStructuredUi ) {
951 $attribs[ 'data-feature-in-structured-ui' ] = true;
954 return Html::rawElement(
955 'span',
956 $attribs,
957 // not using Html::label because that would escape the contents
958 Html::check( $name, (bool)$value, [ 'id' => $name ] ) . "\n" . Html::rawElement(
959 'label',
960 $attribs + [ 'for' => $name ],
961 // <nowiki/> at beginning to avoid messages with "$1 ..." being parsed as pre tags
962 $this->msg( $message, '<nowiki/>' )->parse()
968 * Count the number of paired items on a user's watchlist.
969 * The assumption made here is that when a subject page is watched a talk page is also watched.
970 * Hence the number of individual items is halved.
972 * @return int
974 protected function countItems() {
975 $count = $this->watchedItemStore->countWatchedItems( $this->getUser() );
976 return (int)floor( $count / 2 );
980 * @param RecentChange $rc
981 * @return bool User viewed the revision or a newer one
983 protected function isChangeEffectivelySeen( RecentChange $rc ) {
984 $firstUnseen = $this->getLatestNotificationTimestamp( $rc );
986 return ( $firstUnseen === null || $firstUnseen > $rc->getAttribute( 'rc_timestamp' ) );
990 * @param RecentChange $rc
991 * @return string|null TS_MW timestamp of first unseen revision or null if there isn't one
993 private function getLatestNotificationTimestamp( RecentChange $rc ) {
994 return $this->watchedItemStore->getLatestNotificationTimestamp(
995 $rc->getAttribute( 'wl_notificationtimestamp' ),
996 $this->getUser(),
997 $rc->getTitle()
1002 * @return string
1004 protected function getLimitPreferenceName(): string {
1005 return 'wllimit';
1009 * @return string
1011 protected function getSavedQueriesPreferenceName(): string {
1012 return 'rcfilters-wl-saved-queries';
1016 * @return string
1018 protected function getDefaultDaysPreferenceName(): string {
1019 return 'watchlistdays';
1023 * @return string
1025 protected function getCollapsedPreferenceName(): string {
1026 return 'rcfilters-wl-collapsed';
1032 * Retain the old class name for backwards compatibility.
1033 * @deprecated since 1.41
1035 class_alias( SpecialWatchlist::class, 'SpecialWatchlist' );