MediaWiki master
WatchlistManager.php
Go to the documentation of this file.
1<?php
2
9namespace MediaWiki\Watchlist;
10
26use StatusValue;
29use Wikimedia\Timestamp\TimestampFormat as TS;
30
37
41 public const OPTION_ENOTIF = 'isEnotifEnabled';
42
44 private $isEnotifEnabled;
45
47 private $hookRunner;
48
50 private $readOnlyMode;
51
53 private $revisionLookup;
54
56 private $talkPageNotificationManager;
57
59 private $watchedItemStore;
60
62 private $userFactory;
63
65 private $nsInfo;
66
68 private $wikiPageFactory;
69
86 private $notificationTimestampCache = [];
87
99 public function __construct(
100 array $options,
101 HookContainer $hookContainer,
102 ReadOnlyMode $readOnlyMode,
103 RevisionLookup $revisionLookup,
104 TalkPageNotificationManager $talkPageNotificationManager,
105 WatchedItemStoreInterface $watchedItemStore,
106 UserFactory $userFactory,
107 NamespaceInfo $nsInfo,
108 WikiPageFactory $wikiPageFactory
109 ) {
110 $this->isEnotifEnabled = $options[ self::OPTION_ENOTIF ];
111 $this->hookRunner = new HookRunner( $hookContainer );
112 $this->readOnlyMode = $readOnlyMode;
113 $this->revisionLookup = $revisionLookup;
114 $this->talkPageNotificationManager = $talkPageNotificationManager;
115 $this->watchedItemStore = $watchedItemStore;
116 $this->userFactory = $userFactory;
117 $this->nsInfo = $nsInfo;
118 $this->wikiPageFactory = $wikiPageFactory;
119 }
120
130 public function clearAllUserNotifications( Authority $performer ) {
131 if ( $this->readOnlyMode->isReadOnly() ) {
132 // Cannot change anything in read only
133 return;
134 }
135
136 $user = $performer->getUser();
137
138 // NOTE: Has to be before `editmywatchlist` user right check, to ensure
139 // anonymous / temporary users have their talk page notifications cleared (T345031).
140 if ( !$this->isEnotifEnabled ) {
141 $this->talkPageNotificationManager->removeUserHasNewMessages( $user );
142 return;
143 }
144
145 if ( !$performer->isAllowed( 'editmywatchlist' ) ) {
146 // User isn't allowed to edit the watchlist
147 return;
148 }
149
150 if ( !$user->isRegistered() ) {
151 return;
152 }
153
154 $this->watchedItemStore->resetAllNotificationTimestampsForUser( $user );
155
156 // We also need to clear here the "you have new message" notification for the own
157 // user_talk page; it's cleared one page view later in WikiPage::doViewUpdates().
158 }
159
174 Authority $performer,
175 PageReference $title,
176 int $oldid = 0,
177 ?RevisionRecord $oldRev = null
178 ) {
179 if ( $this->readOnlyMode->isReadOnly() ) {
180 // Cannot change anything in read only
181 return;
182 }
183
184 $userIdentity = $performer->getUser();
185 $userTalkPage = (
186 $title->getNamespace() === NS_USER_TALK &&
187 $title->getDBkey() === strtr( $userIdentity->getName(), ' ', '_' )
188 );
189
190 if ( $userTalkPage ) {
191 if ( !$oldid ) {
192 $oldRev = null;
193 } elseif ( !$oldRev ) {
194 $oldRev = $this->revisionLookup->getRevisionById( $oldid );
195 }
196 // NOTE: Has to be called before isAllowed() check, to ensure users with no watchlist
197 // access (by default, temporary and anonymous users) can clear their talk page
198 // notification (T345031).
199 $this->talkPageNotificationManager->clearForPageView( $userIdentity, $oldRev );
200 }
201
202 if ( !$this->isEnotifEnabled ) {
203 return;
204 }
205
206 if ( !$userIdentity->isRegistered() ) {
207 // Nothing else to do
208 return;
209 }
210
211 // NOTE: Has to be checked after the TalkPageNotificationManager::clearForPageView call, to
212 // ensure users with no watchlist access (by default, temporary and anonymous users) can
213 // clear their talk page notification (T345031).
214 if ( !$performer->isAllowed( 'editmywatchlist' ) ) {
215 // User isn't allowed to edit the watchlist
216 return;
217 }
218
219 // Only update the timestamp if the page is being watched.
220 // The query to find out if it is watched is cached both in memcached and per-invocation,
221 // and when it does have to be executed, it can be on a replica DB
222 // If this is the user's newtalk page, we always update the timestamp
223 $force = $userTalkPage ? 'force' : '';
224 $this->watchedItemStore->resetNotificationTimestamp( $userIdentity, $title, $force, $oldid );
225 }
226
235 if ( !$user->isRegistered() ) {
236 return false;
237 }
238
239 $cacheKey = 'u' . $user->getId() . '-' .
240 $title->getNamespace() . ':' . $title->getDBkey();
241
242 // avoid isset here, as it'll return false for null entries
243 if ( array_key_exists( $cacheKey, $this->notificationTimestampCache ) ) {
244 return $this->notificationTimestampCache[ $cacheKey ];
245 }
246
247 $watchedItem = $this->watchedItemStore->getWatchedItem( $user, $title );
248 if ( $watchedItem ) {
249 $timestamp = $watchedItem->getNotificationTimestamp();
250 } else {
251 $timestamp = false;
252 }
253
254 $this->notificationTimestampCache[ $cacheKey ] = $timestamp;
255 return $timestamp;
256 }
257
263 public function isWatchable( PageReference $target ): bool {
264 if ( !$this->nsInfo->isWatchable( $target->getNamespace() ) ) {
265 return false;
266 }
267
268 if ( $target instanceof PageIdentity && !$target->canExist() ) {
269 // Catch "improper" Title instances
270 return false;
271 }
272
273 return true;
274 }
275
283 public function isWatchedIgnoringRights( UserIdentity $userIdentity, PageReference $target ): bool {
284 if ( $this->isWatchable( $target ) ) {
285 return $this->watchedItemStore->isWatched( $userIdentity, $target );
286 }
287 return false;
288 }
289
298 public function isWatched( Authority $performer, PageReference $target ): bool {
299 if ( $performer->isAllowed( 'viewmywatchlist' ) ) {
300 return $this->isWatchedIgnoringRights( $performer->getUser(), $target );
301 }
302 return false;
303 }
304
312 public function isTempWatchedIgnoringRights( UserIdentity $userIdentity, PageReference $target ): bool {
313 if ( $this->isWatchable( $target ) ) {
314 return $this->watchedItemStore->isTempWatched( $userIdentity, $target );
315 }
316 return false;
317 }
318
327 public function isTempWatched( Authority $performer, PageReference $target ): bool {
328 if ( $performer->isAllowed( 'viewmywatchlist' ) ) {
329 return $this->isTempWatchedIgnoringRights( $performer->getUser(), $target );
330 }
331 return false;
332 }
333
343 public function addWatchIgnoringRights(
344 UserIdentity $userIdentity,
345 PageReference $target,
346 ?string $expiry = null
347 ): StatusValue {
348 if ( !$this->isWatchable( $target ) ) {
349 return StatusValue::newFatal( 'watchlistnotwatchable' );
350 }
351
352 $wikiPage = $this->wikiPageFactory->newFromTitle( $target );
353 $title = $wikiPage->getTitle();
354
355 $status = Status::newFatal( 'hookaborted' );
356 // TODO: broaden the interface on these hooks to accept PageReference
357 if ( $this->hookRunner->onWatchArticle( $userIdentity, $wikiPage, $status, $expiry ) ) {
358 $status = StatusValue::newGood();
359 $this->watchedItemStore->addWatch( $userIdentity, $this->getSubjectPage( $title ), $expiry );
360 if ( $this->nsInfo->canHaveTalkPage( $title ) ) {
361 $this->watchedItemStore->addWatch( $userIdentity, $this->getTalkPage( $title ), $expiry );
362 }
363 $this->hookRunner->onWatchArticleComplete( $userIdentity, $wikiPage );
364 }
365
366 // eventually user_touched should be factored out of User and this should be replaced
367 $user = $this->userFactory->newFromUserIdentity( $userIdentity );
368 $user->invalidateCache();
369
370 return $status;
371 }
372
383 public function addWatch(
384 Authority $performer,
385 PageReference $target,
386 ?string $expiry = null
387 ): StatusValue {
388 $status = PermissionStatus::newEmpty();
389 if ( !$performer->isAllowed( 'editmywatchlist', $status ) ) {
390 return $status;
391 }
392
393 return $this->addWatchIgnoringRights( $performer->getUser(), $target, $expiry );
394 }
395
404 UserIdentity $userIdentity,
405 PageReference $target
406 ): StatusValue {
407 if ( !$this->isWatchable( $target ) ) {
408 return StatusValue::newFatal( 'watchlistnotwatchable' );
409 }
410
411 $wikiPage = $this->wikiPageFactory->newFromTitle( $target );
412 $title = $wikiPage->getTitle();
413
414 $status = Status::newFatal( 'hookaborted' );
415 // TODO broaden the interface on these hooks from WikiPage to PageReference
416 if ( $this->hookRunner->onUnwatchArticle( $userIdentity, $wikiPage, $status ) ) {
417 $status = StatusValue::newGood();
418 $this->watchedItemStore->removeWatch( $userIdentity, $this->getSubjectPage( $title ) );
419 if ( $this->nsInfo->canHaveTalkPage( $title ) ) {
420 $this->watchedItemStore->removeWatch( $userIdentity, $this->getTalkPage( $title ) );
421 }
422 $this->hookRunner->onUnwatchArticleComplete( $userIdentity, $wikiPage );
423 }
424
425 // eventually user_touched should be factored out of User and this should be replaced
426 $user = $this->userFactory->newFromUserIdentity( $userIdentity );
427 $user->invalidateCache();
428
429 return $status;
430 }
431
438 private function getSubjectPage( PageReference $title ): PageReference {
439 if ( $this->nsInfo->isSubject( $title->getNamespace() ) ) {
440 return $title;
441 }
442 return PageReferenceValue::localReference(
443 $this->nsInfo->getSubject( $title->getNamespace() ),
444 $title->getDBkey()
445 );
446 }
447
454 private function getTalkPage( PageReference $title ): PageReference {
455 if ( $this->nsInfo->isTalk( $title->getNamespace() ) ) {
456 return $title;
457 }
458 return PageReferenceValue::localReference(
459 $this->nsInfo->getTalk( $title->getNamespace() ),
460 $title->getDBkey()
461 );
462 }
463
472 public function removeWatch(
473 Authority $performer,
474 PageReference $target
475 ): StatusValue {
476 $status = PermissionStatus::newEmpty();
477 if ( !$performer->isAllowed( 'editmywatchlist', $status ) ) {
478 return $status;
479 }
480
481 return $this->removeWatchIgnoringRights( $performer->getUser(), $target );
482 }
483
497 public function setWatch(
498 bool $watch,
499 Authority $performer,
500 PageReference $target,
501 ?string $expiry = null
502 ): StatusValue {
503 // User must be registered, and (T371091) not a temp user
504 if ( !$performer->getUser()->isRegistered() || $performer->isTemp() ) {
505 return StatusValue::newGood();
506 }
507
508 // User must be either changing the watch state or at least the expiry.
509
510 // Only call addWatchIgnoringRights() or removeWatch() if there's been a change in the watched status.
511 $oldWatchedItem = $this->watchedItemStore->getWatchedItem( $performer->getUser(), $target );
512 $changingWatchStatus = (bool)$oldWatchedItem !== $watch;
513 if ( $oldWatchedItem && $expiry !== null ) {
514 // If there's an old watched item, a non-null change to the expiry requires an UPDATE.
515 $oldWatchPeriod = $oldWatchedItem->getExpiry() ?? 'infinity';
516 $changingWatchStatus = $changingWatchStatus ||
517 $oldWatchPeriod !== ExpiryDef::normalizeExpiry( $expiry, TS::MW );
518 }
519
520 if ( $changingWatchStatus ) {
521 // If the user doesn't have 'editmywatchlist', we still want to
522 // allow them to add but not remove items via edits and such.
523 if ( $watch ) {
524 return $this->addWatchIgnoringRights( $performer->getUser(), $target, $expiry );
525 } else {
526 return $this->removeWatch( $performer, $target );
527 }
528 }
529
530 return StatusValue::newGood();
531 }
532}
const NS_USER_TALK
Definition Defines.php:54
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:68
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...
Create User objects.
addWatch(Authority $performer, PageReference $target, ?string $expiry=null)
Watch a page if the user has permission to edit their watchlist.
__construct(array $options, HookContainer $hookContainer, ReadOnlyMode $readOnlyMode, RevisionLookup $revisionLookup, TalkPageNotificationManager $talkPageNotificationManager, WatchedItemStoreInterface $watchedItemStore, UserFactory $userFactory, NamespaceInfo $nsInfo, WikiPageFactory $wikiPageFactory)
isWatched(Authority $performer, PageReference $target)
Check if the page is watched by the user and the user has permission to view their watchlist.
addWatchIgnoringRights(UserIdentity $userIdentity, PageReference $target, ?string $expiry=null)
Watch a page.
clearTitleUserNotifications(Authority $performer, PageReference $title, int $oldid=0, ?RevisionRecord $oldRev=null)
Clear the user's notification timestamp for the given title.
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.
setWatch(bool $watch, Authority $performer, PageReference $target, ?string $expiry=null)
Watch or unwatch a page, calling watch/unwatch hooks as appropriate.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
static newGood( $value=null)
Factory function for good results.
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)