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