SpecialWatchlist.php 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890
  1. <?php
  2. /**
  3. * Implements Special:Watchlist
  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. use MediaWiki\MediaWikiServices;
  24. use Wikimedia\Rdbms\IResultWrapper;
  25. use Wikimedia\Rdbms\IDatabase;
  26. /**
  27. * A special page that lists last changes made to the wiki,
  28. * limited to user-defined list of titles.
  29. *
  30. * @ingroup SpecialPage
  31. */
  32. class SpecialWatchlist extends ChangesListSpecialPage {
  33. protected static $savedQueriesPreferenceName = 'rcfilters-wl-saved-queries';
  34. protected static $daysPreferenceName = 'watchlistdays';
  35. protected static $limitPreferenceName = 'wllimit';
  36. protected static $collapsedPreferenceName = 'rcfilters-wl-collapsed';
  37. /** @var float|int */
  38. private $maxDays;
  39. /** WatchedItemStore */
  40. private $watchStore;
  41. public function __construct( $page = 'Watchlist', $restriction = 'viewmywatchlist' ) {
  42. parent::__construct( $page, $restriction );
  43. $this->maxDays = $this->getConfig()->get( 'RCMaxAge' ) / ( 3600 * 24 );
  44. $this->watchStore = MediaWikiServices::getInstance()->getWatchedItemStore();
  45. }
  46. public function doesWrites() {
  47. return true;
  48. }
  49. /**
  50. * Main execution point
  51. *
  52. * @param string $subpage
  53. */
  54. function execute( $subpage ) {
  55. // Anons don't get a watchlist
  56. $this->requireLogin( 'watchlistanontext' );
  57. $output = $this->getOutput();
  58. $request = $this->getRequest();
  59. $this->addHelpLink( 'Help:Watching pages' );
  60. $output->addModuleStyles( [ 'mediawiki.special' ] );
  61. $output->addModules( [
  62. 'mediawiki.special.recentchanges',
  63. 'mediawiki.special.watchlist',
  64. ] );
  65. $mode = SpecialEditWatchlist::getMode( $request, $subpage );
  66. if ( $mode !== false ) {
  67. if ( $mode === SpecialEditWatchlist::EDIT_RAW ) {
  68. $title = SpecialPage::getTitleFor( 'EditWatchlist', 'raw' );
  69. } elseif ( $mode === SpecialEditWatchlist::EDIT_CLEAR ) {
  70. $title = SpecialPage::getTitleFor( 'EditWatchlist', 'clear' );
  71. } else {
  72. $title = SpecialPage::getTitleFor( 'EditWatchlist' );
  73. }
  74. $output->redirect( $title->getLocalURL() );
  75. return;
  76. }
  77. $this->checkPermissions();
  78. $user = $this->getUser();
  79. $opts = $this->getOptions();
  80. $config = $this->getConfig();
  81. if ( ( $config->get( 'EnotifWatchlist' ) || $config->get( 'ShowUpdatedMarker' ) )
  82. && $request->getVal( 'reset' )
  83. && $request->wasPosted()
  84. && $user->matchEditToken( $request->getVal( 'token' ) )
  85. ) {
  86. $user->clearAllNotifications();
  87. $output->redirect( $this->getPageTitle()->getFullURL( $opts->getChangedValues() ) );
  88. return;
  89. }
  90. parent::execute( $subpage );
  91. if ( $this->isStructuredFilterUiEnabled() ) {
  92. $output->addModuleStyles( [ 'mediawiki.rcfilters.highlightCircles.seenunseen.styles' ] );
  93. }
  94. }
  95. /**
  96. * @see ChangesListSpecialPage::checkStructuredFilterUiEnabled
  97. */
  98. public static function checkStructuredFilterUiEnabled( $user ) {
  99. if ( $user instanceof Config ) {
  100. wfDeprecated( __METHOD__ . ' with Config argument', '1.34' );
  101. $user = func_get_arg( 1 );
  102. }
  103. return !$user->getOption( 'wlenhancedfilters-disable' );
  104. }
  105. /**
  106. * Return an array of subpages that this special page will accept.
  107. *
  108. * @see also SpecialEditWatchlist::getSubpagesForPrefixSearch
  109. * @return string[] subpages
  110. */
  111. public function getSubpagesForPrefixSearch() {
  112. return [
  113. 'clear',
  114. 'edit',
  115. 'raw',
  116. ];
  117. }
  118. /**
  119. * @inheritDoc
  120. */
  121. protected function transformFilterDefinition( array $filterDefinition ) {
  122. if ( isset( $filterDefinition['showHideSuffix'] ) ) {
  123. $filterDefinition['showHide'] = 'wl' . $filterDefinition['showHideSuffix'];
  124. }
  125. return $filterDefinition;
  126. }
  127. /**
  128. * @inheritDoc
  129. * @suppress PhanUndeclaredMethod
  130. */
  131. protected function registerFilters() {
  132. parent::registerFilters();
  133. // legacy 'extended' filter
  134. $this->registerFilterGroup( new ChangesListBooleanFilterGroup( [
  135. 'name' => 'extended-group',
  136. 'filters' => [
  137. [
  138. 'name' => 'extended',
  139. 'isReplacedInStructuredUi' => true,
  140. 'activeValue' => false,
  141. 'default' => $this->getUser()->getBoolOption( 'extendwatchlist' ),
  142. 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables,
  143. &$fields, &$conds, &$query_options, &$join_conds ) {
  144. $nonRevisionTypes = [ RC_LOG ];
  145. Hooks::run( 'SpecialWatchlistGetNonRevisionTypes', [ &$nonRevisionTypes ] );
  146. if ( $nonRevisionTypes ) {
  147. $conds[] = $dbr->makeList(
  148. [
  149. 'rc_this_oldid=page_latest',
  150. 'rc_type' => $nonRevisionTypes,
  151. ],
  152. LIST_OR
  153. );
  154. }
  155. },
  156. ]
  157. ],
  158. ] ) );
  159. if ( $this->isStructuredFilterUiEnabled() ) {
  160. $this->getFilterGroup( 'lastRevision' )
  161. ->getFilter( 'hidepreviousrevisions' )
  162. ->setDefault( !$this->getUser()->getBoolOption( 'extendwatchlist' ) );
  163. }
  164. $this->registerFilterGroup( new ChangesListStringOptionsFilterGroup( [
  165. 'name' => 'watchlistactivity',
  166. 'title' => 'rcfilters-filtergroup-watchlistactivity',
  167. 'class' => ChangesListStringOptionsFilterGroup::class,
  168. 'priority' => 3,
  169. 'isFullCoverage' => true,
  170. 'filters' => [
  171. [
  172. 'name' => 'unseen',
  173. 'label' => 'rcfilters-filter-watchlistactivity-unseen-label',
  174. 'description' => 'rcfilters-filter-watchlistactivity-unseen-description',
  175. 'cssClassSuffix' => 'watchedunseen',
  176. 'isRowApplicableCallable' => function ( $ctx, RecentChange $rc ) {
  177. return !$this->isChangeEffectivelySeen( $rc );
  178. },
  179. ],
  180. [
  181. 'name' => 'seen',
  182. 'label' => 'rcfilters-filter-watchlistactivity-seen-label',
  183. 'description' => 'rcfilters-filter-watchlistactivity-seen-description',
  184. 'cssClassSuffix' => 'watchedseen',
  185. 'isRowApplicableCallable' => function ( $ctx, RecentChange $rc ) {
  186. return $this->isChangeEffectivelySeen( $rc );
  187. }
  188. ],
  189. ],
  190. 'default' => ChangesListStringOptionsFilterGroup::NONE,
  191. 'queryCallable' => function (
  192. $specialPageClassName,
  193. $context,
  194. IDatabase $dbr,
  195. &$tables,
  196. &$fields,
  197. &$conds,
  198. &$query_options,
  199. &$join_conds,
  200. $selectedValues
  201. ) {
  202. if ( $selectedValues === [ 'seen' ] ) {
  203. $conds[] = $dbr->makeList( [
  204. 'wl_notificationtimestamp IS NULL',
  205. 'rc_timestamp < wl_notificationtimestamp'
  206. ], LIST_OR );
  207. } elseif ( $selectedValues === [ 'unseen' ] ) {
  208. $conds[] = $dbr->makeList( [
  209. 'wl_notificationtimestamp IS NOT NULL',
  210. 'rc_timestamp >= wl_notificationtimestamp'
  211. ], LIST_AND );
  212. }
  213. }
  214. ] ) );
  215. $user = $this->getUser();
  216. $significance = $this->getFilterGroup( 'significance' );
  217. $hideMinor = $significance->getFilter( 'hideminor' );
  218. $hideMinor->setDefault( $user->getBoolOption( 'watchlisthideminor' ) );
  219. $automated = $this->getFilterGroup( 'automated' );
  220. $hideBots = $automated->getFilter( 'hidebots' );
  221. $hideBots->setDefault( $user->getBoolOption( 'watchlisthidebots' ) );
  222. $registration = $this->getFilterGroup( 'registration' );
  223. $hideAnons = $registration->getFilter( 'hideanons' );
  224. $hideAnons->setDefault( $user->getBoolOption( 'watchlisthideanons' ) );
  225. $hideLiu = $registration->getFilter( 'hideliu' );
  226. $hideLiu->setDefault( $user->getBoolOption( 'watchlisthideliu' ) );
  227. // Selecting both hideanons and hideliu on watchlist preferances
  228. // gives mutually exclusive filters, so those are ignored
  229. if ( $user->getBoolOption( 'watchlisthideanons' ) &&
  230. !$user->getBoolOption( 'watchlisthideliu' )
  231. ) {
  232. $this->getFilterGroup( 'userExpLevel' )
  233. ->setDefault( 'registered' );
  234. }
  235. if ( $user->getBoolOption( 'watchlisthideliu' ) &&
  236. !$user->getBoolOption( 'watchlisthideanons' )
  237. ) {
  238. $this->getFilterGroup( 'userExpLevel' )
  239. ->setDefault( 'unregistered' );
  240. }
  241. $reviewStatus = $this->getFilterGroup( 'reviewStatus' );
  242. if ( $reviewStatus !== null ) {
  243. // Conditional on feature being available and rights
  244. if ( $user->getBoolOption( 'watchlisthidepatrolled' ) ) {
  245. $reviewStatus->setDefault( 'unpatrolled' );
  246. $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' );
  247. $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' );
  248. $legacyHidePatrolled->setDefault( true );
  249. }
  250. }
  251. $authorship = $this->getFilterGroup( 'authorship' );
  252. $hideMyself = $authorship->getFilter( 'hidemyself' );
  253. $hideMyself->setDefault( $user->getBoolOption( 'watchlisthideown' ) );
  254. $changeType = $this->getFilterGroup( 'changeType' );
  255. $hideCategorization = $changeType->getFilter( 'hidecategorization' );
  256. if ( $hideCategorization !== null ) {
  257. // Conditional on feature being available
  258. $hideCategorization->setDefault( $user->getBoolOption( 'watchlisthidecategorization' ) );
  259. }
  260. }
  261. /**
  262. * Fetch values for a FormOptions object from the WebRequest associated with this instance.
  263. *
  264. * Maps old pre-1.23 request parameters Watchlist used to use (different from Recentchanges' ones)
  265. * to the current ones.
  266. *
  267. * @param FormOptions $opts
  268. * @return FormOptions
  269. */
  270. protected function fetchOptionsFromRequest( $opts ) {
  271. static $compatibilityMap = [
  272. 'hideMinor' => 'hideminor',
  273. 'hideBots' => 'hidebots',
  274. 'hideAnons' => 'hideanons',
  275. 'hideLiu' => 'hideliu',
  276. 'hidePatrolled' => 'hidepatrolled',
  277. 'hideOwn' => 'hidemyself',
  278. ];
  279. $params = $this->getRequest()->getValues();
  280. foreach ( $compatibilityMap as $from => $to ) {
  281. if ( isset( $params[$from] ) ) {
  282. $params[$to] = $params[$from];
  283. unset( $params[$from] );
  284. }
  285. }
  286. if ( $this->getRequest()->getVal( 'action' ) == 'submit' ) {
  287. $allBooleansFalse = [];
  288. // If the user submitted the form, start with a baseline of "all
  289. // booleans are false", then change the ones they checked. This
  290. // means we ignore the defaults.
  291. // This is how we handle the fact that HTML forms don't submit
  292. // unchecked boxes.
  293. foreach ( $this->getLegacyShowHideFilters() as $filter ) {
  294. $allBooleansFalse[ $filter->getName() ] = false;
  295. }
  296. $params = $params + $allBooleansFalse;
  297. }
  298. // Not the prettiest way to achieve this… FormOptions internally depends on data sanitization
  299. // methods defined on WebRequest and removing this dependency would cause some code duplication.
  300. $request = new DerivativeRequest( $this->getRequest(), $params );
  301. $opts->fetchValuesFromRequest( $request );
  302. return $opts;
  303. }
  304. /**
  305. * @inheritDoc
  306. */
  307. protected function doMainQuery( $tables, $fields, $conds, $query_options,
  308. $join_conds, FormOptions $opts
  309. ) {
  310. $dbr = $this->getDB();
  311. $user = $this->getUser();
  312. $rcQuery = RecentChange::getQueryInfo();
  313. $tables = array_merge( $tables, $rcQuery['tables'], [ 'watchlist' ] );
  314. $fields = array_merge( $rcQuery['fields'], $fields );
  315. $join_conds = array_merge(
  316. [
  317. 'watchlist' => [
  318. 'JOIN',
  319. [
  320. 'wl_user' => $user->getId(),
  321. 'wl_namespace=rc_namespace',
  322. 'wl_title=rc_title'
  323. ],
  324. ],
  325. ],
  326. $rcQuery['joins'],
  327. $join_conds
  328. );
  329. $tables[] = 'page';
  330. $fields[] = 'page_latest';
  331. $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
  332. $fields[] = 'wl_notificationtimestamp';
  333. // Log entries with DELETED_ACTION must not show up unless the user has
  334. // the necessary rights.
  335. $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
  336. if ( !$permissionManager->userHasRight( $user, 'deletedhistory' ) ) {
  337. $bitmask = LogPage::DELETED_ACTION;
  338. } elseif ( !$permissionManager->userHasAnyRight( $user, 'suppressrevision', 'viewsuppressed' ) ) {
  339. $bitmask = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED;
  340. } else {
  341. $bitmask = 0;
  342. }
  343. if ( $bitmask ) {
  344. $conds[] = $dbr->makeList( [
  345. 'rc_type != ' . RC_LOG,
  346. $dbr->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask",
  347. ], LIST_OR );
  348. }
  349. $tagFilter = $opts['tagfilter'] ? explode( '|', $opts['tagfilter'] ) : [];
  350. ChangeTags::modifyDisplayQuery(
  351. $tables,
  352. $fields,
  353. $conds,
  354. $join_conds,
  355. $query_options,
  356. $tagFilter
  357. );
  358. $this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds, $opts );
  359. if ( $this->areFiltersInConflict() ) {
  360. return false;
  361. }
  362. $orderByAndLimit = [
  363. 'ORDER BY' => 'rc_timestamp DESC',
  364. 'LIMIT' => $opts['limit']
  365. ];
  366. if ( in_array( 'DISTINCT', $query_options ) ) {
  367. // ChangeTags::modifyDisplayQuery() adds DISTINCT when filtering on multiple tags.
  368. // In order to prevent DISTINCT from causing query performance problems,
  369. // we have to GROUP BY the primary key. This in turn requires us to add
  370. // the primary key to the end of the ORDER BY, and the old ORDER BY to the
  371. // start of the GROUP BY
  372. $orderByAndLimit['ORDER BY'] = 'rc_timestamp DESC, rc_id DESC';
  373. $orderByAndLimit['GROUP BY'] = 'rc_timestamp, rc_id';
  374. }
  375. // array_merge() is used intentionally here so that hooks can, should
  376. // they so desire, override the ORDER BY / LIMIT condition(s)
  377. $query_options = array_merge( $orderByAndLimit, $query_options );
  378. return $dbr->select(
  379. $tables,
  380. $fields,
  381. $conds,
  382. __METHOD__,
  383. $query_options,
  384. $join_conds
  385. );
  386. }
  387. /**
  388. * Return a IDatabase object for reading
  389. *
  390. * @return IDatabase
  391. */
  392. protected function getDB() {
  393. return wfGetDB( DB_REPLICA, 'watchlist' );
  394. }
  395. /**
  396. * Output feed links.
  397. */
  398. public function outputFeedLinks() {
  399. $user = $this->getUser();
  400. $wlToken = $user->getTokenFromOption( 'watchlisttoken' );
  401. if ( $wlToken ) {
  402. $this->addFeedLinks( [
  403. 'action' => 'feedwatchlist',
  404. 'allrev' => 1,
  405. 'wlowner' => $user->getName(),
  406. 'wltoken' => $wlToken,
  407. ] );
  408. }
  409. }
  410. /**
  411. * Build and output the actual changes list.
  412. *
  413. * @param IResultWrapper $rows Database rows
  414. * @param FormOptions $opts
  415. */
  416. public function outputChangesList( $rows, $opts ) {
  417. $dbr = $this->getDB();
  418. $user = $this->getUser();
  419. $output = $this->getOutput();
  420. $services = MediaWikiServices::getInstance();
  421. # Show a message about replica DB lag, if applicable
  422. $lag = $dbr->getSessionLagStatus()['lag'];
  423. if ( $lag > 0 ) {
  424. $output->showLagWarning( $lag );
  425. }
  426. # If no rows to display, show message before try to render the list
  427. if ( $rows->numRows() == 0 ) {
  428. $output->wrapWikiMsg(
  429. "<div class='mw-changeslist-empty'>\n$1\n</div>", 'recentchanges-noresult'
  430. );
  431. return;
  432. }
  433. $dbr->dataSeek( $rows, 0 );
  434. $list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups );
  435. $list->setWatchlistDivs();
  436. $list->initChangesListRows( $rows );
  437. if ( $user->getOption( 'watchlistunwatchlinks' ) ) {
  438. $list->setChangeLinePrefixer( function ( RecentChange $rc, ChangesList $cl, $grouped ) {
  439. // Don't show unwatch link if the line is a grouped log entry using EnhancedChangesList,
  440. // since EnhancedChangesList groups log entries by performer rather than by target article
  441. if ( $rc->mAttribs['rc_type'] == RC_LOG && $cl instanceof EnhancedChangesList &&
  442. $grouped ) {
  443. return '';
  444. } else {
  445. return $this->getLinkRenderer()
  446. ->makeKnownLink( $rc->getTitle(),
  447. $this->msg( 'watchlist-unwatch' )->text(), [
  448. 'class' => 'mw-unwatch-link',
  449. 'title' => $this->msg( 'tooltip-ca-unwatch' )->text()
  450. ], [ 'action' => 'unwatch' ] ) . "\u{00A0}";
  451. }
  452. } );
  453. }
  454. $dbr->dataSeek( $rows, 0 );
  455. if ( $this->getConfig()->get( 'RCShowWatchingUsers' )
  456. && $user->getOption( 'shownumberswatching' )
  457. ) {
  458. $watchedItemStore = $services->getWatchedItemStore();
  459. }
  460. $s = $list->beginRecentChangesList();
  461. if ( $this->isStructuredFilterUiEnabled() ) {
  462. $s .= $this->makeLegend();
  463. }
  464. $userShowHiddenCats = $this->getUser()->getBoolOption( 'showhiddencats' );
  465. $counter = 1;
  466. foreach ( $rows as $obj ) {
  467. # Make RC entry
  468. $rc = RecentChange::newFromRow( $obj );
  469. # Skip CatWatch entries for hidden cats based on user preference
  470. if (
  471. $rc->getAttribute( 'rc_type' ) == RC_CATEGORIZE &&
  472. !$userShowHiddenCats &&
  473. $rc->getParam( 'hidden-cat' )
  474. ) {
  475. continue;
  476. }
  477. $rc->counter = $counter++;
  478. if ( $this->getConfig()->get( 'ShowUpdatedMarker' ) ) {
  479. $unseen = !$this->isChangeEffectivelySeen( $rc );
  480. } else {
  481. $unseen = false;
  482. }
  483. if ( isset( $watchedItemStore ) ) {
  484. $rcTitleValue = new TitleValue( (int)$obj->rc_namespace, $obj->rc_title );
  485. $rc->numberofWatchingusers = $watchedItemStore->countWatchers( $rcTitleValue );
  486. } else {
  487. $rc->numberofWatchingusers = 0;
  488. }
  489. // XXX: this treats pages with no unseen changes as "not on the watchlist" since
  490. // everything is on the watchlist and it is an easy way to make pages with unseen
  491. // changes appear bold. @TODO: clean this up.
  492. $changeLine = $list->recentChangesLine( $rc, $unseen, $counter );
  493. if ( $changeLine !== false ) {
  494. $s .= $changeLine;
  495. }
  496. }
  497. $s .= $list->endRecentChangesList();
  498. $output->addHTML( $s );
  499. }
  500. /**
  501. * Set the text to be displayed above the changes
  502. *
  503. * @param FormOptions $opts
  504. * @param int $numRows Number of rows in the result to show after this header
  505. */
  506. public function doHeader( $opts, $numRows ) {
  507. $user = $this->getUser();
  508. $out = $this->getOutput();
  509. $out->addSubtitle(
  510. $this->msg( 'watchlistfor2', $user->getName() )
  511. ->rawParams( SpecialEditWatchlist::buildTools(
  512. $this->getLanguage(),
  513. $this->getLinkRenderer()
  514. ) )
  515. );
  516. $this->setTopText( $opts );
  517. $form = '';
  518. $form .= Xml::openElement( 'form', [
  519. 'method' => 'get',
  520. 'action' => wfScript(),
  521. 'id' => 'mw-watchlist-form'
  522. ] );
  523. $form .= Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() );
  524. $form .= Xml::openElement(
  525. 'fieldset',
  526. [ 'id' => 'mw-watchlist-options', 'class' => 'cloptions' ]
  527. );
  528. $form .= Xml::element(
  529. 'legend', null, $this->msg( 'watchlist-options' )->text()
  530. );
  531. if ( !$this->isStructuredFilterUiEnabled() ) {
  532. $form .= $this->makeLegend();
  533. }
  534. $lang = $this->getLanguage();
  535. $timestamp = wfTimestampNow();
  536. $now = $lang->userTimeAndDate( $timestamp, $user );
  537. $wlInfo = Html::rawElement(
  538. 'span',
  539. [
  540. 'class' => 'wlinfo',
  541. 'data-params' => json_encode( [ 'from' => $timestamp, 'fromFormatted' => $now ] ),
  542. ],
  543. $this->msg( 'wlnote' )->numParams( $numRows, round( $opts['days'] * 24 ) )->params(
  544. $lang->userDate( $timestamp, $user ), $lang->userTime( $timestamp, $user )
  545. )->parse()
  546. ) . "<br />\n";
  547. $nondefaults = $opts->getChangedValues();
  548. $cutofflinks = Html::rawElement(
  549. 'span',
  550. [ 'class' => 'cldays cloption' ],
  551. $this->msg( 'wlshowtime' ) . ' ' . $this->cutoffselector( $opts )
  552. );
  553. # Spit out some control panel links
  554. $links = [];
  555. $namesOfDisplayedFilters = [];
  556. foreach ( $this->getLegacyShowHideFilters() as $filterName => $filter ) {
  557. $namesOfDisplayedFilters[] = $filterName;
  558. $links[] = $this->showHideCheck(
  559. $nondefaults,
  560. $filter->getShowHide(),
  561. $filterName,
  562. $opts[ $filterName ],
  563. $filter->isFeatureAvailableOnStructuredUi( $this )
  564. );
  565. }
  566. $hiddenFields = $nondefaults;
  567. $hiddenFields['action'] = 'submit';
  568. unset( $hiddenFields['namespace'] );
  569. unset( $hiddenFields['invert'] );
  570. unset( $hiddenFields['associated'] );
  571. unset( $hiddenFields['days'] );
  572. foreach ( $namesOfDisplayedFilters as $filterName ) {
  573. unset( $hiddenFields[$filterName] );
  574. }
  575. # Namespace filter and put the whole form together.
  576. $form .= $wlInfo;
  577. $form .= $cutofflinks;
  578. $form .= Html::rawElement(
  579. 'span',
  580. [ 'class' => 'clshowhide' ],
  581. $this->msg( 'watchlist-hide' ) .
  582. $this->msg( 'colon-separator' )->escaped() .
  583. implode( ' ', $links )
  584. );
  585. $form .= "\n<br />\n";
  586. $namespaceForm = Html::namespaceSelector(
  587. [
  588. 'selected' => $opts['namespace'],
  589. 'all' => '',
  590. 'label' => $this->msg( 'namespace' )->text(),
  591. 'in-user-lang' => true,
  592. ], [
  593. 'name' => 'namespace',
  594. 'id' => 'namespace',
  595. 'class' => 'namespaceselector',
  596. ]
  597. ) . "\n";
  598. $hidden = $opts['namespace'] === '' ? ' mw-input-hidden' : '';
  599. $namespaceForm .= '<span class="mw-input-with-label' . $hidden . '">' . Xml::checkLabel(
  600. $this->msg( 'invert' )->text(),
  601. 'invert',
  602. 'nsinvert',
  603. $opts['invert'],
  604. [ 'title' => $this->msg( 'tooltip-invert' )->text() ]
  605. ) . "</span>\n";
  606. $namespaceForm .= '<span class="mw-input-with-label' . $hidden . '">' . Xml::checkLabel(
  607. $this->msg( 'namespace_association' )->text(),
  608. 'associated',
  609. 'nsassociated',
  610. $opts['associated'],
  611. [ 'title' => $this->msg( 'tooltip-namespace_association' )->text() ]
  612. ) . "</span>\n";
  613. $form .= Html::rawElement(
  614. 'span',
  615. [ 'class' => 'namespaceForm cloption' ],
  616. $namespaceForm
  617. );
  618. $form .= Xml::submitButton(
  619. $this->msg( 'watchlist-submit' )->text(),
  620. [ 'class' => 'cloption-submit' ]
  621. ) . "\n";
  622. foreach ( $hiddenFields as $key => $value ) {
  623. $form .= Html::hidden( $key, $value ) . "\n";
  624. }
  625. $form .= Xml::closeElement( 'fieldset' ) . "\n";
  626. $form .= Xml::closeElement( 'form' ) . "\n";
  627. // Insert a placeholder for RCFilters
  628. if ( $this->isStructuredFilterUiEnabled() ) {
  629. $rcfilterContainer = Html::element(
  630. 'div',
  631. // TODO: Remove deprecated rcfilters-container class
  632. [ 'class' => 'rcfilters-container mw-rcfilters-container' ]
  633. );
  634. $loadingContainer = Html::rawElement(
  635. 'div',
  636. [ 'class' => 'mw-rcfilters-spinner' ],
  637. Html::element(
  638. 'div',
  639. [ 'class' => 'mw-rcfilters-spinner-bounce' ]
  640. )
  641. );
  642. // Wrap both with rcfilters-head
  643. $this->getOutput()->addHTML(
  644. Html::rawElement(
  645. 'div',
  646. // TODO: Remove deprecated rcfilters-head class
  647. [ 'class' => 'rcfilters-head mw-rcfilters-head' ],
  648. $rcfilterContainer . $form
  649. )
  650. );
  651. // Add spinner
  652. $this->getOutput()->addHTML( $loadingContainer );
  653. } else {
  654. $this->getOutput()->addHTML( $form );
  655. }
  656. $this->setBottomText( $opts );
  657. }
  658. function cutoffselector( $options ) {
  659. $selected = (float)$options['days'];
  660. if ( $selected <= 0 ) {
  661. $selected = $this->maxDays;
  662. }
  663. $selectedHours = round( $selected * 24 );
  664. $hours = array_unique( array_filter( [
  665. 1,
  666. 2,
  667. 6,
  668. 12,
  669. 24,
  670. 72,
  671. 168,
  672. 24 * (float)$this->getUser()->getOption( 'watchlistdays', 0 ),
  673. 24 * $this->maxDays,
  674. $selectedHours
  675. ] ) );
  676. asort( $hours );
  677. $select = new XmlSelect( 'days', 'days', (float)( $selectedHours / 24 ) );
  678. foreach ( $hours as $value ) {
  679. if ( $value < 24 ) {
  680. $name = $this->msg( 'hours' )->numParams( $value )->text();
  681. } else {
  682. $name = $this->msg( 'days' )->numParams( $value / 24 )->text();
  683. }
  684. $select->addOption( $name, (float)( $value / 24 ) );
  685. }
  686. return $select->getHTML() . "\n<br />\n";
  687. }
  688. function setTopText( FormOptions $opts ) {
  689. $nondefaults = $opts->getChangedValues();
  690. $form = '';
  691. $user = $this->getUser();
  692. $numItems = $this->countItems();
  693. $showUpdatedMarker = $this->getConfig()->get( 'ShowUpdatedMarker' );
  694. // Show watchlist header
  695. $watchlistHeader = '';
  696. if ( $numItems == 0 ) {
  697. $watchlistHeader = $this->msg( 'nowatchlist' )->parse();
  698. } else {
  699. $watchlistHeader .= $this->msg( 'watchlist-details' )->numParams( $numItems )->parse() . "\n";
  700. if ( $this->getConfig()->get( 'EnotifWatchlist' )
  701. && $user->getOption( 'enotifwatchlistpages' )
  702. ) {
  703. $watchlistHeader .= $this->msg( 'wlheader-enotif' )->parse() . "\n";
  704. }
  705. if ( $showUpdatedMarker ) {
  706. $watchlistHeader .= $this->msg(
  707. $this->isStructuredFilterUiEnabled() ?
  708. 'rcfilters-watchlist-showupdated' :
  709. 'wlheader-showupdated'
  710. )->parse() . "\n";
  711. }
  712. }
  713. $form .= Html::rawElement(
  714. 'div',
  715. [ 'class' => 'watchlistDetails' ],
  716. $watchlistHeader
  717. );
  718. if ( $numItems > 0 && $showUpdatedMarker ) {
  719. $form .= Xml::openElement( 'form', [ 'method' => 'post',
  720. 'action' => $this->getPageTitle()->getLocalURL(),
  721. 'id' => 'mw-watchlist-resetbutton' ] ) . "\n" .
  722. Xml::submitButton( $this->msg( 'enotif_reset' )->text(),
  723. [ 'name' => 'mw-watchlist-reset-submit' ] ) . "\n" .
  724. Html::hidden( 'token', $user->getEditToken() ) . "\n" .
  725. Html::hidden( 'reset', 'all' ) . "\n";
  726. foreach ( $nondefaults as $key => $value ) {
  727. $form .= Html::hidden( $key, $value ) . "\n";
  728. }
  729. $form .= Xml::closeElement( 'form' ) . "\n";
  730. }
  731. $this->getOutput()->addHTML( $form );
  732. }
  733. protected function showHideCheck( $options, $message, $name, $value, $inStructuredUi ) {
  734. $options[$name] = 1 - (int)$value;
  735. $attribs = [ 'class' => 'mw-input-with-label clshowhideoption cloption' ];
  736. if ( $inStructuredUi ) {
  737. $attribs[ 'data-feature-in-structured-ui' ] = true;
  738. }
  739. return Html::rawElement(
  740. 'span',
  741. $attribs,
  742. // not using Html::checkLabel because that would escape the contents
  743. Html::check( $name, (int)$value, [ 'id' => $name ] ) . Html::rawElement(
  744. 'label',
  745. $attribs + [ 'for' => $name ],
  746. // <nowiki/> at beginning to avoid messages with "$1 ..." being parsed as pre tags
  747. $this->msg( $message, '<nowiki/>' )->parse()
  748. )
  749. );
  750. }
  751. /**
  752. * Count the number of paired items on a user's watchlist.
  753. * The assumption made here is that when a subject page is watched a talk page is also watched.
  754. * Hence the number of individual items is halved.
  755. *
  756. * @return int
  757. */
  758. protected function countItems() {
  759. $store = MediaWikiServices::getInstance()->getWatchedItemStore();
  760. $count = $store->countWatchedItems( $this->getUser() );
  761. return floor( $count / 2 );
  762. }
  763. /**
  764. * @param RecentChange $rc
  765. * @return bool User viewed the revision or a newer one
  766. */
  767. protected function isChangeEffectivelySeen( RecentChange $rc ) {
  768. $firstUnseen = $this->getLatestNotificationTimestamp( $rc );
  769. return ( $firstUnseen === null || $firstUnseen > $rc->getAttribute( 'rc_timestamp' ) );
  770. }
  771. /**
  772. * @param RecentChange $rc
  773. * @return string|null TS_MW timestamp of first unseen revision or null if there isn't one
  774. */
  775. private function getLatestNotificationTimestamp( RecentChange $rc ) {
  776. return $this->watchStore->getLatestNotificationTimestamp(
  777. $rc->getAttribute( 'wl_notificationtimestamp' ),
  778. $this->getUser(),
  779. $rc->getTitle()
  780. );
  781. }
  782. }