Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
87.62% |
92 / 105 |
|
71.43% |
10 / 14 |
CRAP | |
0.00% |
0 / 1 |
| TalkPageNotificationManager | |
87.62% |
92 / 105 |
|
71.43% |
10 / 14 |
38.46 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| userHasNewMessages | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| clearForPageView | |
62.96% |
17 / 27 |
|
0.00% |
0 / 1 |
9.49 | |||
| setUserHasNewMessages | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| removeUserHasNewMessages | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
| getLatestSeenMessageTimestamp | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
6.02 | |||
| clearInstanceCache | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| isTalkDisabled | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| dbCheckNewUserMessages | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| dbUpdateNewUserMessages | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
4.01 | |||
| dbDeleteNewUserMessages | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
| getQueryFieldAndId | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| getCacheKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| touchUser | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\User; |
| 8 | |
| 9 | use MediaWiki\Config\ServiceOptions; |
| 10 | use MediaWiki\Deferred\DeferredUpdates; |
| 11 | use MediaWiki\HookContainer\HookContainer; |
| 12 | use MediaWiki\HookContainer\HookRunner; |
| 13 | use MediaWiki\MainConfigNames; |
| 14 | use MediaWiki\Revision\RevisionLookup; |
| 15 | use MediaWiki\Revision\RevisionRecord; |
| 16 | use MediaWiki\Utils\MWTimestamp; |
| 17 | use Wikimedia\Rdbms\IConnectionProvider; |
| 18 | use Wikimedia\Rdbms\ReadOnlyMode; |
| 19 | use Wikimedia\Timestamp\TimestampFormat as TS; |
| 20 | |
| 21 | /** |
| 22 | * Manages user talk page notifications |
| 23 | * |
| 24 | * @since 1.35 |
| 25 | * @ingroup User |
| 26 | */ |
| 27 | class TalkPageNotificationManager { |
| 28 | |
| 29 | /** |
| 30 | * @internal For use by ServiceWiring |
| 31 | */ |
| 32 | public const CONSTRUCTOR_OPTIONS = [ |
| 33 | MainConfigNames::DisableAnonTalk |
| 34 | ]; |
| 35 | |
| 36 | private array $userMessagesCache = []; |
| 37 | private bool $disableAnonTalk; |
| 38 | private IConnectionProvider $dbProvider; |
| 39 | private ReadOnlyMode $readOnlyMode; |
| 40 | private RevisionLookup $revisionLookup; |
| 41 | private HookRunner $hookRunner; |
| 42 | private UserFactory $userFactory; |
| 43 | |
| 44 | /** |
| 45 | * @param ServiceOptions $serviceOptions |
| 46 | * @param IConnectionProvider $dbProvider |
| 47 | * @param ReadOnlyMode $readOnlyMode |
| 48 | * @param RevisionLookup $revisionLookup |
| 49 | * @param HookContainer $hookContainer |
| 50 | * @param UserFactory $userFactory |
| 51 | */ |
| 52 | public function __construct( |
| 53 | ServiceOptions $serviceOptions, |
| 54 | IConnectionProvider $dbProvider, |
| 55 | ReadOnlyMode $readOnlyMode, |
| 56 | RevisionLookup $revisionLookup, |
| 57 | HookContainer $hookContainer, |
| 58 | UserFactory $userFactory |
| 59 | ) { |
| 60 | $serviceOptions->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
| 61 | $this->disableAnonTalk = $serviceOptions->get( MainConfigNames::DisableAnonTalk ); |
| 62 | $this->dbProvider = $dbProvider; |
| 63 | $this->readOnlyMode = $readOnlyMode; |
| 64 | $this->revisionLookup = $revisionLookup; |
| 65 | $this->hookRunner = new HookRunner( $hookContainer ); |
| 66 | $this->userFactory = $userFactory; |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Check if the user has new messages. |
| 71 | * @param UserIdentity $user |
| 72 | * @return bool whether the user has new messages |
| 73 | */ |
| 74 | public function userHasNewMessages( UserIdentity $user ): bool { |
| 75 | $userKey = $this->getCacheKey( $user ); |
| 76 | |
| 77 | // Load the newtalk status if it is unloaded |
| 78 | if ( !isset( $this->userMessagesCache[$userKey] ) ) { |
| 79 | if ( $this->isTalkDisabled( $user ) ) { |
| 80 | // Anon disabled by configuration. |
| 81 | $this->userMessagesCache[$userKey] = false; |
| 82 | } else { |
| 83 | $this->userMessagesCache[$userKey] = $this->dbCheckNewUserMessages( $user ); |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | return (bool)$this->userMessagesCache[$userKey]; |
| 88 | } |
| 89 | |
| 90 | /** |
| 91 | * Clear notifications when the user's own talk page is viewed |
| 92 | * |
| 93 | * @param UserIdentity $user |
| 94 | * @param RevisionRecord|null $oldRev If it is an old revision view, the |
| 95 | * old revision. If it is a current revision view, this should be null. |
| 96 | */ |
| 97 | public function clearForPageView( |
| 98 | UserIdentity $user, |
| 99 | ?RevisionRecord $oldRev = null |
| 100 | ) { |
| 101 | // Abort if the hook says so. (Echo doesn't abort, it just queues its own update) |
| 102 | if ( !$this->hookRunner->onUserClearNewTalkNotification( |
| 103 | $user, |
| 104 | $oldRev ? $oldRev->getId() : 0 |
| 105 | ) ) { |
| 106 | return; |
| 107 | } |
| 108 | |
| 109 | if ( $this->isTalkDisabled( $user ) ) { |
| 110 | return; |
| 111 | } |
| 112 | |
| 113 | // Nothing to do if there are no messages |
| 114 | if ( !$this->userHasNewMessages( $user ) ) { |
| 115 | return; |
| 116 | } |
| 117 | |
| 118 | // If there is a subsequent revision after the one being viewed, use |
| 119 | // its timestamp as the new notification timestamp. If there is no |
| 120 | // subsequent revision, the notification is cleared. |
| 121 | if ( $oldRev ) { |
| 122 | $newRev = $this->revisionLookup->getNextRevision( $oldRev ); |
| 123 | if ( $newRev ) { |
| 124 | DeferredUpdates::addCallableUpdate( |
| 125 | function () use ( $user, $newRev ) { |
| 126 | $this->dbDeleteNewUserMessages( $user ); |
| 127 | $this->dbUpdateNewUserMessages( $user, $newRev ); |
| 128 | } |
| 129 | ); |
| 130 | return; |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // Update the cache now so that the skin doesn't show a notification |
| 135 | $userKey = $this->getCacheKey( $user ); |
| 136 | $this->userMessagesCache[$userKey] = false; |
| 137 | |
| 138 | // Defer the DB delete |
| 139 | DeferredUpdates::addCallableUpdate( |
| 140 | function () use ( $user ) { |
| 141 | $this->touchUser( $user ); |
| 142 | $this->dbDeleteNewUserMessages( $user ); |
| 143 | } |
| 144 | ); |
| 145 | } |
| 146 | |
| 147 | /** |
| 148 | * Update the talk page messages status. |
| 149 | * |
| 150 | * @param UserIdentity $user |
| 151 | * @param RevisionRecord|null $curRev New, as yet unseen revision of the user talk page. |
| 152 | * Null is acceptable in case the revision is not known. This will indicate that new messages |
| 153 | * exist, but will not affect the latest seen message timestamp |
| 154 | */ |
| 155 | public function setUserHasNewMessages( |
| 156 | UserIdentity $user, |
| 157 | ?RevisionRecord $curRev = null |
| 158 | ): void { |
| 159 | if ( $this->isTalkDisabled( $user ) ) { |
| 160 | return; |
| 161 | } |
| 162 | |
| 163 | $userKey = $this->getCacheKey( $user ); |
| 164 | $this->userMessagesCache[$userKey] = true; |
| 165 | $this->touchUser( $user ); |
| 166 | $this->dbUpdateNewUserMessages( $user, $curRev ); |
| 167 | } |
| 168 | |
| 169 | /** |
| 170 | * Remove the new messages status |
| 171 | */ |
| 172 | public function removeUserHasNewMessages( UserIdentity $user ): void { |
| 173 | if ( $this->isTalkDisabled( $user ) ) { |
| 174 | return; |
| 175 | } |
| 176 | |
| 177 | $userKey = $this->getCacheKey( $user ); |
| 178 | $this->userMessagesCache[$userKey] = false; |
| 179 | |
| 180 | $this->dbDeleteNewUserMessages( $user ); |
| 181 | } |
| 182 | |
| 183 | /** |
| 184 | * Returns the timestamp of the latest revision of the user talkpage |
| 185 | * that the user has already seen in TS::MW format. |
| 186 | * If the user has no new messages, returns null |
| 187 | * |
| 188 | * @param UserIdentity $user |
| 189 | * @return string|null |
| 190 | */ |
| 191 | public function getLatestSeenMessageTimestamp( UserIdentity $user ): ?string { |
| 192 | $userKey = $this->getCacheKey( $user ); |
| 193 | // Don't use self::userHasNewMessages here to avoid an extra DB query |
| 194 | // in case the value is not cached already |
| 195 | if ( $this->isTalkDisabled( $user ) || |
| 196 | ( isset( $this->userMessagesCache[$userKey] ) && !$this->userMessagesCache[$userKey] ) |
| 197 | ) { |
| 198 | return null; |
| 199 | } |
| 200 | |
| 201 | [ $field, $id ] = $this->getQueryFieldAndId( $user ); |
| 202 | // Get the "last viewed rev" timestamp from the oldest message notification |
| 203 | $timestamp = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder() |
| 204 | ->select( 'MIN(user_last_timestamp)' ) |
| 205 | ->from( 'user_newtalk' ) |
| 206 | ->where( [ $field => $id ] ) |
| 207 | ->caller( __METHOD__ )->fetchField(); |
| 208 | if ( $timestamp ) { |
| 209 | // TODO: Now that User::setNewTalk() was removed, it should be possible to |
| 210 | // cache *not* having a new message as well (if $timestamp is null). |
| 211 | $this->userMessagesCache[$userKey] = true; |
| 212 | } |
| 213 | return $timestamp !== null ? MWTimestamp::convert( TS::MW, $timestamp ) : null; |
| 214 | } |
| 215 | |
| 216 | /** |
| 217 | * Remove the cached newtalk status for the given user |
| 218 | * @internal There should be no need to call this other than from User::clearInstanceCache |
| 219 | * @param UserIdentity $user |
| 220 | */ |
| 221 | public function clearInstanceCache( UserIdentity $user ): void { |
| 222 | $userKey = $this->getCacheKey( $user ); |
| 223 | $this->userMessagesCache[$userKey] = null; |
| 224 | } |
| 225 | |
| 226 | /** |
| 227 | * Check whether the talk page is disabled for a user |
| 228 | * @param UserIdentity $user |
| 229 | * @return bool |
| 230 | */ |
| 231 | private function isTalkDisabled( UserIdentity $user ): bool { |
| 232 | return !$user->isRegistered() && $this->disableAnonTalk; |
| 233 | } |
| 234 | |
| 235 | /** |
| 236 | * Internal uncached check for new messages |
| 237 | * @param UserIdentity $user |
| 238 | * @return bool True if the user has new messages |
| 239 | */ |
| 240 | private function dbCheckNewUserMessages( UserIdentity $user ): bool { |
| 241 | [ $field, $id ] = $this->getQueryFieldAndId( $user ); |
| 242 | $ok = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder() |
| 243 | ->select( $field ) |
| 244 | ->from( 'user_newtalk' ) |
| 245 | ->where( [ $field => $id ] ) |
| 246 | ->caller( __METHOD__ )->fetchField(); |
| 247 | return (bool)$ok; |
| 248 | } |
| 249 | |
| 250 | /** |
| 251 | * Add or update the new messages flag |
| 252 | * @param UserIdentity $user |
| 253 | * @param RevisionRecord|null $curRev New, as yet unseen revision of the |
| 254 | * user talk page. Ignored if null. |
| 255 | * @return bool True if successful, false otherwise |
| 256 | */ |
| 257 | private function dbUpdateNewUserMessages( |
| 258 | UserIdentity $user, |
| 259 | ?RevisionRecord $curRev = null |
| 260 | ): bool { |
| 261 | if ( $this->readOnlyMode->isReadOnly() ) { |
| 262 | return false; |
| 263 | } |
| 264 | |
| 265 | if ( $curRev ) { |
| 266 | $prevRev = $this->revisionLookup->getPreviousRevision( $curRev ); |
| 267 | $ts = $prevRev ? $prevRev->getTimestamp() : null; |
| 268 | } else { |
| 269 | $ts = null; |
| 270 | } |
| 271 | |
| 272 | // Mark the user as having new messages since this revision |
| 273 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
| 274 | [ $field, $id ] = $this->getQueryFieldAndId( $user ); |
| 275 | $dbw->newInsertQueryBuilder() |
| 276 | ->insertInto( 'user_newtalk' ) |
| 277 | ->ignore() |
| 278 | ->row( [ $field => $id, 'user_last_timestamp' => $dbw->timestampOrNull( $ts ) ] ) |
| 279 | ->caller( __METHOD__ )->execute(); |
| 280 | return (bool)$dbw->affectedRows(); |
| 281 | } |
| 282 | |
| 283 | /** |
| 284 | * Clear the new messages flag for the given user |
| 285 | * @param UserIdentity $user |
| 286 | * @return bool True if successful, false otherwise |
| 287 | */ |
| 288 | private function dbDeleteNewUserMessages( UserIdentity $user ): bool { |
| 289 | if ( $this->readOnlyMode->isReadOnly() ) { |
| 290 | return false; |
| 291 | } |
| 292 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
| 293 | [ $field, $id ] = $this->getQueryFieldAndId( $user ); |
| 294 | $dbw->newDeleteQueryBuilder() |
| 295 | ->deleteFrom( 'user_newtalk' ) |
| 296 | ->where( [ $field => $id ] ) |
| 297 | ->caller( __METHOD__ )->execute(); |
| 298 | return (bool)$dbw->affectedRows(); |
| 299 | } |
| 300 | |
| 301 | /** |
| 302 | * Get the field name and id for the user_newtalk table query |
| 303 | * @param UserIdentity $user |
| 304 | * @return array ( string $field, string|int $id ) |
| 305 | */ |
| 306 | private function getQueryFieldAndId( UserIdentity $user ): array { |
| 307 | if ( $user->isRegistered() ) { |
| 308 | $field = 'user_id'; |
| 309 | $id = $user->getId(); |
| 310 | } else { |
| 311 | $field = 'user_ip'; |
| 312 | $id = $user->getName(); |
| 313 | } |
| 314 | return [ $field, $id ]; |
| 315 | } |
| 316 | |
| 317 | /** |
| 318 | * Gets a unique key for various caches. |
| 319 | * @param UserIdentity $user |
| 320 | * @return string |
| 321 | */ |
| 322 | private function getCacheKey( UserIdentity $user ): string { |
| 323 | return $user->isRegistered() ? "u:{$user->getId()}" : "anon:{$user->getName()}"; |
| 324 | } |
| 325 | |
| 326 | /** |
| 327 | * Update the user touched timestamp |
| 328 | */ |
| 329 | private function touchUser( UserIdentity $user ) { |
| 330 | // Ideally this would not be in User, it would be in its own service |
| 331 | // or something |
| 332 | $this->userFactory->newFromUserIdentity( $user )->touch(); |
| 333 | } |
| 334 | } |