Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.27% covered (warning)
75.27%
353 / 469
53.85% covered (warning)
53.85%
14 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialWatchlist
75.43% covered (warning)
75.43%
353 / 468
53.85% covered (warning)
53.85%
14 / 26
232.74
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
85.29% covered (warning)
85.29%
29 / 34
0.00% covered (danger)
0.00%
0 / 1
12.46
 getRedirect
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
6.00
 getWatchlistLabelsForFiltering
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
2.98
 checkStructuredFilterUiEnabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getSubpagesForPrefixSearch
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getExtraFilterFactoryConfig
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getExtraFilterGroupDefinitions
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
1
 getFilterDefaultOverrides
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
6
 getDefaultOptions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 fetchOptionsFromRequest
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
 modifyQuery
37.50% covered (danger)
37.50%
6 / 16
0.00% covered (danger)
0.00%
0 / 1
23.62
 outputFeedLinks
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 outputChangesList
16.13% covered (danger)
16.13%
10 / 62
0.00% covered (danger)
0.00%
0 / 1
281.18
 doHeader
98.41% covered (success)
98.41%
124 / 126
0.00% covered (danger)
0.00%
0 / 1
6
 cutoffselector
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
4
 setTopText
40.54% covered (danger)
40.54%
15 / 37
0.00% covered (danger)
0.00%
0 / 1
26.03
 showHideCheck
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 countItems
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isChangeEffectivelySeen
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getLatestNotificationTimestamp
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getLimitPreferenceName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSavedQueriesPreferenceName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultDaysPreferenceName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCollapsedPreferenceName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Specials;
8
9use MediaWiki\Exception\UserNotLoggedIn;
10use MediaWiki\Html\FormOptions;
11use MediaWiki\Html\Html;
12use MediaWiki\MainConfigNames;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Page\PageReferenceValue;
15use MediaWiki\RecentChanges\ChangesList;
16use MediaWiki\RecentChanges\ChangesListBooleanFilterGroup;
17use MediaWiki\RecentChanges\ChangesListQuery\ChangesListQuery;
18use MediaWiki\RecentChanges\ChangesListQuery\ChangesListQueryFactory;
19use MediaWiki\RecentChanges\ChangesListStringOptionsFilterGroup;
20use MediaWiki\RecentChanges\EnhancedChangesList;
21use MediaWiki\RecentChanges\RecentChange;
22use MediaWiki\RecentChanges\RecentChangeFactory;
23use MediaWiki\Request\DerivativeRequest;
24use MediaWiki\Request\WebRequest;
25use MediaWiki\SpecialPage\ChangesListSpecialPage;
26use MediaWiki\SpecialPage\SpecialPage;
27use MediaWiki\Title\Title;
28use MediaWiki\User\Options\UserOptionsLookup;
29use MediaWiki\User\TempUser\TempUserConfig;
30use MediaWiki\User\User;
31use MediaWiki\User\UserIdentity;
32use MediaWiki\User\UserIdentityUtils;
33use MediaWiki\Watchlist\WatchedItem;
34use MediaWiki\Watchlist\WatchedItemStoreInterface;
35use MediaWiki\Watchlist\WatchlistLabelStore;
36use MediaWiki\Watchlist\WatchlistManager;
37use MediaWiki\Watchlist\WatchlistSpecialPage;
38use MediaWiki\Xml\XmlSelect;
39use Wikimedia\Message\MessageValue;
40use Wikimedia\Rdbms\IResultWrapper;
41
42/**
43 * @defgroup Watchlist Users watchlist handling
44 */
45
46/**
47 * A special page that lists last changes made to the wiki,
48 * limited to user-defined list of titles.
49 *
50 * @ingroup SpecialPage
51 * @ingroup Watchlist
52 */
53class SpecialWatchlist extends ChangesListSpecialPage {
54
55    use WatchlistSpecialPage;
56
57    // @todo Move this to WatchlistSpecialPage trait.
58    public const WATCHLIST_TAB_PATHS = [
59        'Special:Watchlist',
60        'Special:EditWatchlist',
61        'Special:WatchlistLabels',
62        'Special:EditWatchlist/raw',
63        'Special:EditWatchlist/clear'
64    ];
65
66    public const WATCHLIST_LABEL_CSS_CLASS_PREFIX = 'mw-changeslist-label-';
67
68    private array $watchlistLabelsForCurrentUser;
69
70    public function __construct(
71        private readonly WatchedItemStoreInterface $watchedItemStore,
72        private readonly WatchlistManager $watchlistManager,
73        private UserOptionsLookup $userOptionsLookup,
74        UserIdentityUtils $userIdentityUtils,
75        TempUserConfig $tempUserConfig,
76        RecentChangeFactory $recentChangeFactory,
77        ChangesListQueryFactory $changesListQueryFactory,
78        WatchlistLabelStore $watchlistLabelStore,
79    ) {
80        parent::__construct(
81            'Watchlist',
82            'viewmywatchlist',
83            $userIdentityUtils,
84            $tempUserConfig,
85            $recentChangeFactory,
86            $changesListQueryFactory,
87        );
88
89        $this->watchlistLabelsForCurrentUser = $watchlistLabelStore->loadAllForUser( $this->getUser() );
90    }
91
92    /** @inheritDoc */
93    public function doesWrites() {
94        return true;
95    }
96
97    /**
98     * Main execution point
99     *
100     * @param string|null $subpage
101     */
102    public function execute( $subpage ) {
103        $user = $this->getUser();
104        if (
105            // Anons don't get a watchlist
106            !$user->isRegistered()
107            // Redirect temp users to login if they're not allowed
108            || ( $user->isTemp() && !$user->isAllowed( 'viewmywatchlist' ) )
109        ) {
110            throw new UserNotLoggedIn( 'watchlistanontext' );
111        }
112
113        $output = $this->getOutput();
114        $request = $this->getRequest();
115        $this->addHelpLink( 'Help:Watchlist' );
116        $output->addModuleStyles( [ 'mediawiki.special' ] );
117        $output->addModules( [ 'mediawiki.special.watchlist' ] );
118
119        $title = $this->getRedirect( $request, $subpage );
120        if ( $title ) {
121            $output->redirect( $title->getLocalURL() );
122            return;
123        }
124
125        $this->checkPermissions();
126
127        $opts = $this->getOptions();
128
129        $config = $this->getConfig();
130        if ( ( $config->get( MainConfigNames::EnotifWatchlist ) ||
131                $config->get( MainConfigNames::ShowUpdatedMarker ) )
132            && $request->getVal( 'reset' )
133            && $request->wasPosted()
134            && $user->matchEditToken( $request->getVal( 'token' ) )
135        ) {
136            $this->watchlistManager->clearAllUserNotifications( $user );
137            $output->redirect( $this->getPageTitle()->getFullURL( $opts->getChangedValues() ) );
138
139            return;
140        }
141
142        parent::execute( $subpage );
143
144        if ( $this->isStructuredFilterUiEnabled() ) {
145            $output->addModuleStyles( [ 'mediawiki.rcfilters.highlightCircles.seenunseen.styles' ] );
146        }
147
148        if ( $this->getConfig()->get( MainConfigNames::EnableWatchlistLabels ) ) {
149            $output->addJsConfigVars( [
150                'enableWatchlistLabels' => true,
151                'watchlistLabels' => $this->getWatchlistLabelsForFiltering( $this->getUser() ),
152                'SpecialWatchlistLabelsUrl' => SpecialPage::getTitleFor( 'WatchlistLabels' )->getLinkURL(),
153                'SpecialEditWatchlistUrl' => SpecialPage::getTitleFor( 'EditWatchlist' )->getLinkURL(),
154            ] );
155        }
156    }
157
158    /**
159     * Handle legacy urls
160     *
161     * @param WebRequest $request
162     * @param string|null $subpage
163     * @return Title|null
164     */
165    private function getRedirect( WebRequest $request, ?string $subpage ): ?Title {
166        $title = null;
167        $mode = SpecialEditWatchlist::getMode( $request, $subpage );
168        $this->currentMode = $mode;
169        if ( $mode !== false ) {
170            if ( $mode === SpecialEditWatchlist::EDIT_RAW ) {
171                $title = SpecialPage::getTitleFor( 'EditWatchlist', 'raw' );
172            } elseif ( $mode === SpecialEditWatchlist::EDIT_CLEAR ) {
173                $title = SpecialPage::getTitleFor( 'EditWatchlist', 'clear' );
174            } else {
175                $title = SpecialPage::getTitleFor( 'EditWatchlist' );
176            }
177        }
178        return $title;
179    }
180
181    /**
182     * @param User $user
183     * @return array
184     */
185    private function getWatchlistLabelsForFiltering( User $user ): array {
186        $idAndNames = [];
187        foreach ( $this->watchlistLabelsForCurrentUser as $label ) {
188            $idAndNames[] = [
189                'name' => (string)$label->getId(),
190                'label' => $label->getName(),
191                'cssClass' => self::WATCHLIST_LABEL_CSS_CLASS_PREFIX . $label->getId(),
192            ];
193        }
194        return $idAndNames;
195    }
196
197    /**
198     * @inheritDoc
199     */
200    public static function checkStructuredFilterUiEnabled( UserIdentity $user ) {
201        return !MediaWikiServices::getInstance()
202            ->getUserOptionsLookup()
203            ->getOption( $user, 'wlenhancedfilters-disable' );
204    }
205
206    /**
207     * Return an array of subpages that this special page will accept.
208     *
209     * @see also SpecialEditWatchlist::getSubpagesForPrefixSearch
210     * @return string[] subpages
211     */
212    public function getSubpagesForPrefixSearch() {
213        return [
214            'edit',
215            'raw',
216            'clear',
217        ];
218    }
219
220    protected function getExtraFilterFactoryConfig(): array {
221        return [
222            'showHidePrefix' => 'wl',
223        ];
224    }
225
226    protected function getExtraFilterGroupDefinitions(): array {
227        return [
228            // legacy 'extended' filter
229            [
230                'name' => 'extended-group',
231                'class' => ChangesListBooleanFilterGroup::class,
232                'filters' => [
233                    [
234                        'name' => 'extended',
235                        'isReplacedInStructuredUi' => true,
236                        'activeValue' => false,
237                        'default' => $this->userOptionsLookup->getBoolOption( $this->getUser(), 'extendwatchlist' ),
238                        'action' => [
239                            [ 'require', 'revisionType', 'latest' ],
240                            [ 'require', 'revisionType', 'none' ]
241                        ],
242                    ]
243                ],
244            ],
245            [
246                'name' => 'watchlistactivity',
247                'title' => 'rcfilters-filtergroup-watchlistactivity',
248                'class' => ChangesListStringOptionsFilterGroup::class,
249                'priority' => 3,
250                'isFullCoverage' => true,
251                'filters' => [
252                    [
253                        'name' => 'unseen',
254                        'label' => 'rcfilters-filter-watchlistactivity-unseen-label',
255                        'description' => 'rcfilters-filter-watchlistactivity-unseen-description',
256                        'cssClassSuffix' => 'watchedunseen',
257                        'action' => [ 'require', 'seen', false ],
258                    ],
259                    [
260                        'name' => 'seen',
261                        'label' => 'rcfilters-filter-watchlistactivity-seen-label',
262                        'description' => 'rcfilters-filter-watchlistactivity-seen-description',
263                        'cssClassSuffix' => 'watchedseen',
264                        'action' => [ 'require', 'seen', true ],
265                    ],
266                ],
267                'default' => ChangesListStringOptionsFilterGroup::NONE,
268            ]
269        ];
270    }
271
272    protected function getFilterDefaultOverrides(): array {
273        $opt = fn ( $optName ) =>
274            $this->userOptionsLookup->getBoolOption( $this->getUser(), $optName );
275        $defaults = [
276            'lastRevision' => [
277                'hidepreviousrevisions' => !$opt( 'extendwatchlist' )
278            ],
279            'significance' => [
280                'hideminor' => $opt( 'watchlisthideminor' )
281            ],
282            'automated' => [
283                'hidebots' => $opt( 'watchlisthidebots' )
284            ],
285            'registration' => [
286                'hideanons' => $opt( 'watchlisthideanons' ),
287                'hideliu' => $opt( 'watchlisthideliu' )
288            ]
289        ];
290
291        // Selecting both hideanons and hideliu on watchlist preferences
292        // gives mutually exclusive filters, so those are ignored
293        if ( $opt( 'watchlisthideanons' ) && !$opt( 'watchlisthideliu' ) ) {
294            $defaults['userExpLevel'] = 'registered';
295        }
296
297        if ( $opt( 'watchlisthideliu' ) && !$opt( 'watchlisthideanons' ) ) {
298            $defaults['userExpLevel'] = 'unregistered';
299        }
300
301        if ( $opt( 'watchlisthidepatrolled' ) ) {
302            $defaults['reviewStatus'] = 'unpatrolled';
303            $defaults['legacyReviewStatus']['hidepatrolled'] = true;
304        }
305
306        $defaults['authorship']['hidemyself'] = $opt( 'watchlisthideown' );
307        $defaults['changeType']['hidecategorization'] = $opt( 'watchlisthidecategorization' );
308
309        return $defaults;
310    }
311
312    /** @inheritDoc */
313    public function getDefaultOptions() {
314        $opts = parent::getDefaultOptions();
315        $opts->add( 'wllabel', '' );
316        $opts->add( 'invertwllabels', false );
317        return $opts;
318    }
319
320    /**
321     * Fetch values for a FormOptions object from the WebRequest associated with this instance.
322     *
323     * Maps old pre-1.23 request parameters Watchlist used to use (different from Recentchanges' ones)
324     * to the current ones.
325     *
326     * @param FormOptions $opts
327     * @return FormOptions
328     */
329    protected function fetchOptionsFromRequest( $opts ) {
330        static $compatibilityMap = [
331            'hideMinor' => 'hideminor',
332            'hideBots' => 'hidebots',
333            'hideAnons' => 'hideanons',
334            'hideLiu' => 'hideliu',
335            'hidePatrolled' => 'hidepatrolled',
336            'hideOwn' => 'hidemyself',
337        ];
338
339        $params = $this->getRequest()->getValues();
340        foreach ( $compatibilityMap as $from => $to ) {
341            if ( isset( $params[$from] ) ) {
342                $params[$to] = $params[$from];
343                unset( $params[$from] );
344            }
345        }
346
347        if ( $this->getRequest()->getRawVal( 'action' ) == 'submit' ) {
348            $allBooleansFalse = [];
349
350            // If the user submitted the form, start with a baseline of "all
351            // booleans are false", then change the ones they checked.  This
352            // means we ignore the defaults.
353
354            // This is how we handle the fact that HTML forms don't submit
355            // unchecked boxes.
356            foreach ( $this->filterGroups->getLegacyShowHideFilters() as $filter ) {
357                $allBooleansFalse[ $filter->getName() ] = false;
358            }
359
360            $params += $allBooleansFalse;
361        }
362
363        // Not the prettiest way to achieve this… FormOptions internally depends on data sanitization
364        // methods defined on WebRequest and removing this dependency would cause some code duplication.
365        $request = new DerivativeRequest( $this->getRequest(), $params );
366        $opts->fetchValuesFromRequest( $request );
367
368        return $opts;
369    }
370
371    /**
372     * @inheritDoc
373     */
374    protected function modifyQuery( ChangesListQuery $query, FormOptions $opts ) {
375        if ( !$this->getUser()->isRegistered() ) {
376            // Broken but reachable from tests
377            $query->forceEmptySet();
378            return;
379        }
380        $query->requireWatched()
381            ->watchlistFields( [ 'wl_notificationtimestamp', 'we_expiry' ] );
382
383        if ( $this->getConfig()->get( MainConfigNames::EnableWatchlistLabels ) ) {
384            $query->addWatchlistLabelSummaryField();
385            if ( $opts['wllabel'] ) {
386                $ids = [];
387                foreach ( explode( ';', $opts['wllabel'] ) as $id ) {
388                    if ( preg_match( '/^[0-9]+$/', $id ) ) {
389                        $ids[] = (int)$id;
390                    }
391                }
392                if ( $ids ) {
393                    if ( $opts['invertwllabels'] ) {
394                        $query->excludeWatchlistLabelIds( $ids );
395                    } else {
396                        $query->requireWatchlistLabelIds( $ids );
397                    }
398                }
399            }
400        }
401    }
402
403    public function outputFeedLinks() {
404        $user = $this->getUser();
405        $wlToken = $user->getTokenFromOption( 'watchlisttoken' );
406        if ( $wlToken ) {
407            $this->addFeedLinks( [
408                'action' => 'feedwatchlist',
409                'allrev' => 1,
410                'wlowner' => $user->getName(),
411                'wltoken' => $wlToken,
412            ] );
413        }
414    }
415
416    /**
417     * Build and output the actual changes list.
418     *
419     * @param IResultWrapper $rows Database rows
420     * @param FormOptions $opts
421     */
422    public function outputChangesList( $rows, $opts ) {
423        $dbr = $this->getDB();
424        $user = $this->getUser();
425        $output = $this->getOutput();
426
427        // Show a message about replica DB lag, if applicable
428        $lag = $dbr->getSessionLagStatus()['lag'];
429        if ( $lag > 0 ) {
430            $output->showLagWarning( $lag );
431        }
432
433        // If there are no rows to display, show message before trying to render the list
434        if ( iterator_count( $rows ) == 0 ) {
435            $output->wrapWikiMsg(
436                "<div class='mw-changeslist-empty'>\n$1\n</div>", 'recentchanges-noresult'
437            );
438            return;
439        }
440
441        $list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups );
442        $list->setWatchlistDivs();
443        $list->initChangesListRows( $rows );
444        if ( $this->getConfig()->get( MainConfigNames::EnableWatchlistLabels ) ) {
445            $list->setUserLabels( $this->watchlistLabelsForCurrentUser );
446        }
447
448        if ( $this->userOptionsLookup->getBoolOption( $user, 'watchlistunwatchlinks' ) ) {
449            $list->setChangeLinePrefixer( function ( RecentChange $rc, ChangesList $cl, $grouped ) {
450                $unwatch = $this->msg( 'watchlist-unwatch' )->text();
451                // Don't show unwatch link if the line is a grouped log entry using EnhancedChangesList,
452                // since EnhancedChangesList groups log entries by performer rather than by target article
453                if ( $rc->getAttribute( 'rc_source' ) == RecentChange::SRC_LOG && $cl instanceof EnhancedChangesList &&
454                    $grouped ) {
455                    return "<span style='visibility:hidden'>$unwatch</span>\u{00A0}";
456                } else {
457                    $unwatchTooltipMessage = 'tooltip-ca-unwatch';
458                    // Check if the watchlist expiry flag is enabled to show new tooltip message
459                    if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) ) {
460                        $watchedItem = $this->watchedItemStore->getWatchedItem( $this->getUser(), $rc->getTitle() );
461                        if ( $watchedItem instanceof WatchedItem && $watchedItem->getExpiry() !== null ) {
462                            $diffInDays = $watchedItem->getExpiryInDays();
463
464                            if ( $diffInDays > 0 ) {
465                                $unwatchTooltipMessage = MessageValue::new( 'tooltip-ca-unwatch-expiring' )
466                                    ->numParams( $diffInDays );
467                            } else {
468                                $unwatchTooltipMessage = 'tooltip-ca-unwatch-expiring-hours';
469                            }
470                        }
471                    }
472
473                    return $this->getLinkRenderer()
474                            ->makeKnownLink( $rc->getTitle(),
475                                $unwatch, [
476                                    'class' => 'mw-unwatch-link',
477                                    'title' => $this->msg( $unwatchTooltipMessage )->text()
478                                ], [ 'action' => 'unwatch' ] ) . "\u{00A0}";
479                }
480            } );
481        }
482        $s = $list->beginRecentChangesList();
483
484        if ( $this->isStructuredFilterUiEnabled() ) {
485            $s .= $this->makeLegend();
486        }
487
488        $userShowHiddenCats = $this->userOptionsLookup->getBoolOption( $user, 'showhiddencats' );
489
490        $counter = 1;
491        foreach ( $rows as $obj ) {
492            // Make RC entry
493            $rc = $this->newRecentChangeFromRow( $obj );
494
495            // Skip CatWatch entries for hidden cats based on user preference
496            if (
497                $rc->getAttribute( 'rc_source' ) == RecentChange::SRC_CATEGORIZE &&
498                !$userShowHiddenCats &&
499                $rc->getParam( 'hidden-cat' )
500            ) {
501                continue;
502            }
503
504            $rc->counter = $counter++;
505
506            if ( $this->getConfig()->get( MainConfigNames::ShowUpdatedMarker ) ) {
507                $unseen = !$this->isChangeEffectivelySeen( $rc );
508            } else {
509                $unseen = false;
510            }
511
512            if ( $this->getConfig()->get( MainConfigNames::RCShowWatchingUsers )
513                && $this->userOptionsLookup->getBoolOption( $user, 'shownumberswatching' )
514            ) {
515                $rcPageRef = PageReferenceValue::localReference( (int)$obj->rc_namespace, $obj->rc_title );
516                $rc->numberofWatchingusers = $this->watchedItemStore->countWatchers( $rcPageRef );
517            } else {
518                $rc->numberofWatchingusers = 0;
519            }
520
521            // XXX: this treats pages with no unseen changes as "not on the watchlist" since
522            // everything is on the watchlist and it is an easy way to make pages with unseen
523            // changes appear bold. @TODO: clean this up.
524            $changeLine = $list->recentChangesLine( $rc, $unseen, $counter );
525            if ( $changeLine !== false ) {
526                $s .= $changeLine;
527            }
528        }
529        $s .= $list->endRecentChangesList();
530
531        $output->addHTML( $s );
532    }
533
534    /**
535     * Set the text to be displayed above the changes
536     *
537     * @param FormOptions $opts
538     * @param int $numRows Number of rows in the result to show after this header
539     */
540    public function doHeader( $opts, $numRows ) {
541        $user = $this->getUser();
542        $out = $this->getOutput();
543
544        $this->outputSubtitle();
545
546        $this->setTopText( $opts );
547
548        $form = '';
549
550        $form .= Html::openElement( 'form', [
551            'method' => 'get',
552            'action' => wfScript(),
553            'id' => 'mw-watchlist-form'
554        ] );
555        $form .= Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() );
556        $form .= Html::openElement(
557            'fieldset',
558            [ 'id' => 'mw-watchlist-options', 'class' => 'cloptions' ]
559        );
560        $form .= Html::element(
561            'legend', [], $this->msg( 'watchlist-options' )->text()
562        );
563
564        if ( !$this->isStructuredFilterUiEnabled() ) {
565            $form .= $this->makeLegend();
566        }
567
568        $lang = $this->getLanguage();
569        $timestamp = wfTimestampNow();
570        $now = $lang->userTimeAndDate( $timestamp, $user );
571        $wlInfo = Html::rawElement(
572            'span',
573            [
574                'class' => 'wlinfo',
575                'data-params' => json_encode( [ 'from' => $timestamp, 'fromFormatted' => $now ] ),
576            ],
577            $this->msg( 'wlnote' )->numParams( $numRows, round( $opts['days'] * 24 ) )->params(
578                $lang->userDate( $timestamp, $user ), $lang->userTime( $timestamp, $user )
579            )->parse()
580        ) . "<br />\n";
581
582        $nondefaults = $opts->getChangedValues();
583        $cutofflinks = Html::rawElement(
584            'span',
585            [ 'class' => [ 'cldays', 'cloption' ] ],
586            $this->msg( 'wlshowtime' ) . ' ' . $this->cutoffselector( $opts )
587        );
588
589        // Spit out some control panel links
590        $links = [];
591        $namesOfDisplayedFilters = [];
592        foreach ( $this->filterGroups->getLegacyShowHideFilters() as $filterName => $filter ) {
593            $namesOfDisplayedFilters[] = $filterName;
594            $links[] = $this->showHideCheck(
595                $nondefaults,
596                $filter->getShowHide(),
597                $filterName,
598                $opts[ $filterName ],
599                $filter->isFeatureAvailableOnStructuredUi()
600            );
601        }
602
603        $hiddenFields = $nondefaults;
604        $hiddenFields['action'] = 'submit';
605        unset( $hiddenFields['namespace'] );
606        unset( $hiddenFields['invert'] );
607        unset( $hiddenFields['associated'] );
608        unset( $hiddenFields['days'] );
609        foreach ( $namesOfDisplayedFilters as $filterName ) {
610            unset( $hiddenFields[$filterName] );
611        }
612
613        // Namespace filter and put the whole form together.
614        $form .= $wlInfo;
615        $form .= $cutofflinks;
616        $form .= Html::rawElement(
617            'span',
618            [ 'class' => 'clshowhide' ],
619            $this->msg( 'watchlist-hide' ) .
620            $this->msg( 'colon-separator' )->escaped() .
621            implode( ' ', $links )
622        );
623        $form .= "\n<br />\n";
624
625        $namespaceForm = Html::namespaceSelector(
626            [
627                'selected' => $opts['namespace'],
628                'all' => '',
629                'label' => $this->msg( 'namespace' )->text(),
630                'in-user-lang' => true,
631            ], [
632                'name' => 'namespace',
633                'id' => 'namespace',
634                'class' => 'namespaceselector',
635            ]
636        ) . "\n";
637        $namespaceForm .= Html::rawElement( 'label', [
638            'class' => 'mw-input-with-label', 'title' => $this->msg( 'tooltip-invert' )->text(),
639        ], Html::element( 'input', [
640            'type' => 'checkbox', 'name' => 'invert', 'value' => '1', 'checked' => $opts['invert'],
641        ] ) . '&nbsp;' . $this->msg( 'invert' )->escaped() ) . "\n";
642        $namespaceForm .= Html::rawElement( 'label', [
643            'class' => 'mw-input-with-label', 'title' => $this->msg( 'tooltip-namespace_association' )->text(),
644        ], Html::element( 'input', [
645            'type' => 'checkbox', 'name' => 'associated', 'value' => '1', 'checked' => $opts['associated'],
646        ] ) . '&nbsp;' . $this->msg( 'namespace_association' )->escaped() ) . "\n";
647        $form .= Html::rawElement(
648            'span',
649            [ 'class' => [ 'namespaceForm', 'cloption' ] ],
650            $namespaceForm
651        );
652
653        $form .= Html::submitButton(
654            $this->msg( 'watchlist-submit' )->text(),
655            [ 'class' => 'cloption-submit' ]
656        ) . "\n";
657        foreach ( $hiddenFields as $key => $value ) {
658            $form .= Html::hidden( $key, $value ) . "\n";
659        }
660        $form .= Html::closeElement( 'fieldset' ) . "\n";
661        $form .= Html::closeElement( 'form' ) . "\n";
662
663        // Insert a placeholder for RCFilters
664        if ( $this->isStructuredFilterUiEnabled() ) {
665            $rcfilterContainer = Html::element(
666                'div',
667                [ 'class' => 'mw-rcfilters-container' ]
668            );
669
670            $loadingContainer = Html::rawElement(
671                'div',
672                [ 'class' => 'mw-rcfilters-spinner' ],
673                Html::element(
674                    'div',
675                    [ 'class' => 'mw-rcfilters-spinner-bounce' ]
676                )
677            );
678
679            // Wrap both with mw-rcfilters-head
680            $this->getOutput()->addHTML(
681                Html::rawElement(
682                    'div',
683                    [ 'class' => 'mw-rcfilters-head' ],
684                    $rcfilterContainer . $form
685                )
686            );
687
688            // Add spinner
689            $this->getOutput()->addHTML( $loadingContainer );
690        } else {
691            $this->getOutput()->addHTML( $form );
692        }
693
694        $this->setBottomText( $opts );
695    }
696
697    private function cutoffselector( FormOptions $options ): string {
698        $selected = (float)$options['days'];
699        $maxDays = $this->getConfig()->get( MainConfigNames::RCMaxAge ) / ( 3600 * 24 );
700        if ( $selected <= 0 ) {
701            $selected = $maxDays;
702        }
703
704        $selectedHours = round( $selected * 24 );
705
706        $hours = array_unique( array_filter( [
707            1,
708            2,
709            6,
710            12,
711            24,
712            72,
713            168,
714            24 * (float)$this->userOptionsLookup->getOption( $this->getUser(), 'watchlistdays', 0 ),
715            24 * $maxDays,
716            $selectedHours
717        ] ) );
718        asort( $hours );
719
720        $select = new XmlSelect( 'days', 'days', $selectedHours / 24 );
721
722        foreach ( $hours as $value ) {
723            if ( $value < 24 ) {
724                $name = $this->msg( 'hours' )->numParams( $value )->text();
725            } else {
726                $name = $this->msg( 'days' )->numParams( $value / 24 )->text();
727            }
728            $select->addOption( $name, (float)( $value / 24 ) );
729        }
730
731        return $select->getHTML() . "\n<br />\n";
732    }
733
734    public function setTopText( FormOptions $opts ) {
735        $nondefaults = $opts->getChangedValues();
736        $form = '';
737        $user = $this->getUser();
738
739        $numItems = $this->countItems();
740        $showUpdatedMarker = $this->getConfig()->get( MainConfigNames::ShowUpdatedMarker );
741
742        // Show watchlist header
743        $watchlistHeader = '';
744        if ( $numItems == 0 ) {
745            $watchlistHeader = $this->msg( 'nowatchlist' )->parse();
746        } else {
747            $watchlistHeader .= $this->msg( 'watchlist-details' )->numParams( $numItems )->parse()
748                . $this->msg( 'word-separator' )->escaped();
749            if ( $this->getConfig()->get( MainConfigNames::EnotifWatchlist )
750                && $this->userOptionsLookup->getBoolOption( $user, 'enotifwatchlistpages' )
751            ) {
752                $watchlistHeader .= $this->msg( 'wlheader-enotif' )->parse()
753                    . $this->msg( 'word-separator' )->escaped();
754            }
755            if ( $showUpdatedMarker ) {
756                $watchlistHeader .= $this->msg(
757                    $this->isStructuredFilterUiEnabled() ?
758                        'rcfilters-watchlist-showupdated' :
759                        'wlheader-showupdated'
760                )->parse() . $this->msg( 'word-separator' )->escaped();
761            }
762        }
763        $form .= Html::rawElement(
764            'div',
765            [ 'class' => 'watchlistDetails' ],
766            $watchlistHeader
767        );
768
769        if ( $numItems > 0 && $showUpdatedMarker ) {
770            $form .= Html::openElement( 'form', [ 'method' => 'post',
771                'action' => $this->getPageTitle()->getLocalURL(),
772                'id' => 'mw-watchlist-resetbutton' ] ) . "\n" .
773            Html::submitButton( $this->msg( 'enotif_reset' )->text(),
774                [ 'name' => 'mw-watchlist-reset-submit' ] ) . "\n" .
775            Html::hidden( 'token', $user->getEditToken() ) . "\n" .
776            Html::hidden( 'reset', 'all' ) . "\n";
777            foreach ( $nondefaults as $key => $value ) {
778                $form .= Html::hidden( $key, $value ) . "\n";
779            }
780            $form .= Html::closeElement( 'form' ) . "\n";
781        }
782
783        $this->getOutput()->addHTML( $form );
784    }
785
786    /**
787     * @param array $options
788     * @param string $message
789     * @param string $name
790     * @param string $value
791     * @param bool $inStructuredUi
792     * @return string
793     */
794    protected function showHideCheck( $options, $message, $name, $value, $inStructuredUi ) {
795        $options[$name] = 1 - (int)$value;
796
797        $attribs = [ 'class' => [ 'mw-input-with-label', 'clshowhideoption', 'cloption' ] ];
798        if ( $inStructuredUi ) {
799            $attribs[ 'data-feature-in-structured-ui' ] = true;
800        }
801
802        return Html::rawElement(
803            'span',
804            $attribs,
805            // not using Html::label because that would escape the contents
806            Html::check( $name, (bool)$value, [ 'id' => $name ] ) . "\n" . Html::rawElement(
807                'label',
808                $attribs + [ 'for' => $name ],
809                // <nowiki/> at beginning to avoid messages with "$1 ..." being parsed as pre tags
810                $this->msg( $message, '<nowiki/>' )->parse()
811            )
812        );
813    }
814
815    /**
816     * Count the number of paired items on a user's watchlist.
817     * The assumption made here is that when a subject page is watched a talk page is also watched.
818     * Hence the number of individual items is halved.
819     *
820     * @return int
821     */
822    protected function countItems() {
823        $count = $this->watchedItemStore->countWatchedItems( $this->getUser() );
824        return (int)floor( $count / 2 );
825    }
826
827    /**
828     * @param RecentChange $rc
829     * @return bool User viewed the revision or a newer one
830     */
831    protected function isChangeEffectivelySeen( RecentChange $rc ) {
832        $firstUnseen = $this->getLatestNotificationTimestamp( $rc );
833
834        return ( $firstUnseen === null || $firstUnseen > $rc->getAttribute( 'rc_timestamp' ) );
835    }
836
837    /**
838     * @param RecentChange $rc
839     * @return string|null TS::MW timestamp of first unseen revision or null if there isn't one
840     */
841    private function getLatestNotificationTimestamp( RecentChange $rc ) {
842        return $this->watchedItemStore->getLatestNotificationTimestamp(
843            $rc->getAttribute( 'wl_notificationtimestamp' ),
844            $this->getUser(),
845            $rc->getTitle()
846        );
847    }
848
849    protected function getLimitPreferenceName(): string {
850        return 'wllimit';
851    }
852
853    protected function getSavedQueriesPreferenceName(): string {
854        return 'rcfilters-wl-saved-queries';
855    }
856
857    protected function getDefaultDaysPreferenceName(): string {
858        return 'watchlistdays';
859    }
860
861    protected function getCollapsedPreferenceName(): string {
862        return 'rcfilters-wl-collapsed';
863    }
864
865}
866
867/**
868 * Retain the old class name for backwards compatibility.
869 * @deprecated since 1.41
870 */
871class_alias( SpecialWatchlist::class, 'SpecialWatchlist' );