MediaWiki master
WatchlistManager.php
Go to the documentation of this file.
1<?php
2
9namespace MediaWiki\Watchlist;
10
27use StatusValue;
30use Wikimedia\Timestamp\TimestampFormat as TS;
31
38
42 public const OPTION_ENOTIF = 'isEnotifEnabled';
43
45 private $isEnotifEnabled;
46
48 private $hookRunner;
49
51 private $readOnlyMode;
52
54 private $revisionLookup;
55
57 private $talkPageNotificationManager;
58
60 private $watchedItemStore;
61
63 private $userFactory;
64
66 private $nsInfo;
67
69 private $wikiPageFactory;
70
72 private array $watchlistLabels = [];
73
90 private $notificationTimestampCache = [];
91
104 public function __construct(
105 array $options,
106 HookContainer $hookContainer,
107 ReadOnlyMode $readOnlyMode,
108 RevisionLookup $revisionLookup,
109 TalkPageNotificationManager $talkPageNotificationManager,
110 WatchedItemStoreInterface $watchedItemStore,
111 private readonly WatchlistLabelStore $watchlistLabelStore,
112 UserFactory $userFactory,
113 NamespaceInfo $nsInfo,
114 WikiPageFactory $wikiPageFactory
115 ) {
116 $this->isEnotifEnabled = $options[ self::OPTION_ENOTIF ];
117 $this->hookRunner = new HookRunner( $hookContainer );
118 $this->readOnlyMode = $readOnlyMode;
119 $this->revisionLookup = $revisionLookup;
120 $this->talkPageNotificationManager = $talkPageNotificationManager;
121 $this->watchedItemStore = $watchedItemStore;
122 $this->userFactory = $userFactory;
123 $this->nsInfo = $nsInfo;
124 $this->wikiPageFactory = $wikiPageFactory;
125 }
126
136 public function clearAllUserNotifications( Authority $performer ) {
137 if ( $this->readOnlyMode->isReadOnly() ) {
138 // Cannot change anything in read only
139 return;
140 }
141
142 $user = $performer->getUser();
143
144 // NOTE: Has to be before `editmywatchlist` user right check, to ensure
145 // anonymous / temporary users have their talk page notifications cleared (T345031).
146 if ( !$this->isEnotifEnabled ) {
147 $this->talkPageNotificationManager->removeUserHasNewMessages( $user );
148 return;
149 }
150
151 if ( !$performer->isAllowed( 'editmywatchlist' ) ) {
152 // User isn't allowed to edit the watchlist
153 return;
154 }
155
156 if ( !$user->isRegistered() ) {
157 return;
158 }
159
160 $this->watchedItemStore->resetAllNotificationTimestampsForUser( $user );
161
162 // We also need to clear here the "you have new message" notification for the own
163 // user_talk page; it's cleared one page view later in WikiPage::doViewUpdates().
164 }
165
180 Authority $performer,
181 PageReference $title,
182 $oldRev = null,
183 $oldRevDeprecated = null
184 ) {
185 if ( func_num_args() > 3 ) {
186 wfDeprecatedMsg( 'Passing $oldid to ' . __METHOD__ . ' is deprecated since 1.46.' );
187 $oldid = $oldRev ?? 0;
188 $oldRev = $oldRevDeprecated;
189 } else {
190 $oldid = $oldRev?->getId() ?? 0;
191 }
192
193 if ( $this->readOnlyMode->isReadOnly() ) {
194 // Cannot change anything in read only
195 return;
196 }
197
198 $userIdentity = $performer->getUser();
199 $userTalkPage = (
200 $title->getNamespace() === NS_USER_TALK &&
201 $title->getDBkey() === strtr( $userIdentity->getName(), ' ', '_' )
202 );
203
204 if ( $userTalkPage ) {
205 if ( !$oldid ) {
206 $oldRev = null;
207 } elseif ( !$oldRev ) {
208 $oldRev = $this->revisionLookup->getRevisionById( $oldid );
209 }
210 // NOTE: Has to be called before isAllowed() check, to ensure users with no watchlist
211 // access (by default, temporary and anonymous users) can clear their talk page
212 // notification (T345031).
213 $this->talkPageNotificationManager->clearForPageView( $userIdentity, $oldRev );
214 }
215
216 if ( !$this->isEnotifEnabled ) {
217 return;
218 }
219
220 if ( !$userIdentity->isRegistered() ) {
221 // Nothing else to do
222 return;
223 }
224
225 // NOTE: Has to be checked after the TalkPageNotificationManager::clearForPageView call, to
226 // ensure users with no watchlist access (by default, temporary and anonymous users) can
227 // clear their talk page notification (T345031).
228 if ( !$performer->isAllowed( 'editmywatchlist' ) ) {
229 // User isn't allowed to edit the watchlist
230 return;
231 }
232
233 // Only update the timestamp if the page is being watched.
234 // The query to find out if it is watched is cached both in memcached and per-invocation,
235 // and when it does have to be executed, it can be on a replica DB
236 // If this is the user's newtalk page, we always update the timestamp
237 $force = $userTalkPage ? 'force' : '';
238 $this->watchedItemStore->resetNotificationTimestamp( $userIdentity, $title, $force, $oldid );
239 }
240
249 if ( !$user->isRegistered() ) {
250 return false;
251 }
252
253 $cacheKey = 'u' . $user->getId() . '-' .
254 $title->getNamespace() . ':' . $title->getDBkey();
255
256 // avoid isset here, as it'll return false for null entries
257 if ( array_key_exists( $cacheKey, $this->notificationTimestampCache ) ) {
258 return $this->notificationTimestampCache[ $cacheKey ];
259 }
260
261 $watchedItem = $this->watchedItemStore->getWatchedItem( $user, $title );
262 if ( $watchedItem ) {
263 $timestamp = $watchedItem->getNotificationTimestamp();
264 } else {
265 $timestamp = false;
266 }
267
268 $this->notificationTimestampCache[ $cacheKey ] = $timestamp;
269 return $timestamp;
270 }
271
277 public function isWatchable( PageReference $target ): bool {
278 if ( !$this->nsInfo->isWatchable( $target->getNamespace() ) ) {
279 return false;
280 }
281
282 if ( $target instanceof PageIdentity && !$target->canExist() ) {
283 // Catch "improper" Title instances
284 return false;
285 }
286
287 return true;
288 }
289
297 public function isWatchedIgnoringRights( UserIdentity $userIdentity, PageReference $target ): bool {
298 if ( $this->isWatchable( $target ) ) {
299 return $this->watchedItemStore->isWatched( $userIdentity, $target );
300 }
301 return false;
302 }
303
312 public function isWatched( Authority $performer, PageReference $target ): bool {
313 if ( $performer->isAllowed( 'viewmywatchlist' ) ) {
314 return $this->isWatchedIgnoringRights( $performer->getUser(), $target );
315 }
316 return false;
317 }
318
326 public function isTempWatchedIgnoringRights( UserIdentity $userIdentity, PageReference $target ): bool {
327 if ( $this->isWatchable( $target ) ) {
328 return $this->watchedItemStore->isTempWatched( $userIdentity, $target );
329 }
330 return false;
331 }
332
341 public function isTempWatched( Authority $performer, PageReference $target ): bool {
342 if ( $performer->isAllowed( 'viewmywatchlist' ) ) {
343 return $this->isTempWatchedIgnoringRights( $performer->getUser(), $target );
344 }
345 return false;
346 }
347
357 public function addWatchIgnoringRights(
358 UserIdentity $userIdentity,
359 PageReference $target,
360 ?string $expiry = null
361 ): StatusValue {
362 if ( !$this->isWatchable( $target ) ) {
363 return StatusValue::newFatal( 'watchlistnotwatchable' );
364 }
365
366 $wikiPage = $this->wikiPageFactory->newFromTitle( $target );
367 $title = $wikiPage->getTitle();
368
369 $status = Status::newFatal( 'hookaborted' );
370 // TODO: broaden the interface on these hooks to accept PageReference
371 if ( $this->hookRunner->onWatchArticle( $userIdentity, $wikiPage, $status, $expiry ) ) {
372 $status = StatusValue::newGood();
373 $this->watchedItemStore->addWatch( $userIdentity, $this->getSubjectPage( $title ), $expiry );
374 if ( $this->nsInfo->canHaveTalkPage( $title ) ) {
375 $this->watchedItemStore->addWatch( $userIdentity, $this->getTalkPage( $title ), $expiry );
376 }
377 $this->hookRunner->onWatchArticleComplete( $userIdentity, $wikiPage );
378 }
379
380 // eventually user_touched should be factored out of User and this should be replaced
381 $user = $this->userFactory->newFromUserIdentity( $userIdentity );
382 $user->invalidateCache();
383
384 return $status;
385 }
386
397 public function addWatch(
398 Authority $performer,
399 PageReference $target,
400 ?string $expiry = null
401 ): StatusValue {
402 $status = PermissionStatus::newEmpty();
403 if ( !$performer->isAllowed( 'editmywatchlist', $status ) ) {
404 return $status;
405 }
406
407 return $this->addWatchIgnoringRights( $performer->getUser(), $target, $expiry );
408 }
409
418 UserIdentity $userIdentity,
419 PageReference $target
420 ): StatusValue {
421 if ( !$this->isWatchable( $target ) ) {
422 return StatusValue::newFatal( 'watchlistnotwatchable' );
423 }
424
425 $wikiPage = $this->wikiPageFactory->newFromTitle( $target );
426 $title = $wikiPage->getTitle();
427
428 $status = Status::newFatal( 'hookaborted' );
429 // TODO broaden the interface on these hooks from WikiPage to PageReference
430 if ( $this->hookRunner->onUnwatchArticle( $userIdentity, $wikiPage, $status ) ) {
431 $status = StatusValue::newGood();
432 $this->watchedItemStore->removeWatch( $userIdentity, $this->getSubjectPage( $title ) );
433 if ( $this->nsInfo->canHaveTalkPage( $title ) ) {
434 $this->watchedItemStore->removeWatch( $userIdentity, $this->getTalkPage( $title ) );
435 }
436 $this->hookRunner->onUnwatchArticleComplete( $userIdentity, $wikiPage );
437 }
438
439 // eventually user_touched should be factored out of User and this should be replaced
440 $user = $this->userFactory->newFromUserIdentity( $userIdentity );
441 $user->invalidateCache();
442
443 return $status;
444 }
445
452 private function getSubjectPage( PageReference $title ): PageReference {
453 if ( $this->nsInfo->isSubject( $title->getNamespace() ) ) {
454 return $title;
455 }
456 return PageReferenceValue::localReference(
457 $this->nsInfo->getSubject( $title->getNamespace() ),
458 $title->getDBkey()
459 );
460 }
461
468 private function getTalkPage( PageReference $title ): PageReference {
469 if ( $this->nsInfo->isTalk( $title->getNamespace() ) ) {
470 return $title;
471 }
472 return PageReferenceValue::localReference(
473 $this->nsInfo->getTalk( $title->getNamespace() ),
474 $title->getDBkey()
475 );
476 }
477
486 public function removeWatch(
487 Authority $performer,
488 PageReference $target
489 ): StatusValue {
490 $status = PermissionStatus::newEmpty();
491 if ( !$performer->isAllowed( 'editmywatchlist', $status ) ) {
492 return $status;
493 }
494
495 return $this->removeWatchIgnoringRights( $performer->getUser(), $target );
496 }
497
512 public function setWatch(
513 bool $watch,
514 Authority $performer,
515 PageReference $target,
516 ?string $expiry = null,
517 ?array $labels = null
518 ): StatusValue {
519 // User must be registered, and (T371091) not a temp user
520 if ( !$performer->getUser()->isRegistered() || $performer->isTemp() ) {
521 return StatusValue::newGood();
522 }
523
524 // User must be either changing the watch state or at least the expiry.
525
526 // Only call addWatchIgnoringRights() or removeWatch() if there's been a change in the watched status.
527 $oldWatchedItem = $this->watchedItemStore->getWatchedItem( $performer->getUser(), $target );
528 $changingWatchStatus = (bool)$oldWatchedItem !== $watch;
529 if ( $oldWatchedItem && $expiry !== null ) {
530 // If there's an old watched item, a non-null change to the expiry requires an UPDATE.
531 $oldWatchPeriod = $oldWatchedItem->getExpiry() ?? 'infinity';
532 $changingWatchStatus = $changingWatchStatus ||
533 $oldWatchPeriod !== ExpiryDef::normalizeExpiry( $expiry, TS::MW );
534 }
535
536 $out = StatusValue::newGood();
537 if ( $changingWatchStatus ) {
538 // If the user doesn't have 'editmywatchlist', we still want to
539 // allow them to add but not remove items via edits and such.
540 if ( $watch ) {
541 $out = $this->addWatchIgnoringRights( $performer->getUser(), $target, $expiry );
542 } else {
543 $out = $this->removeWatch( $performer, $target );
544 }
545 }
546
547 if ( $watch && $labels !== null ) {
548 // Add labels to both the subject and talk pages.
549 $assocPage = $this->nsInfo->getAssociatedPage( TitleValue::newFromPage( $target ) );
550 $targets = [
551 $target,
552 PageReferenceValue::localReference( $assocPage->getNamespace(), $assocPage->getDBkey() ),
553 ];
554 $this->addOrRemoveLabels( $performer->getUser(), $targets, $labels );
555 }
556
557 return $out;
558 }
559
569 private function addOrRemoveLabels( UserIdentity $user, array $targets, array $newLabelIds ): void {
570 foreach ( $targets as $target ) {
571 $currentLabelIds = [];
572 $watchedItem = $this->watchedItemStore->getWatchedItem( $user, $target );
573 if ( $watchedItem ) {
574 foreach ( $watchedItem->getLabels() as $label ) {
575 $labelId = $label->getId();
576 if ( $labelId !== null ) {
577 $currentLabelIds[] = $labelId;
578 }
579 }
580 }
581 $userLabelIds = array_keys( $this->loadWatchlistLabelsForUser( $user ) );
582 $submittedLabelIds = array_values( array_intersect( $newLabelIds, $userLabelIds ) );
583 $toAdd = array_values( array_diff( $submittedLabelIds, $currentLabelIds ) );
584 $toRemove = array_values( array_diff( $currentLabelIds, $submittedLabelIds ) );
585 if ( $toAdd ) {
586 $this->watchedItemStore->addLabels( $user, $targets, $toAdd );
587 }
588 if ( $toRemove ) {
589 $this->watchedItemStore->removeLabels( $user, $targets, $toRemove );
590 }
591 }
592 }
593
600 private function loadWatchlistLabelsForUser( UserIdentity $user ): array {
601 if ( !isset( $this->watchlistLabels[$user->getId()] ) ) {
602 $this->watchlistLabels[$user->getId()] = $this->watchlistLabelStore->loadAllForUser( $user );
603 }
604
605 return $this->watchlistLabels[$user->getId()];
606 }
607}
const NS_USER_TALK
Definition Defines.php:54
wfDeprecatedMsg( $msg, $version=false, $component=false, $callerOffset=2)
Log a deprecation warning with arbitrary message text.
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:69
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Immutable value object representing a page reference.
Service for creating WikiPage objects.
A StatusValue for permission errors.
Page revision base class.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:44
This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of them ba...
Represents the target of a wiki link.
Create User objects.
Service class for storage of watchlist labels.
addWatch(Authority $performer, PageReference $target, ?string $expiry=null)
Watch a page if the user has permission to edit their watchlist.
clearTitleUserNotifications(Authority $performer, PageReference $title, $oldRev=null, $oldRevDeprecated=null)
Clear the user's notification timestamp for the given title.
setWatch(bool $watch, Authority $performer, PageReference $target, ?string $expiry=null, ?array $labels=null)
Watch or unwatch a page, calling watch/unwatch hooks as appropriate.
isWatched(Authority $performer, PageReference $target)
Check if the page is watched by the user and the user has permission to view their watchlist.
__construct(array $options, HookContainer $hookContainer, ReadOnlyMode $readOnlyMode, RevisionLookup $revisionLookup, TalkPageNotificationManager $talkPageNotificationManager, WatchedItemStoreInterface $watchedItemStore, private readonly WatchlistLabelStore $watchlistLabelStore, UserFactory $userFactory, NamespaceInfo $nsInfo, WikiPageFactory $wikiPageFactory)
addWatchIgnoringRights(UserIdentity $userIdentity, PageReference $target, ?string $expiry=null)
Watch a page.
getTitleNotificationTimestamp(UserIdentity $user, PageReference $title)
Get the timestamp when this page was updated since the user last saw it.
removeWatchIgnoringRights(UserIdentity $userIdentity, PageReference $target)
Stop watching a page.
clearAllUserNotifications(Authority $performer)
Resets all of the given user's page-change notification timestamps.
isTempWatched(Authority $performer, PageReference $target)
Check if the page is temporarily watched by the user and the user has permission to view their watchl...
isWatchedIgnoringRights(UserIdentity $userIdentity, PageReference $target)
Check if the page is watched by the user.
isTempWatchedIgnoringRights(UserIdentity $userIdentity, PageReference $target)
Check if the article is temporarily watched by the user.
removeWatch(Authority $performer, PageReference $target)
Stop watching a page if the user has permission to edit their watchlist.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Type definition for expiry timestamps.
Definition ExpiryDef.php:18
Determine whether a site is currently in read-only mode.
Interface for objects (potentially) representing an editable wiki page.
canExist()
Checks whether this PageIdentity represents a "proper" page, meaning that it could exist as an editab...
Interface for objects (potentially) representing a page that can be viewable and linked to on a wiki.
getNamespace()
Returns the page's namespace number.
getDBkey()
Get the page title in DB key form.
This interface represents the authority associated with the current execution context,...
Definition Authority.php:23
isAllowed(string $permission, ?PermissionStatus $status=null)
Checks whether this authority has the given permission in general.
getUser()
Returns the performer of the actions associated with this authority.
Service for looking up page revisions.
Interface for objects representing user identity.
isRegistered()
This must be equivalent to getId() != 0 and is provided for code readability.
getId( $wikiId=self::LOCAL)