Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.62% covered (warning)
87.62%
92 / 105
71.43% covered (warning)
71.43%
10 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
TalkPageNotificationManager
87.62% covered (warning)
87.62%
92 / 105
71.43% covered (warning)
71.43%
10 / 14
38.46
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 userHasNewMessages
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 clearForPageView
62.96% covered (warning)
62.96%
17 / 27
0.00% covered (danger)
0.00%
0 / 1
9.49
 setUserHasNewMessages
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 removeUserHasNewMessages
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 getLatestSeenMessageTimestamp
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
 clearInstanceCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isTalkDisabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 dbCheckNewUserMessages
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 dbUpdateNewUserMessages
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 dbDeleteNewUserMessages
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getQueryFieldAndId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 touchUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
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
22namespace MediaWiki\User;
23
24use MediaWiki\Config\ServiceOptions;
25use MediaWiki\Deferred\DeferredUpdates;
26use MediaWiki\HookContainer\HookContainer;
27use MediaWiki\HookContainer\HookRunner;
28use MediaWiki\MainConfigNames;
29use MediaWiki\Revision\RevisionLookup;
30use MediaWiki\Revision\RevisionRecord;
31use MediaWiki\Utils\MWTimestamp;
32use Wikimedia\Rdbms\IConnectionProvider;
33use Wikimedia\Rdbms\ReadOnlyMode;
34
35/**
36 * Manages user talk page notifications
37 * @since 1.35
38 */
39class 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}