MediaWiki master
WatchlistManager.php
Go to the documentation of this file.
1<?php
2
23namespace MediaWiki\Watchlist;
24
40use StatusValue;
43
50
54 public const OPTION_ENOTIF = 'isEnotifEnabled';
55
57 private $isEnotifEnabled;
58
60 private $hookRunner;
61
63 private $readOnlyMode;
64
66 private $revisionLookup;
67
69 private $talkPageNotificationManager;
70
72 private $watchedItemStore;
73
75 private $userFactory;
76
78 private $nsInfo;
79
81 private $wikiPageFactory;
82
99 private $notificationTimestampCache = [];
100
112 public function __construct(
113 array $options,
114 HookContainer $hookContainer,
115 ReadOnlyMode $readOnlyMode,
116 RevisionLookup $revisionLookup,
117 TalkPageNotificationManager $talkPageNotificationManager,
118 WatchedItemStoreInterface $watchedItemStore,
119 UserFactory $userFactory,
120 NamespaceInfo $nsInfo,
121 WikiPageFactory $wikiPageFactory
122 ) {
123 $this->isEnotifEnabled = $options[ self::OPTION_ENOTIF ];
124 $this->hookRunner = new HookRunner( $hookContainer );
125 $this->readOnlyMode = $readOnlyMode;
126 $this->revisionLookup = $revisionLookup;
127 $this->talkPageNotificationManager = $talkPageNotificationManager;
128 $this->watchedItemStore = $watchedItemStore;
129 $this->userFactory = $userFactory;
130 $this->nsInfo = $nsInfo;
131 $this->wikiPageFactory = $wikiPageFactory;
132 }
133
143 public function clearAllUserNotifications( $performer ) {
144 if ( $this->readOnlyMode->isReadOnly() ) {
145 // Cannot change anything in read only
146 return;
147 }
148
149 if ( !$performer instanceof Authority ) {
150 $performer = $this->userFactory->newFromUserIdentity( $performer );
151 }
152
153 $user = $performer->getUser();
154
155 // NOTE: Has to be before `editmywatchlist` user right check, to ensure
156 // anonymous / temporary users have their talk page notifications cleared (T345031).
157 if ( !$this->isEnotifEnabled ) {
158 $this->talkPageNotificationManager->removeUserHasNewMessages( $user );
159 return;
160 }
161
162 if ( !$performer->isAllowed( 'editmywatchlist' ) ) {
163 // User isn't allowed to edit the watchlist
164 return;
165 }
166
167 if ( !$user->isRegistered() ) {
168 return;
169 }
170
171 $this->watchedItemStore->resetAllNotificationTimestampsForUser( $user );
172
173 // We also need to clear here the "you have new message" notification for the own
174 // user_talk page; it's cleared one page view later in WikiPage::doViewUpdates().
175 }
176
191 $performer,
192 $title,
193 int $oldid = 0,
194 ?RevisionRecord $oldRev = null
195 ) {
196 if ( $this->readOnlyMode->isReadOnly() ) {
197 // Cannot change anything in read only
198 return;
199 }
200
201 if ( !$performer instanceof Authority ) {
202 $performer = $this->userFactory->newFromUserIdentity( $performer );
203 }
204
205 $userIdentity = $performer->getUser();
206 $userTalkPage = (
207 $title->getNamespace() === NS_USER_TALK &&
208 $title->getDBkey() === strtr( $userIdentity->getName(), ' ', '_' )
209 );
210
211 if ( $userTalkPage ) {
212 if ( !$oldid ) {
213 $oldRev = null;
214 } elseif ( !$oldRev ) {
215 $oldRev = $this->revisionLookup->getRevisionById( $oldid );
216 }
217 // NOTE: Has to be called before isAllowed() check, to ensure users with no watchlist
218 // access (by default, temporary and anonymous users) can clear their talk page
219 // notification (T345031).
220 $this->talkPageNotificationManager->clearForPageView( $userIdentity, $oldRev );
221 }
222
223 if ( !$this->isEnotifEnabled ) {
224 return;
225 }
226
227 if ( !$userIdentity->isRegistered() ) {
228 // Nothing else to do
229 return;
230 }
231
232 // NOTE: Has to be checked after the TalkPageNotificationManager::clearForPageView call, to
233 // ensure users with no watchlist access (by default, temporary and anonymous users) can
234 // clear their talk page notification (T345031).
235 if ( !$performer->isAllowed( 'editmywatchlist' ) ) {
236 // User isn't allowed to edit the watchlist
237 return;
238 }
239
240 // Only update the timestamp if the page is being watched.
241 // The query to find out if it is watched is cached both in memcached and per-invocation,
242 // and when it does have to be executed, it can be on a replica DB
243 // If this is the user's newtalk page, we always update the timestamp
244 $force = $userTalkPage ? 'force' : '';
245 $this->watchedItemStore->resetNotificationTimestamp( $userIdentity, $title, $force, $oldid );
246 }
247
255 public function getTitleNotificationTimestamp( UserIdentity $user, $title ) {
256 if ( !$user->isRegistered() ) {
257 return false;
258 }
259
260 $cacheKey = 'u' . $user->getId() . '-' .
261 $title->getNamespace() . ':' . $title->getDBkey();
262
263 // avoid isset here, as it'll return false for null entries
264 if ( array_key_exists( $cacheKey, $this->notificationTimestampCache ) ) {
265 return $this->notificationTimestampCache[ $cacheKey ];
266 }
267
268 $watchedItem = $this->watchedItemStore->getWatchedItem( $user, $title );
269 if ( $watchedItem ) {
270 $timestamp = $watchedItem->getNotificationTimestamp();
271 } else {
272 $timestamp = false;
273 }
274
275 $this->notificationTimestampCache[ $cacheKey ] = $timestamp;
276 return $timestamp;
277 }
278
284 public function isWatchable( PageReference $target ): bool {
285 if ( !$this->nsInfo->isWatchable( $target->getNamespace() ) ) {
286 return false;
287 }
288
289 if ( $target instanceof PageIdentity && !$target->canExist() ) {
290 // Catch "improper" Title instances
291 return false;
292 }
293
294 return true;
295 }
296
304 public function isWatchedIgnoringRights( UserIdentity $userIdentity, PageIdentity $target ): bool {
305 if ( $this->isWatchable( $target ) ) {
306 return $this->watchedItemStore->isWatched( $userIdentity, $target );
307 }
308 return false;
309 }
310
319 public function isWatched( Authority $performer, PageIdentity $target ): bool {
320 if ( $performer->isAllowed( 'viewmywatchlist' ) ) {
321 return $this->isWatchedIgnoringRights( $performer->getUser(), $target );
322 }
323 return false;
324 }
325
333 public function isTempWatchedIgnoringRights( UserIdentity $userIdentity, PageIdentity $target ): bool {
334 if ( $this->isWatchable( $target ) ) {
335 return $this->watchedItemStore->isTempWatched( $userIdentity, $target );
336 }
337 return false;
338 }
339
348 public function isTempWatched( Authority $performer, PageIdentity $target ): bool {
349 if ( $performer->isAllowed( 'viewmywatchlist' ) ) {
350 return $this->isTempWatchedIgnoringRights( $performer->getUser(), $target );
351 }
352 return false;
353 }
354
364 public function addWatchIgnoringRights(
365 UserIdentity $userIdentity,
366 PageIdentity $target,
367 ?string $expiry = null
368 ): StatusValue {
369 if ( !$this->isWatchable( $target ) ) {
370 return StatusValue::newFatal( 'watchlistnotwatchable' );
371 }
372
373 $wikiPage = $this->wikiPageFactory->newFromTitle( $target );
374 $title = $wikiPage->getTitle();
375
376 // TODO: update hooks to take Authority
377 $status = Status::newFatal( 'hookaborted' );
378 $user = $this->userFactory->newFromUserIdentity( $userIdentity );
379 if ( $this->hookRunner->onWatchArticle( $user, $wikiPage, $status, $expiry ) ) {
380 $status = StatusValue::newGood();
381 $this->watchedItemStore->addWatch( $userIdentity, $this->nsInfo->getSubjectPage( $title ), $expiry );
382 if ( $this->nsInfo->canHaveTalkPage( $title ) ) {
383 $this->watchedItemStore->addWatch( $userIdentity, $this->nsInfo->getTalkPage( $title ), $expiry );
384 }
385 $this->hookRunner->onWatchArticleComplete( $user, $wikiPage );
386 }
387
388 // eventually user_touched should be factored out of User and this should be replaced
389 $user->invalidateCache();
390
391 return $status;
392 }
393
404 public function addWatch(
405 Authority $performer,
406 PageIdentity $target,
407 ?string $expiry = null
408 ): StatusValue {
409 if ( !$performer->isAllowed( 'editmywatchlist' ) ) {
410 // TODO: this function should be moved out of User
411 return User::newFatalPermissionDeniedStatus( 'editmywatchlist' );
412 }
413
414 return $this->addWatchIgnoringRights( $performer->getUser(), $target, $expiry );
415 }
416
425 UserIdentity $userIdentity,
426 PageIdentity $target
427 ): StatusValue {
428 if ( !$this->isWatchable( $target ) ) {
429 return StatusValue::newFatal( 'watchlistnotwatchable' );
430 }
431
432 $wikiPage = $this->wikiPageFactory->newFromTitle( $target );
433 $title = $wikiPage->getTitle();
434
435 // TODO: update hooks to take Authority
436 $status = Status::newFatal( 'hookaborted' );
437 $user = $this->userFactory->newFromUserIdentity( $userIdentity );
438 if ( $this->hookRunner->onUnwatchArticle( $user, $wikiPage, $status ) ) {
439 $status = StatusValue::newGood();
440 $this->watchedItemStore->removeWatch( $userIdentity, $this->nsInfo->getSubjectPage( $title ) );
441 if ( $this->nsInfo->canHaveTalkPage( $title ) ) {
442 $this->watchedItemStore->removeWatch( $userIdentity, $this->nsInfo->getTalkPage( $title ) );
443 }
444 $this->hookRunner->onUnwatchArticleComplete( $user, $wikiPage );
445 }
446
447 // eventually user_touched should be factored out of User and this should be replaced
448 $user->invalidateCache();
449
450 return $status;
451 }
452
461 public function removeWatch(
462 Authority $performer,
463 PageIdentity $target
464 ): StatusValue {
465 if ( !$performer->isAllowed( 'editmywatchlist' ) ) {
466 // TODO: this function should be moved out of User
467 return User::newFatalPermissionDeniedStatus( 'editmywatchlist' );
468 }
469
470 return $this->removeWatchIgnoringRights( $performer->getUser(), $target );
471 }
472
486 public function setWatch(
487 bool $watch,
488 Authority $performer,
489 PageIdentity $target,
490 ?string $expiry = null
491 ): StatusValue {
492 // User must be registered, and (T371091) not a temp user
493 if ( !$performer->getUser()->isRegistered() || $performer->isTemp() ) {
494 return StatusValue::newGood();
495 }
496
497 // User must be either changing the watch state or at least the expiry.
498
499 // Only call addWatchIgnoringRights() or removeWatch() if there's been a change in the watched status.
500 $oldWatchedItem = $this->watchedItemStore->getWatchedItem( $performer->getUser(), $target );
501 $changingWatchStatus = (bool)$oldWatchedItem !== $watch;
502 if ( $oldWatchedItem && $expiry !== null ) {
503 // If there's an old watched item, a non-null change to the expiry requires an UPDATE.
504 $oldWatchPeriod = $oldWatchedItem->getExpiry() ?? 'infinity';
505 $changingWatchStatus = $changingWatchStatus ||
506 $oldWatchPeriod !== ExpiryDef::normalizeExpiry( $expiry, TS_MW );
507 }
508
509 if ( $changingWatchStatus ) {
510 // If the user doesn't have 'editmywatchlist', we still want to
511 // allow them to add but not remove items via edits and such.
512 if ( $watch ) {
513 return $this->addWatchIgnoringRights( $performer->getUser(), $target, $expiry );
514 } else {
515 return $this->removeWatch( $performer, $target );
516 }
517 }
518
519 return StatusValue::newGood();
520 }
521}
const NS_USER_TALK
Definition Defines.php:68
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:81
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Service for creating WikiPage objects.
Page revision base class.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:54
This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of them ba...
Creates User objects.
internal since 1.36
Definition User.php:93
isTempWatchedIgnoringRights(UserIdentity $userIdentity, PageIdentity $target)
Check if the article is temporarily watched by the user.
__construct(array $options, HookContainer $hookContainer, ReadOnlyMode $readOnlyMode, RevisionLookup $revisionLookup, TalkPageNotificationManager $talkPageNotificationManager, WatchedItemStoreInterface $watchedItemStore, UserFactory $userFactory, NamespaceInfo $nsInfo, WikiPageFactory $wikiPageFactory)
getTitleNotificationTimestamp(UserIdentity $user, $title)
Get the timestamp when this page was updated since the user last saw it.
clearAllUserNotifications( $performer)
Resets all of the given user's page-change notification timestamps.
clearTitleUserNotifications( $performer, $title, int $oldid=0, ?RevisionRecord $oldRev=null)
Clear the user's notification timestamp for the given title.
removeWatch(Authority $performer, PageIdentity $target)
Stop watching a page if the user has permission to edit their watchlist.
isWatched(Authority $performer, PageIdentity $target)
Check if the page is watched by the user and the user has permission to view their watchlist.
setWatch(bool $watch, Authority $performer, PageIdentity $target, ?string $expiry=null)
Watch or unwatch a page, calling watch/unwatch hooks as appropriate.
addWatch(Authority $performer, PageIdentity $target, ?string $expiry=null)
Watch a page if the user has permission to edit their watchlist.
isTempWatched(Authority $performer, PageIdentity $target)
Check if the page is temporarily watched by the user and the user has permission to view their watchl...
isWatchedIgnoringRights(UserIdentity $userIdentity, PageIdentity $target)
Check if the page is watched by the user.
addWatchIgnoringRights(UserIdentity $userIdentity, PageIdentity $target, ?string $expiry=null)
Watch a page.
removeWatchIgnoringRights(UserIdentity $userIdentity, PageIdentity $target)
Stop watching a page.
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:17
Determine whether a site is currently in read-only mode.
Represents the target of a wiki link.
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.
This interface represents the authority associated with the current execution context,...
Definition Authority.php:37
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)