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 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\User;
8
9use MediaWiki\Config\ServiceOptions;
10use MediaWiki\Deferred\DeferredUpdates;
11use MediaWiki\HookContainer\HookContainer;
12use MediaWiki\HookContainer\HookRunner;
13use MediaWiki\MainConfigNames;
14use MediaWiki\Revision\RevisionLookup;
15use MediaWiki\Revision\RevisionRecord;
16use MediaWiki\Utils\MWTimestamp;
17use Wikimedia\Rdbms\IConnectionProvider;
18use Wikimedia\Rdbms\ReadOnlyMode;
19use Wikimedia\Timestamp\TimestampFormat as TS;
20
21/**
22 * Manages user talk page notifications
23 *
24 * @since 1.35
25 * @ingroup User
26 */
27class 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}