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