Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
21.32% covered (danger)
21.32%
58 / 272
14.81% covered (danger)
14.81%
4 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
NotifUser
21.32% covered (danger)
21.32%
58 / 272
14.81% covered (danger)
14.81%
4 / 27
2814.41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 newFromUser
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 clearUserTalkNotifications
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getMessageCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAlertCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLocalNotificationCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNotificationCount
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
56
 getLastUnreadAlertTime
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLastUnreadMessageTime
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLastUnreadNotificationTime
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
72
 markRead
43.48% covered (danger)
43.48%
10 / 23
0.00% covered (danger)
0.00%
0 / 1
12.50
 markUnRead
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
42
 markAllRead
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
5.04
 markReadForeign
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getForeignNotificationInfo
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 resetNotificationCount
18.18% covered (danger)
18.18%
4 / 22
0.00% covered (danger)
0.00%
0 / 1
33.84
 getGlobalUpdateTime
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getCountsAndTimestamps
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 computeLocalCountsAndTimestamps
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
12
 computeGlobalCountsAndTimestamps
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 getEmailFormat
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getMemcKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGlobalMemcKey
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getForeignNotifications
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getForeignCount
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getForeignTimestamp
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 capNotificationCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\Notifications;
4
5use InvalidArgumentException;
6use MediaWiki\Extension\Notifications\Gateway\UserNotificationGateway;
7use MediaWiki\Extension\Notifications\Mapper\NotificationMapper;
8use MediaWiki\Extension\Notifications\Mapper\TargetPageMapper;
9use MediaWiki\Extension\Notifications\Model\Notification;
10use MediaWiki\MediaWikiServices;
11use MediaWiki\Request\WebRequest;
12use MediaWiki\User\CentralId\CentralIdLookup;
13use MediaWiki\User\Options\UserOptionsLookup;
14use MediaWiki\User\UserFactory;
15use MediaWiki\User\UserIdentity;
16use MediaWiki\Utils\MWTimestamp;
17use MediaWiki\WikiMap\WikiMap;
18use WANObjectCache;
19use Wikimedia\Rdbms\Database;
20use Wikimedia\Rdbms\ReadOnlyMode;
21
22/**
23 * Entity that represents a notification target user
24 */
25class NotifUser {
26
27    /**
28     * Notification target user
29     * @var UserIdentity
30     */
31    private $mUser;
32
33    /**
34     * Object cache
35     * @var WANObjectCache
36     */
37    private $cache;
38
39    /**
40     * Database access gateway
41     * @var UserNotificationGateway
42     */
43    private $userNotifGateway;
44
45    /**
46     * Notification mapper
47     * @var NotificationMapper
48     */
49    private $notifMapper;
50
51    /**
52     * Target page mapper
53     * @var TargetPageMapper
54     */
55    private $targetPageMapper;
56
57    /**
58     * @var ForeignNotifications|null
59     */
60    private $foreignNotifications;
61
62    /**
63     * @var array[]|null
64     */
65    private $localCountsAndTimestamps;
66
67    /**
68     * @var array[]|null
69     */
70    private $globalCountsAndTimestamps;
71
72    /**
73     * @var UserOptionsLookup
74     */
75    private $userOptionsLookup;
76
77    /**
78     * @var UserFactory
79     */
80    private $userFactory;
81
82    /**
83     * @var ReadOnlyMode
84     */
85    private $readOnlyMode;
86
87    // The max notification count shown in badge
88
89    // The max number shown in bundled message, eg, <user> and 99+ others <action>.
90    // This is really a totally separate thing, and could be its own constant.
91
92    // WARNING: If you change this, you should also change all references in the
93    // i18n messages (100 and 99) in all repositories using Echo.
94    public const MAX_BADGE_COUNT = 99;
95
96    private const CACHE_TTL = 86400;
97    private const CACHE_KEY = 'echo-notification-counts';
98    private const CHECK_KEY = 'echo-notification-updated';
99
100    /**
101     * Usually client code doesn't need to initialize the object directly
102     * because it could be obtained from factory method newFromUser()
103     * @param UserIdentity $user
104     * @param WANObjectCache $cache
105     * @param UserNotificationGateway $userNotifGateway
106     * @param NotificationMapper $notifMapper
107     * @param TargetPageMapper $targetPageMapper
108     * @param UserOptionsLookup $userOptionsLookup
109     * @param UserFactory $userFactory
110     * @param ReadOnlyMode $readOnlyMode
111     */
112    public function __construct(
113        UserIdentity $user,
114        WANObjectCache $cache,
115        UserNotificationGateway $userNotifGateway,
116        NotificationMapper $notifMapper,
117        TargetPageMapper $targetPageMapper,
118        UserOptionsLookup $userOptionsLookup,
119        UserFactory $userFactory,
120        ReadOnlyMode $readOnlyMode
121    ) {
122        $this->mUser = $user;
123        $this->userNotifGateway = $userNotifGateway;
124        $this->cache = $cache;
125        $this->notifMapper = $notifMapper;
126        $this->targetPageMapper = $targetPageMapper;
127        $this->userOptionsLookup = $userOptionsLookup;
128        $this->userFactory = $userFactory;
129        $this->readOnlyMode = $readOnlyMode;
130    }
131
132    /**
133     * Factory method. The caller should make sure that the user is registered.
134     * @param UserIdentity $user
135     * @return NotifUser
136     */
137    public static function newFromUser( UserIdentity $user ) {
138        if ( !$user->isRegistered() ) {
139            throw new InvalidArgumentException( 'User must be logged in to view notification!' );
140        }
141        $services = MediaWikiServices::getInstance();
142        return new NotifUser(
143            $user,
144            $services->getMainWANObjectCache(),
145            new UserNotificationGateway(
146                $user,
147                DbFactory::newFromDefault(),
148                $services->getMainConfig()
149            ),
150            new NotificationMapper(),
151            new TargetPageMapper(),
152            $services->getUserOptionsLookup(),
153            $services->getUserFactory(),
154            $services->getReadOnlyMode()
155        );
156    }
157
158    /**
159     * Mark all edit-user-talk notifications as read. This is called when a user visits their user talk page.
160     */
161    public function clearUserTalkNotifications() {
162        $this->markRead(
163            $this->userNotifGateway->getUnreadNotifications(
164                'edit-user-talk'
165            )
166        );
167    }
168
169    /**
170     * Get message count for this user.
171     *
172     * @return int
173     */
174    public function getMessageCount() {
175        return $this->getNotificationCount( AttributeManager::MESSAGE );
176    }
177
178    /**
179     * Get alert count for this user.
180     *
181     * @return int
182     */
183    public function getAlertCount() {
184        return $this->getNotificationCount( AttributeManager::ALERT );
185    }
186
187    /**
188     * Get the number of unread local notifications in a given section. This does not include
189     * foreign notifications, even if the user has cross-wiki notifications enabled.
190     *
191     * @param string $section Notification section
192     * @return int
193     */
194    public function getLocalNotificationCount( $section = AttributeManager::ALL ) {
195        return $this->getNotificationCount( $section, false );
196    }
197
198    /**
199     * Retrieves number of unread notifications that a user has, would return
200     * NotifUser::MAX_BADGE_COUNT + 1 at most.
201     *
202     * If $wgEchoCrossWikiNotifications is disabled, the $global parameter is ignored.
203     *
204     * @param string $section Notification section
205     * @param bool|string $global Whether to include foreign notifications.
206     *   If set to 'preference', uses the user's preference.
207     * @return int
208     */
209    public function getNotificationCount( $section = AttributeManager::ALL, $global = 'preference' ) {
210        if ( !$this->mUser->isRegistered() ) {
211            return 0;
212        }
213
214        global $wgEchoCrossWikiNotifications;
215        if ( !$wgEchoCrossWikiNotifications ) {
216            // Ignore the $global parameter
217            $global = false;
218        }
219
220        if ( $global === 'preference' ) {
221            $global = $this->getForeignNotifications()->isEnabledByUser();
222        }
223
224        $data = $this->getCountsAndTimestamps( $global );
225        if ( $global && $data['global'] === null ) {
226            // No global user exists, no data. Use only local count
227            $global = false;
228        }
229        $count = $data[$global ? 'global' : 'local'][$section]['count'];
230        return (int)$count;
231    }
232
233    /**
234     * Get the timestamp of the latest unread alert
235     *
236     * @return bool|MWTimestamp Timestamp of latest unread alert, or false if there are no unread alerts.
237     */
238    public function getLastUnreadAlertTime() {
239        return $this->getLastUnreadNotificationTime( AttributeManager::ALERT );
240    }
241
242    /**
243     * Get the timestamp of the latest unread message
244     *
245     * @return bool|MWTimestamp
246     */
247    public function getLastUnreadMessageTime() {
248        return $this->getLastUnreadNotificationTime( AttributeManager::MESSAGE );
249    }
250
251    /**
252     * Returns the timestamp of the last unread notification.
253     *
254     * If $wgEchoCrossWikiNotifications is disabled, the $global parameter is ignored.
255     *
256     * @param string $section Notification section
257     * @param bool|string $global Whether to include foreign notifications.
258     *   If set to 'preference', uses the user's preference.
259     * @return bool|MWTimestamp Timestamp of latest unread message, or false if there are no unread messages.
260     */
261    public function getLastUnreadNotificationTime( $section = AttributeManager::ALL, $global = 'preference' ) {
262        if ( !$this->mUser->isRegistered() ) {
263            return false;
264        }
265
266        global $wgEchoCrossWikiNotifications;
267        if ( !$wgEchoCrossWikiNotifications ) {
268            // Ignore the $global parameter
269            $global = false;
270        }
271
272        if ( $global === 'preference' ) {
273            $global = $this->getForeignNotifications()->isEnabledByUser();
274        }
275
276        $data = $this->getCountsAndTimestamps( $global );
277        if ( $global && $data['global'] === null ) {
278            // No global user exists, no data. Use only local count
279            $global = false;
280        }
281        $timestamp = $data[$global ? 'global' : 'local'][$section]['timestamp'];
282        return $timestamp === -1 ? false : new MWTimestamp( $timestamp );
283    }
284
285    /**
286     * Mark one or more notifications read for a user.
287     * @param array $eventIds Array of event IDs to mark read
288     * @return bool Returns true when data has been updated in DB, false on
289     *   failure, or when there was nothing to update
290     */
291    public function markRead( $eventIds ) {
292        $eventIds = array_filter( (array)$eventIds, 'is_numeric' );
293        if ( !$eventIds || $this->readOnlyMode->isReadOnly() ) {
294            return false;
295        }
296
297        $updated = $this->userNotifGateway->markRead( $eventIds );
298        if ( $updated ) {
299            // Update notification count in cache
300            $this->resetNotificationCount();
301
302            // After this 'mark read', is there any unread edit-user-talk
303            // remaining?  If not, we should clear the newtalk flag.
304            $talkPageNotificationManager = MediaWikiServices::getInstance()
305                ->getTalkPageNotificationManager();
306            if ( $talkPageNotificationManager->userHasNewMessages( $this->mUser ) ) {
307                $attributeManager = Services::getInstance()->getAttributeManager();
308                $categoryMap = $attributeManager->getEventsByCategory();
309                $usertalkTypes = $categoryMap['edit-user-talk'];
310                $unreadEditUserTalk = $this->notifMapper->fetchUnreadByUser(
311                    $this->mUser,
312                    1,
313                    null,
314                    $usertalkTypes,
315                    null,
316                    DB_PRIMARY
317                );
318                if ( $unreadEditUserTalk === [] ) {
319                    $talkPageNotificationManager->removeUserHasNewMessages( $this->mUser );
320                }
321            }
322        }
323
324        return $updated;
325    }
326
327    /**
328     * Mark one or more notifications unread for a user.
329     * @param array $eventIds Array of event IDs to mark unread
330     * @return bool Returns true when data has been updated in DB, false on
331     *   failure, or when there was nothing to update
332     */
333    public function markUnRead( $eventIds ) {
334        $eventIds = array_filter( (array)$eventIds, 'is_numeric' );
335        if ( !$eventIds || $this->readOnlyMode->isReadOnly() ) {
336            return false;
337        }
338
339        $updated = $this->userNotifGateway->markUnRead( $eventIds );
340        if ( $updated ) {
341            // Update notification count in cache
342            $this->resetNotificationCount();
343
344            // After this 'mark unread', is there any unread edit-user-talk?
345            // If so, we should add the edit-user-talk flag
346            $talkPageNotificationManager = MediaWikiServices::getInstance()
347                ->getTalkPageNotificationManager();
348            if ( !$talkPageNotificationManager->userHasNewMessages( $this->mUser ) ) {
349                $attributeManager = Services::getInstance()->getAttributeManager();
350                $categoryMap = $attributeManager->getEventsByCategory();
351                $usertalkTypes = $categoryMap['edit-user-talk'];
352                $unreadEditUserTalk = $this->notifMapper->fetchUnreadByUser(
353                    $this->mUser,
354                    1,
355                    null,
356                    $usertalkTypes,
357                    null,
358                    DB_PRIMARY
359                );
360                if ( $unreadEditUserTalk !== [] ) {
361                    $talkPageNotificationManager->setUserHasNewMessages( $this->mUser );
362                }
363            }
364        }
365
366        return $updated;
367    }
368
369    /**
370     * Attempt to mark all or sections of notifications as read, this only
371     * updates up to $wgEchoMaxUpdateCount records per request, see more
372     * detail about this in Echo.php, the other reason is that mediawiki
373     * database interface doesn't support updateJoin() that would update
374     * across multiple tables, we would visit this later
375     *
376     * @param string[] $sections
377     * @return bool
378     */
379    public function markAllRead( array $sections = [ AttributeManager::ALL ] ) {
380        if ( $this->readOnlyMode->isReadOnly() ) {
381            return false;
382        }
383
384        global $wgEchoMaxUpdateCount;
385
386        // Mark all sections as read if this is the case
387        if ( in_array( AttributeManager::ALL, $sections ) ) {
388            $sections = AttributeManager::$sections;
389        }
390
391        $attributeManager = Services::getInstance()->getAttributeManager();
392        $eventTypes = $attributeManager->getUserEnabledEventsBySections( $this->mUser, 'web', $sections );
393
394        $notifs = $this->notifMapper->fetchUnreadByUser( $this->mUser, $wgEchoMaxUpdateCount, null, $eventTypes );
395
396        $eventIds = array_filter(
397            array_map( static function ( Notification $notif ) {
398                // This should not happen at all, but use 0 in
399                // such case so to keep the code running
400                if ( $notif->getEvent() ) {
401                    return $notif->getEvent()->getId();
402                }
403                return 0;
404            }, $notifs )
405        );
406
407        $updated = $this->markRead( $eventIds );
408        if ( $updated ) {
409            // Delete records from echo_target_page
410            /**
411             * Keep the 'echo_target_page' records so they can be used for moderation.
412             */
413            // $this->targetPageMapper->deleteByUserEvents( $this->mUser, $eventIds );
414        }
415
416        return $updated;
417    }
418
419    /**
420     * Mark one of more notifications as read on a foreign wiki.
421     *
422     * @param int[] $eventIds Event IDs to mark as read
423     * @param string $wiki Wiki name
424     * @param WebRequest|null $originalRequest Original request data to be sent with these requests
425     */
426    public function markReadForeign( array $eventIds, $wiki, ?WebRequest $originalRequest = null ) {
427        $foreignReq = new ForeignWikiRequest(
428            $this->userFactory->newFromUserIdentity( $this->mUser ),
429            [
430                'action' => 'echomarkread',
431                'list' => implode( '|', $eventIds ),
432            ],
433            [ $wiki ],
434            'wikis',
435            'csrf'
436        );
437        $foreignReq->execute( $originalRequest );
438    }
439
440    /**
441     * Get information about a set of unread notifications on a foreign wiki.
442     *
443     * @param int[] $eventIds Event IDs to look up. Only unread notifications can be found.
444     * @param string $wiki Wiki name
445     * @param WebRequest|null $originalRequest Original request data to be sent with these requests
446     * @return array[] Array of notification data as returned by api.php, keyed by event ID
447     */
448    public function getForeignNotificationInfo( array $eventIds, $wiki, ?WebRequest $originalRequest = null ) {
449        $foreignReq = new ForeignWikiRequest(
450            $this->userFactory->newFromUserIdentity( $this->mUser ),
451            [
452                'action' => 'query',
453                'meta' => 'notifications',
454                'notprop' => 'list',
455                'notfilter' => '!read',
456                'notlimit' => 'max'
457            ],
458            [ $wiki ],
459            'notwikis'
460        );
461        $foreignResults = $foreignReq->execute( $originalRequest );
462        $list = $foreignResults[$wiki]['query']['notifications']['list'] ?? [];
463
464        $result = [];
465        foreach ( $list as $notif ) {
466            if ( in_array( $notif['id'], $eventIds ) ) {
467                $result[$notif['id']] = $notif;
468            }
469        }
470        return $result;
471    }
472
473    /**
474     * Invalidate cache and update echo_unread_wikis if x-wiki notifications is enabled.
475     *
476     * This updates the user's touched timestamp, as well as the value returned by getGlobalUpdateTime().
477     *
478     * NOTE: Consider calling this function from a deferred update, since it will read from and write to
479     * the primary DB if cross-wiki notifications are enabled.
480     */
481    public function resetNotificationCount() {
482        global $wgEchoCrossWikiNotifications;
483
484        // Delete cached local counts and timestamps
485        $localMemcKey = $this->getMemcKey( self::CACHE_KEY );
486        $this->cache->delete( $localMemcKey );
487
488        // Update the user touched timestamp for the local user
489        $this->userFactory->newFromUserIdentity( $this->mUser )->invalidateCache();
490
491        if ( $wgEchoCrossWikiNotifications ) {
492            // Delete cached global counts and timestamps
493            $globalMemcKey = $this->getGlobalMemcKey( self::CACHE_KEY );
494            if ( $globalMemcKey !== false ) {
495                $this->cache->delete( $globalMemcKey );
496            }
497
498            $uw = UnreadWikis::newFromUser( $this->mUser );
499            if ( $uw ) {
500                // Immediately compute new local counts and timestamps
501                $newLocalData = $this->computeLocalCountsAndTimestamps( DB_PRIMARY );
502                // Write the new values to the echo_unread_wikis table
503                $alertTs = $newLocalData[AttributeManager::ALERT]['timestamp'];
504                $messageTs = $newLocalData[AttributeManager::MESSAGE]['timestamp'];
505                $uw->updateCount(
506                    WikiMap::getCurrentWikiId(),
507                    $newLocalData[AttributeManager::ALERT]['count'],
508                    $alertTs === -1 ? false : new MWTimestamp( $alertTs ),
509                    $newLocalData[AttributeManager::MESSAGE]['count'],
510                    $messageTs === -1 ? false : new MWTimestamp( $messageTs )
511                );
512                // We could set() $newLocalData into the cache here, but we don't because that seems risky;
513                // instead we let it be recomputed on demand
514            }
515
516            // Update the global touched timestamp
517            $checkKey = $this->getGlobalMemcKey( self::CHECK_KEY );
518            if ( $checkKey ) {
519                $this->cache->touchCheckKey( $checkKey );
520            }
521        }
522    }
523
524    /**
525     * Get the timestamp of the last time the global notification counts/timestamps were updated, if available.
526     *
527     * If the timestamp of the last update is not known, this will return the current timestamp.
528     * If the user is not attached, this will return false.
529     *
530     * @return string|false MW timestamp of the last update, or false if the user is not attached
531     */
532    public function getGlobalUpdateTime() {
533        $key = $this->getGlobalMemcKey( self::CHECK_KEY );
534        if ( $key === false ) {
535            return false;
536        }
537        return wfTimestamp( TS_MW, $this->cache->getCheckKeyTime( $key ) );
538    }
539
540    /**
541     * Get the number of notifications in each section, and the timestamp of the latest notification in
542     * each section. This returns the raw data structure that is stored in the cache; unless you want
543     * all of this information, you're probably looking for getNotificationCount(),
544     * getLastUnreadNotificationTime() or one of its wrappers.
545     *
546     * The returned data structure looks like:
547     * [
548     *   'local' => [
549     *     'alert' => [ 'count' => N, 'timestamp' => TS ],
550     *     'message' => [ 'count' = N, 'timestamp' => TS ],
551     *     'all' => [ 'count' => N, 'timestamp' => TS ],
552     *   ],
553     *   'global' => [
554     *     'alert' => [ 'count' => N, 'timestamp' => TS ],
555     *     'message' => [ 'count' = N, 'timestamp' => TS ],
556     *     'all' => [ 'count' => N, 'timestamp' => TS ],
557     *   ],
558     * ]
559     * Where N is a number and TS is a timestamp in TS_MW format or -1. If $includeGlobal is false,
560     * the 'global' key will not be present. It could be null, if no global user exists.
561     *
562     * @param bool $includeGlobal Whether to include cross-wiki notifications as well
563     * @return array
564     */
565    public function getCountsAndTimestamps( $includeGlobal = false ) {
566        if ( $this->localCountsAndTimestamps === null ) {
567            $this->localCountsAndTimestamps = $this->cache->getWithSetCallback(
568                $this->getMemcKey( self::CACHE_KEY ),
569                self::CACHE_TTL,
570                function ( $oldValue, &$ttl, array &$setOpts ) {
571                    $dbr = $this->userNotifGateway->getDB( DB_REPLICA );
572                    $setOpts += Database::getCacheSetOptions( $dbr );
573                    return $this->computeLocalCountsAndTimestamps();
574                }
575            );
576        }
577        $result = [ 'local' => $this->localCountsAndTimestamps ];
578
579        if ( $includeGlobal ) {
580            if ( $this->globalCountsAndTimestamps === null ) {
581                $memcKey = $this->getGlobalMemcKey( self::CACHE_KEY );
582                // If getGlobalMemcKey returns false, we don't have a global user ID
583                // In that case, don't compute data that we can't cache or store
584                if ( $memcKey !== false ) {
585                    $this->globalCountsAndTimestamps = $this->cache->getWithSetCallback(
586                        $memcKey,
587                        self::CACHE_TTL,
588                        function ( $oldValue, &$ttl, array &$setOpts ) {
589                            $dbr = $this->userNotifGateway->getDB( DB_REPLICA );
590                            $setOpts += Database::getCacheSetOptions( $dbr );
591                            return $this->computeGlobalCountsAndTimestamps();
592                        }
593                    );
594                }
595            }
596            $result['global'] = $this->globalCountsAndTimestamps;
597        }
598        return $result;
599    }
600
601    /**
602     * Compute the counts and timestamps for the local notifications in each section.
603     * @param int $dbSource DB_REPLICA or DB_PRIMARY
604     * @return array[] [ 'alert' => [ 'count' => N, 'timestamp' => TS ], ... ]
605     */
606    protected function computeLocalCountsAndTimestamps( $dbSource = DB_REPLICA ) {
607        $attributeManager = Services::getInstance()->getAttributeManager();
608        $result = [];
609        $totals = [ 'count' => 0, 'timestamp' => -1 ];
610
611        foreach ( AttributeManager::$sections as $section ) {
612            $eventTypesToLoad = $attributeManager->getUserEnabledEventsBySections(
613                $this->mUser,
614                'web',
615                [ $section ]
616            );
617
618            $count = $this->userNotifGateway->getCappedNotificationCount(
619                $dbSource,
620                $eventTypesToLoad,
621                self::MAX_BADGE_COUNT + 1
622            );
623            $result[$section]['count'] = $count;
624            $totals['count'] += $count;
625
626            $notifications = $this->notifMapper->fetchUnreadByUser(
627                $this->mUser,
628                1,
629                null,
630                $eventTypesToLoad,
631                null,
632                $dbSource
633            );
634            if ( $notifications ) {
635                $notification = reset( $notifications );
636                $timestamp = $notification->getTimestamp();
637            } else {
638                $timestamp = -1;
639            }
640            $result[$section]['timestamp'] = $timestamp;
641            $totals['timestamp'] = max( $totals['timestamp'], $timestamp );
642        }
643        $totals['count'] = self::capNotificationCount( $totals['count'] );
644        $result[AttributeManager::ALL] = $totals;
645        return $result;
646    }
647
648    /**
649     * Compute the global counts and timestamps for each section.
650     *
651     * This calls getCountsAndTimestamps() to get data about local notifications, which may end up
652     * calling computeLocalCountsAndTimestamps() if there's a cache miss.
653     * @return array[] [ 'alert' => [ 'count' => N, 'timestamp' => TS ], ... ]
654     */
655    protected function computeGlobalCountsAndTimestamps() {
656        $localData = $this->getCountsAndTimestamps()['local'];
657        $result = [];
658        $totals = [ 'count' => 0, 'timestamp' => -1 ];
659        foreach ( AttributeManager::$sections as $section ) {
660            $localCount = $localData[$section]['count'];
661            $globalCount = self::capNotificationCount( $localCount + $this->getForeignCount( $section ) );
662            $result[$section]['count'] = $globalCount;
663            $totals['count'] += $globalCount;
664
665            $localTimestamp = $localData[$section]['timestamp'];
666            $foreignTimestamp = $this->getForeignTimestamp( $section );
667            $globalTimestamp = max(
668                $localTimestamp,
669                $foreignTimestamp ? $foreignTimestamp->getTimestamp( TS_MW ) : -1
670            );
671            $result[$section]['timestamp'] = $globalTimestamp;
672            $totals['timestamp'] = max( $totals['timestamp'], $globalTimestamp );
673        }
674        $totals['count'] = self::capNotificationCount( $totals['count'] );
675        $result[AttributeManager::ALL] = $totals;
676        return $result;
677    }
678
679    /**
680     * Get the user's email notification format
681     * @return string
682     */
683    public function getEmailFormat() {
684        global $wgAllowHTMLEmail;
685
686        if ( $wgAllowHTMLEmail ) {
687            return $this->userOptionsLookup->getOption( $this->mUser, 'echo-email-format' );
688        }
689
690        return EmailFormat::PLAIN_TEXT;
691    }
692
693    /**
694     * Build a cache key for local use (local to this wiki)
695     *
696     * @param string $key Key, typically prefixed with echo-notification-
697     * @return string Cache key
698     */
699    protected function getMemcKey( $key ) {
700        global $wgEchoCacheVersion;
701        return $this->cache->makeKey( $key, $this->mUser->getId(), $wgEchoCacheVersion );
702    }
703
704    /**
705     * Build a cache key for global use
706     *
707     * @param string $key Key, typically prefixed with echo-notification-
708     * @return string|false Memcached key, or false if one could not be generated
709     */
710    protected function getGlobalMemcKey( $key ) {
711        global $wgEchoCacheVersion;
712        $globalId = MediaWikiServices::getInstance()
713            ->getCentralIdLookup()
714            ->centralIdFromLocalUser( $this->mUser, CentralIdLookup::AUDIENCE_RAW );
715        if ( !$globalId ) {
716            return false;
717        }
718        return $this->cache->makeGlobalKey( $key, $globalId, $wgEchoCacheVersion );
719    }
720
721    /**
722     * Lazy-construct an ForeignNotifications instance. This instance is force-enabled, so it
723     * returns information about cross-wiki notifications even if the user has them disabled.
724     * @return ForeignNotifications
725     */
726    protected function getForeignNotifications() {
727        if ( !$this->foreignNotifications ) {
728            $this->foreignNotifications = new ForeignNotifications( $this->mUser, true );
729        }
730        return $this->foreignNotifications;
731    }
732
733    /**
734     * Get the number of foreign notifications in a given section.
735     * @param string $section One of AttributeManager::$sections
736     * @return int Number of foreign notifications
737     */
738    protected function getForeignCount( $section = AttributeManager::ALL ) {
739        return self::capNotificationCount(
740            $this->getForeignNotifications()->getCount( $section )
741        );
742    }
743
744    /**
745     * Get the timestamp of the most recent foreign notification in a given section.
746     * @param string $section One of AttributeManager::$sections
747     * @return MWTimestamp|false Timestamp of the most recent foreign notification, or false if
748     *  there aren't any
749     */
750    protected function getForeignTimestamp( $section = AttributeManager::ALL ) {
751        return $this->getForeignNotifications()->getTimestamp( $section );
752    }
753
754    /**
755     * Helper function to produce the capped number of notifications
756     * based on the value of NotifUser::MAX_BADGE_COUNT
757     *
758     * @param int $number Raw notification count to cap
759     * @return int Capped notification count
760     */
761    public static function capNotificationCount( $number ) {
762        return min( $number, self::MAX_BADGE_COUNT + 1 );
763    }
764}