Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
13.45% covered (danger)
13.45%
92 / 684
0.00% covered (danger)
0.00%
0 / 35
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
13.45% covered (danger)
13.45%
92 / 684
0.00% covered (danger)
0.00%
0 / 35
22133.90
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 onUserGetDefaultOptions
79.59% covered (warning)
79.59%
39 / 49
0.00% covered (danger)
0.00%
0 / 1
8.54
 initEchoExtension
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 onResourceLoaderRegisterModules
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 onGetPreferences
0.00% covered (danger)
0.00%
0 / 133
0.00% covered (danger)
0.00%
0 / 1
702
 onPreferencesGetIcon
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isEmailChangeAllowed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onPageSaveComplete
50.85% covered (warning)
50.85%
30 / 59
0.00% covered (danger)
0.00%
0 / 1
33.07
 getEditCount
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 onLocalUserCreated
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 onUserGroupsChanged
60.53% covered (warning)
60.53%
23 / 38
0.00% covered (danger)
0.00%
0 / 1
13.98
 onLinksUpdateComplete
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
210
 onBeforePageDisplay
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 processMarkAsRead
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
210
 shouldDisplayTalkAlert
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 onSkinTemplateNavigation__Universal
0.00% covered (danger)
0.00%
0 / 102
0.00% covered (danger)
0.00%
0 / 1
462
 onAbortTalkPageEmailNotification
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 onSendWatchlistEmailNotification
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
90
 onOutputPageCheckLastModified
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 onGetNewMessagesAlert
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 onRollbackComplete
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 onUserSaveSettings
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getVirtualUserOptions
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 onLoadUserOptions
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 onSaveUserOptions
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 mapToInt
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 onUserClearNewTalkNotification
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onEmailUserComplete
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 onLoginFormValidErrorMessages
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getConfigVars
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 onArticleDeleteComplete
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 onArticleUndelete
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 onSpecialMuteModifyFormFields
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 onRecentChange_save
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 onApiMain__ModuleManager
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\Notifications;
4
5use ApiModuleManager;
6use Content;
7use EchoAttributeManager;
8use EchoUserLocator;
9use EmailNotification;
10use ExtensionRegistry;
11use HTMLCheckMatrix;
12use IBufferingStatsdDataFactory;
13use Language;
14use LogEntry;
15use LogicException;
16use MailAddress;
17use MediaWiki\Api\Hook\ApiMain__moduleManagerHook;
18use MediaWiki\Auth\AuthManager;
19use MediaWiki\Auth\Hook\LocalUserCreatedHook;
20use MediaWiki\Config\Config;
21use MediaWiki\DAO\WikiAwareEntity;
22use MediaWiki\Deferred\DeferredUpdates;
23use MediaWiki\Deferred\LinksUpdate\LinksUpdate;
24use MediaWiki\Extension\Notifications\Controller\ModerationController;
25use MediaWiki\Extension\Notifications\Controller\NotificationController;
26use MediaWiki\Extension\Notifications\Formatters\EchoEventPresentationModel;
27use MediaWiki\Extension\Notifications\Hooks\HookRunner;
28use MediaWiki\Extension\Notifications\Mapper\EventMapper;
29use MediaWiki\Extension\Notifications\Mapper\NotificationMapper;
30use MediaWiki\Extension\Notifications\Model\Event;
31use MediaWiki\Extension\Notifications\Model\Notification;
32use MediaWiki\Extension\Notifications\Push\Api\ApiEchoPushSubscriptions;
33use MediaWiki\Hook\AbortTalkPageEmailNotificationHook;
34use MediaWiki\Hook\BeforePageDisplayHook;
35use MediaWiki\Hook\EmailUserCompleteHook;
36use MediaWiki\Hook\GetNewMessagesAlertHook;
37use MediaWiki\Hook\LinksUpdateCompleteHook;
38use MediaWiki\Hook\LoginFormValidErrorMessagesHook;
39use MediaWiki\Hook\OutputPageCheckLastModifiedHook;
40use MediaWiki\Hook\PreferencesGetIconHook;
41use MediaWiki\Hook\RecentChange_saveHook;
42use MediaWiki\Hook\SendWatchlistEmailNotificationHook;
43use MediaWiki\Hook\SkinTemplateNavigation__UniversalHook;
44use MediaWiki\Hook\SpecialMuteModifyFormFieldsHook;
45use MediaWiki\HookContainer\HookContainer;
46use MediaWiki\Linker\LinkRenderer;
47use MediaWiki\Logger\LoggerFactory;
48use MediaWiki\MainConfigNames;
49use MediaWiki\MediaWikiServices;
50use MediaWiki\Output\OutputPage;
51use MediaWiki\Page\Hook\ArticleDeleteCompleteHook;
52use MediaWiki\Page\Hook\ArticleUndeleteHook;
53use MediaWiki\Page\Hook\RollbackCompleteHook;
54use MediaWiki\Permissions\PermissionManager;
55use MediaWiki\Preferences\Hook\GetPreferencesHook;
56use MediaWiki\Preferences\MultiTitleFilter;
57use MediaWiki\Preferences\MultiUsernameFilter;
58use MediaWiki\Request\WebRequest;
59use MediaWiki\ResourceLoader as RL;
60use MediaWiki\ResourceLoader\Hook\ResourceLoaderRegisterModulesHook;
61use MediaWiki\ResourceLoader\ResourceLoader;
62use MediaWiki\Revision\RevisionRecord;
63use MediaWiki\Revision\RevisionStore;
64use MediaWiki\SpecialPage\SpecialPage;
65use MediaWiki\Storage\EditResult;
66use MediaWiki\Storage\Hook\PageSaveCompleteHook;
67use MediaWiki\Title\NamespaceInfo;
68use MediaWiki\Title\Title;
69use MediaWiki\User\CentralId\CentralIdLookup;
70use MediaWiki\User\Hook\UserClearNewTalkNotificationHook;
71use MediaWiki\User\Hook\UserGetDefaultOptionsHook;
72use MediaWiki\User\Hook\UserGroupsChangedHook;
73use MediaWiki\User\Hook\UserSaveSettingsHook;
74use MediaWiki\User\Options\Hook\LoadUserOptionsHook;
75use MediaWiki\User\Options\Hook\SaveUserOptionsHook;
76use MediaWiki\User\Options\UserOptionsManager;
77use MediaWiki\User\TalkPageNotificationManager;
78use MediaWiki\User\User;
79use MediaWiki\User\UserEditTracker;
80use MediaWiki\User\UserFactory;
81use MediaWiki\User\UserIdentity;
82use MediaWiki\WikiMap\WikiMap;
83use RecentChange;
84use Skin;
85use SkinTemplate;
86use WikiPage;
87
88class Hooks implements
89    AbortTalkPageEmailNotificationHook,
90    ApiMain__moduleManagerHook,
91    ArticleDeleteCompleteHook,
92    ArticleUndeleteHook,
93    BeforePageDisplayHook,
94    EmailUserCompleteHook,
95    GetNewMessagesAlertHook,
96    GetPreferencesHook,
97    LinksUpdateCompleteHook,
98    LoadUserOptionsHook,
99    LocalUserCreatedHook,
100    LoginFormValidErrorMessagesHook,
101    OutputPageCheckLastModifiedHook,
102    PageSaveCompleteHook,
103    PreferencesGetIconHook,
104    RecentChange_saveHook,
105    ResourceLoaderRegisterModulesHook,
106    RollbackCompleteHook,
107    SaveUserOptionsHook,
108    SendWatchlistEmailNotificationHook,
109    SkinTemplateNavigation__UniversalHook,
110    UserClearNewTalkNotificationHook,
111    UserGetDefaultOptionsHook,
112    UserGroupsChangedHook,
113    UserSaveSettingsHook,
114    SpecialMuteModifyFormFieldsHook
115{
116    private AuthManager $authManager;
117    private CentralIdLookup $centralIdLookup;
118    private Config $config;
119    private EchoAttributeManager $attributeManager;
120    private HookContainer $hookContainer;
121    private Language $contentLanguage;
122    private LinkRenderer $linkRenderer;
123    private NamespaceInfo $namespaceInfo;
124    private PermissionManager $permissionManager;
125    private RevisionStore $revisionStore;
126    private IBufferingStatsdDataFactory $statsdDataFactory;
127    private TalkPageNotificationManager $talkPageNotificationManager;
128    private UserEditTracker $userEditTracker;
129    private UserFactory $userFactory;
130    private UserOptionsManager $userOptionsManager;
131
132    private static array $revertedRevIds = [];
133
134    public function __construct(
135        AuthManager $authManager,
136        CentralIdLookup $centralIdLookup,
137        Config $config,
138        EchoAttributeManager $attributeManager,
139        HookContainer $hookContainer,
140        Language $contentLanguage,
141        LinkRenderer $linkRenderer,
142        NamespaceInfo $namespaceInfo,
143        PermissionManager $permissionManager,
144        RevisionStore $revisionStore,
145        IBufferingStatsdDataFactory $statsdDataFactory,
146        TalkPageNotificationManager $talkPageNotificationManager,
147        UserEditTracker $userEditTracker,
148        UserFactory $userFactory,
149        UserOptionsManager $userOptionsManager
150    ) {
151        $this->authManager = $authManager;
152        $this->centralIdLookup = $centralIdLookup;
153        $this->config = $config;
154        $this->attributeManager = $attributeManager;
155        $this->hookContainer = $hookContainer;
156        $this->contentLanguage = $contentLanguage;
157        $this->linkRenderer = $linkRenderer;
158        $this->namespaceInfo = $namespaceInfo;
159        $this->permissionManager = $permissionManager;
160        $this->revisionStore = $revisionStore;
161        $this->statsdDataFactory = $statsdDataFactory;
162        $this->talkPageNotificationManager = $talkPageNotificationManager;
163        $this->userEditTracker = $userEditTracker;
164        $this->userFactory = $userFactory;
165        $this->userOptionsManager = $userOptionsManager;
166    }
167
168    /**
169     * @param array &$defaults
170     */
171    public function onUserGetDefaultOptions( &$defaults ) {
172        if ( $this->config->get( MainConfigNames::AllowHTMLEmail ) ) {
173            $defaults['echo-email-format'] = 'html';
174        } else {
175            $defaults['echo-email-format'] = 'plain-text';
176        }
177
178        $presets = [
179            // Set all of the events to notify by web but not email by default
180            // (won't affect events that don't email)
181            'default' => [
182                'email' => false,
183                'web' => true,
184            ],
185            // most settings default to web on, email off, but override these
186            'system' => [
187                'email' => true,
188            ],
189            'user-rights' => [
190                'email' => true,
191            ],
192            'article-linked' => [
193                'web' => false,
194            ],
195            'mention-failure' => [
196                'web' => false,
197            ],
198            'mention-success' => [
199                'web' => false,
200            ],
201            'watchlist' => [
202                'web' => false,
203            ],
204            'minor-watchlist' => [
205                'web' => false,
206            ],
207        ];
208
209        $echoPushEnabled = $this->config->get( ConfigNames::EnablePush );
210        if ( $echoPushEnabled ) {
211            $presets['default']['push'] = true;
212            $presets['article-linked']['push'] = false;
213            $presets['mention-failure']['push'] = false;
214            $presets['mention-success']['push'] = false;
215            $presets['watchlist']['push'] = false;
216            $presets['minor-watchlist']['push'] = false;
217        }
218
219        foreach ( $this->config->get( ConfigNames::NotificationCategories ) as $category => $categoryData ) {
220            if ( !isset( $defaults["echo-subscriptions-email-{$category}"] ) ) {
221                $defaults["echo-subscriptions-email-{$category}"] = $presets[$category]['email']
222                    ?? $presets['default']['email'];
223            }
224            if ( !isset( $defaults["echo-subscriptions-web-{$category}"] ) ) {
225                $defaults["echo-subscriptions-web-{$category}"] = $presets[$category]['web']
226                    ?? $presets['default']['web'];
227            }
228            if ( $echoPushEnabled && !isset( $defaults["echo-subscriptions-push-{$category}"] ) ) {
229                $defaults["echo-subscriptions-push-{$category}"] = $presets[$category]['push']
230                    // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
231                    ?? $presets['default']['push'];
232            }
233        }
234    }
235
236    /**
237     * Initialize Echo extension with necessary data, this function is invoked
238     * from $wgExtensionFunctions
239     */
240    public static function initEchoExtension() {
241        global $wgEchoNotifications, $wgEchoNotificationCategories, $wgEchoNotificationIcons,
242            $wgEchoMentionStatusNotifications, $wgAllowArticleReminderNotification, $wgAPIModules,
243            $wgEchoWatchlistNotifications, $wgEchoSeenTimeCacheType, $wgMainStash, $wgEnableEmail,
244            $wgEnableUserEmail;
245
246        // allow extensions to define their own event
247        ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )->onBeforeCreateEchoEvent(
248            $wgEchoNotifications, $wgEchoNotificationCategories, $wgEchoNotificationIcons );
249
250        // Only allow mention status notifications when enabled
251        if ( !$wgEchoMentionStatusNotifications ) {
252            unset( $wgEchoNotificationCategories['mention-failure'] );
253            unset( $wgEchoNotificationCategories['mention-success'] );
254        }
255
256        // Only allow article reminder notifications when enabled
257        if ( !$wgAllowArticleReminderNotification ) {
258            unset( $wgEchoNotificationCategories['article-reminder'] );
259            unset( $wgAPIModules['echoarticlereminder'] );
260        }
261
262        // Only allow watchlist notifications when enabled
263        if ( !$wgEchoWatchlistNotifications ) {
264            unset( $wgEchoNotificationCategories['watchlist'] );
265            unset( $wgEchoNotificationCategories['minor-watchlist'] );
266        }
267
268        // Only allow user email notifications when enabled
269        if ( !$wgEnableEmail || !$wgEnableUserEmail ) {
270            unset( $wgEchoNotificationCategories['emailuser'] );
271        }
272
273        // Default $wgEchoSeenTimeCacheType to $wgMainStash
274        if ( $wgEchoSeenTimeCacheType === null ) {
275            $wgEchoSeenTimeCacheType = $wgMainStash;
276        }
277    }
278
279    /**
280     * Handler for ResourceLoaderRegisterModules hook
281     * @param ResourceLoader $resourceLoader
282     */
283    public function onResourceLoaderRegisterModules( ResourceLoader $resourceLoader ): void {
284        $resourceLoader->register( 'ext.echo.emailicons', [
285            'class' => ResourceLoaderEchoImageModule::class,
286            'icons' => $this->config->get( ConfigNames::NotificationIcons ),
287            'selector' => '.mw-echo-icon-{name}',
288            'localBasePath' => $this->config->get( MainConfigNames::ExtensionDirectory ),
289            'remoteExtPath' => 'Echo/modules'
290        ] );
291        $resourceLoader->register( 'ext.echo.secondaryicons', [
292            'class' => ResourceLoaderEchoImageModule::class,
293            'icons' => $this->config->get( ConfigNames::SecondaryIcons ),
294            'selector' => '.mw-echo-icon-{name}',
295            'localBasePath' => $this->config->get( MainConfigNames::ExtensionDirectory ),
296            'remoteExtPath' => 'Echo/modules'
297        ] );
298    }
299
300    /**
301     * Handler for GetPreferences hook.
302     * @see https://www.mediawiki.org/wiki/Manual:Hooks/GetPreferences
303     *
304     * @param User $user User to get preferences for
305     * @param array &$preferences Preferences array
306     */
307    public function onGetPreferences( $user, &$preferences ) {
308        // The following messages are generated upstrem:
309        // * prefs-echo
310        // * prefs-description-echo
311
312        // Show email frequency options
313        $freqOptions = [
314            'echo-pref-email-frequency-never' => EmailFrequency::NEVER,
315            'echo-pref-email-frequency-immediately' => EmailFrequency::IMMEDIATELY,
316        ];
317        // Only show digest options if email batch is enabled
318        if ( $this->config->get( ConfigNames::EnableEmailBatch ) ) {
319            $freqOptions += [
320                'echo-pref-email-frequency-daily' => EmailFrequency::DAILY_DIGEST,
321                'echo-pref-email-frequency-weekly' => EmailFrequency::WEEKLY_DIGEST,
322            ];
323        }
324        $preferences['echo-email-frequency'] = [
325            'type' => 'select',
326            'label-message' => 'echo-pref-send-me',
327            // The following message is generated upstrem:
328            // * prefs-emailsettings
329            'section' => 'echo/emailsettings',
330            'options-messages' => $freqOptions
331        ];
332
333        $preferences['echo-dont-email-read-notifications'] = [
334            'type' => 'toggle',
335            'label-message' => 'echo-pref-dont-email-read-notifications',
336            // The following message is generated upstrem:
337            // * prefs-emailsettings
338            'section' => 'echo/emailsettings',
339            'hide-if' => [ 'OR', [ '===', 'echo-email-frequency', '-1' ], [ '===', 'echo-email-frequency', '0' ] ]
340        ];
341
342        // Display information about the user's currently set email address
343        $prefsTitle = SpecialPage::getTitleFor( 'Preferences', false, 'mw-prefsection-echo' );
344        $link = $this->linkRenderer->makeLink(
345            SpecialPage::getTitleFor( 'ChangeEmail' ),
346            wfMessage( $user->getEmail() ? 'prefs-changeemail' : 'prefs-setemail' )->text(),
347            [],
348            [ 'returnto' => $prefsTitle->getFullText() ]
349        );
350        $emailAddress = $user->getEmail() && $this->permissionManager->userHasRight( $user, 'viewmyprivateinfo' )
351            ? htmlspecialchars( $user->getEmail() ) : '';
352        if ( $this->permissionManager->userHasRight( $user, 'editmyprivateinfo' ) && $this->isEmailChangeAllowed() ) {
353            if ( $emailAddress === '' ) {
354                $emailAddress .= $link;
355            } else {
356                $emailAddress .= wfMessage( 'word-separator' )->escaped()
357                    . wfMessage( 'parentheses' )->rawParams( $link )->escaped();
358            }
359        }
360        $preferences['echo-emailaddress'] = [
361            'type' => 'info',
362            'raw' => true,
363            'default' => $emailAddress,
364            'label-message' => 'echo-pref-send-to',
365            // The following message is generated upstrem:
366            // * prefs-emailsettings
367            'section' => 'echo/emailsettings'
368        ];
369
370        // Only show this option if html email is allowed, otherwise it is always plain text format
371        if ( $this->config->get( MainConfigNames::AllowHTMLEmail ) ) {
372            // Email format
373            $preferences['echo-email-format'] = [
374                'type' => 'select',
375                'label-message' => 'echo-pref-email-format',
376                // The following message is generated upstrem:
377                // * prefs-emailsettings
378                'section' => 'echo/emailsettings',
379                'options-messages' => [
380                    'echo-pref-email-format-html' => EmailFormat::HTML,
381                    'echo-pref-email-format-plain-text' => EmailFormat::PLAIN_TEXT,
382                ]
383            ];
384        }
385
386        // Sort notification categories by priority
387        $categoriesAndPriorities = [];
388        foreach ( $this->attributeManager->getInternalCategoryNames() as $category ) {
389            // See if the category should be hidden from preferences.
390            if ( !$this->attributeManager->isCategoryDisplayedInPreferences( $category ) ) {
391                continue;
392            }
393
394            // See if user is eligible to receive this notification (per user group restrictions)
395            if ( $this->attributeManager->getCategoryEligibility( $user, $category ) ) {
396                $categoriesAndPriorities[$category] = $this->attributeManager->getCategoryPriority( $category );
397            }
398        }
399        asort( $categoriesAndPriorities );
400        $validSortedCategories = array_keys( $categoriesAndPriorities );
401
402        // Show subscription options.  IMPORTANT: 'echo-subscriptions-email-edit-user-talk',
403        // 'echo-subscriptions-email-watchlist', and 'echo-subscriptions-email-minor-watchlist' are
404        // virtual options, their values are saved to existing notification options 'enotifusertalkpages',
405        // 'enotifwatchlistpages', and 'enotifminoredits', see onLoadUserOptions() and onSaveUserOptions()
406        // for more information on how it is handled. Doing it in this way, we can avoid keeping running
407        // massive data migration script to keep these two options synced when echo is enabled on
408        // new wikis or Echo is disabled and re-enabled for some reason.  We can update the name
409        // if Echo is ever merged to core
410
411        // Build the columns (notify types)
412        $columns = [];
413        foreach ( $this->config->get( ConfigNames::Notifiers ) as $notifierType => $notifierData ) {
414            // The following messages are generated here
415            // * echo-pref-web
416            // * echo-pref-email
417            // * echo-pref-push
418            $formatMessage = wfMessage( 'echo-pref-' . $notifierType )->escaped();
419            $columns[$formatMessage] = $notifierType;
420        }
421
422        // Build the rows (notification categories)
423        $rows = [];
424        $tooltips = [];
425        $notificationCategories = $this->config->get( ConfigNames::NotificationCategories );
426        foreach ( $validSortedCategories as $category ) {
427            $categoryMessage = wfMessage( 'echo-category-title-' . $category )->numParams( 1 )->escaped();
428            $rows[$categoryMessage] = $category;
429            if ( isset( $notificationCategories[$category]['tooltip'] ) ) {
430                $tooltips[$categoryMessage] = wfMessage( $notificationCategories[$category]['tooltip'] )->text();
431            }
432        }
433
434        // Figure out the individual exceptions in the matrix and make them disabled
435        $forceOptionsOff = $forceOptionsOn = [];
436        foreach ( $this->config->get( ConfigNames::Notifiers ) as $notifierType => $notifierData ) {
437            foreach ( $validSortedCategories as $category ) {
438                // See if this notify type is non-dismissable
439                if ( !$this->attributeManager->isNotifyTypeDismissableForCategory( $category, $notifierType ) ) {
440                    $forceOptionsOn[] = "$notifierType-$category";
441                }
442
443                if ( !$this->attributeManager->isNotifyTypeAvailableForCategory( $category, $notifierType ) ) {
444                    $forceOptionsOff[] = "$notifierType-$category";
445                }
446            }
447        }
448
449        $invalid = array_intersect( $forceOptionsOff, $forceOptionsOn );
450        if ( $invalid ) {
451            throw new LogicException( sprintf(
452                'The following notifications are both forced and removed: %s',
453                implode( ', ', $invalid )
454            ) );
455        }
456        $preferences['echo-subscriptions'] = [
457            'class' => HTMLCheckMatrix::class,
458            // The following message is generated upstrem:
459            // * prefs-echosubscriptions
460            'section' => 'echo/echosubscriptions',
461            'rows' => $rows,
462            'columns' => $columns,
463            'prefix' => 'echo-subscriptions-',
464            'force-options-off' => $forceOptionsOff,
465            'force-options-on' => $forceOptionsOn,
466            'tooltips' => $tooltips,
467        ];
468
469        if ( $this->config->get( ConfigNames::CrossWikiNotifications ) ) {
470            $preferences['echo-cross-wiki-notifications'] = [
471                'type' => 'toggle',
472                'label-message' => 'echo-pref-cross-wiki-notifications',
473                // The following message is generated upstrem:
474                // * prefs-echocrosswiki
475                'section' => 'echo/echocrosswiki'
476            ];
477        }
478
479        if ( $this->config->get( ConfigNames::PollForUpdates ) ) {
480            $preferences['echo-show-poll-updates'] = [
481                'type' => 'toggle',
482                'label-message' => 'echo-pref-show-poll-updates',
483                'help-message' => 'echo-pref-show-poll-updates-help',
484                // The following message is generated upstrem:
485                // * prefs-echopollupdates
486                'section' => 'echo/echopollupdates'
487            ];
488        }
489
490        // If we're using Echo to handle user talk page post or watchlist notifications,
491        // hide the old (non-Echo) preferences for them. If Echo is moved to core
492        // we'll want to remove the old user options entirely. For now, though,
493        // we need to keep it defined in case Echo is ever uninstalled.
494        // Otherwise, that preference could be lost entirely. This hiding logic
495        // is not abstracted since there are only three preferences in core
496        // that are potentially made obsolete by Echo.
497        $notifications = $this->config->get( ConfigNames::Notifications );
498        if ( isset( $notifications['edit-user-talk'] ) ) {
499            $preferences['enotifusertalkpages']['type'] = 'hidden';
500            unset( $preferences['enotifusertalkpages']['section'] );
501        }
502        if ( $this->config->get( ConfigNames::WatchlistNotifications ) &&
503            isset( $notifications['watchlist-change'] )
504        ) {
505            $preferences['enotifwatchlistpages']['type'] = 'hidden';
506            unset( $preferences['enotifusertalkpages']['section'] );
507            $preferences['enotifminoredits']['type'] = 'hidden';
508            unset( $preferences['enotifminoredits']['section'] );
509        }
510
511        if ( $this->config->get( ConfigNames::PerUserBlacklist ) ) {
512            $preferences['echo-notifications-blacklist'] = [
513                'type' => 'usersmultiselect',
514                'label-message' => 'echo-pref-notifications-blacklist',
515                // The following message is generated upstrem:
516                // * prefs-blocknotificationslist
517                'section' => 'echo/blocknotificationslist',
518                'filter' => MultiUsernameFilter::class,
519            ];
520            $preferences['echo-notifications-page-linked-title-muted-list'] = [
521                'type' => 'titlesmultiselect',
522                'label-message' => 'echo-pref-notifications-page-linked-title-muted-list',
523                // The following message is generated upstrem:
524                // * prefs-mutedpageslist
525                'section' => 'echo/mutedpageslist',
526                'showMissing' => false,
527                'excludeDynamicNamespaces' => true,
528                'filter' => new MultiTitleFilter()
529            ];
530        }
531    }
532
533    /**
534     * Add icon for Special:Preferences mobile layout
535     *
536     * @param array &$iconNames Array of icon names for their respective sections.
537     */
538    public function onPreferencesGetIcon( &$iconNames ) {
539        $iconNames[ 'echo' ] = 'bell';
540    }
541
542    /**
543     * Test whether email address change is supposed to be allowed
544     * @return bool
545     */
546    private function isEmailChangeAllowed() {
547        return $this->authManager->allowsPropertyChange( 'emailaddress' );
548    }
549
550    /**
551     * Handler for PageSaveComplete hook
552     * @see https://www.mediawiki.org/wiki/Manual:Hooks/PageSaveComplete
553     *
554     * @param WikiPage $wikiPage modified WikiPage
555     * @param UserIdentity $userIdentity User who edited
556     * @param string $summary Edit summary
557     * @param int $flags Edit flags
558     * @param RevisionRecord $revisionRecord RevisionRecord for the revision that was created
559     * @param EditResult $editResult
560     */
561    public function onPageSaveComplete(
562        $wikiPage,
563        $userIdentity,
564        $summary,
565        $flags,
566        $revisionRecord,
567        $editResult
568    ) {
569        if ( $editResult->isNullEdit() ) {
570            return;
571        }
572
573        $title = $wikiPage->getTitle();
574        $isRevert = $editResult->getRevertMethod() === EditResult::REVERT_UNDO ||
575            $editResult->getRevertMethod() === EditResult::REVERT_ROLLBACK;
576
577        // Save the revert status for the LinksUpdateComplete hook
578        if ( $isRevert ) {
579            self::$revertedRevIds[$revisionRecord->getId()] = true;
580        }
581
582        // Try to do this after the HTTP response
583        DeferredUpdates::addCallableUpdate( static function () use ( $revisionRecord, $isRevert ) {
584            DiscussionParser::generateEventsForRevision( $revisionRecord, $isRevert );
585        } );
586
587        // If the user is not an IP and this is not a null edit,
588        // test for them reaching a congratulatory threshold
589        $thresholds = [ 1, 10, 100, 1000, 10000, 100000, 1000000, 10000000 ];
590        if ( $userIdentity->isRegistered() ) {
591            $thresholdCount = $this->getEditCount( $userIdentity );
592            if ( in_array( $thresholdCount, $thresholds ) ) {
593                DeferredUpdates::addCallableUpdate( static function () use (
594                    $revisionRecord, $userIdentity, $title, $thresholdCount
595                ) {
596                    $notificationMapper = new NotificationMapper();
597                    $notifications = $notificationMapper->fetchByUser( $userIdentity, 10, null, [ 'thank-you-edit' ] );
598                    /** @var Notification $notification */
599                    foreach ( $notifications as $notification ) {
600                        if ( $notification->getEvent()->getExtraParam( 'editCount' ) === $thresholdCount ) {
601                            LoggerFactory::getInstance( 'Echo' )->debug(
602                                '{user} (id: {id}) has already been thanked for their {count} edit',
603                                [
604                                    'user' => $userIdentity->getName(),
605                                    'id' => $userIdentity->getId(),
606                                    'count' => $thresholdCount,
607                                ]
608                            );
609                            return;
610                        }
611                    }
612
613                    Event::create( [
614                        'type' => 'thank-you-edit',
615                        'title' => $title,
616                        'agent' => $userIdentity,
617                        // Edit threshold notifications are sent to the agent
618                        'extra' => [
619                            'editCount' => $thresholdCount,
620                            'revid' => $revisionRecord->getId(),
621                        ]
622                    ] );
623                } );
624            }
625        }
626
627        // Handle the case of someone undoing an edit, either through the
628        // 'undo' link in the article history or via the API.
629        // Reverts through the 'rollback' link (EditResult::REVERT_ROLLBACK)
630        // are handled in ::onRollbackComplete().
631        if ( $editResult->getRevertMethod() === EditResult::REVERT_UNDO ) {
632            $undidRevId = $editResult->getUndidRevId();
633            $undidRevision = $this->revisionStore->getRevisionById( $undidRevId );
634            if (
635                $undidRevision &&
636                Title::newFromLinkTarget( $undidRevision->getPageAsLinkTarget() )->equals( $title )
637            ) {
638                $revertedUser = $undidRevision->getUser();
639                // No notifications for anonymous users
640                if ( $revertedUser && $revertedUser->getId() ) {
641                    Event::create( [
642                        'type' => 'reverted',
643                        'title' => $title,
644                        'extra' => [
645                            'revid' => $revisionRecord->getId(),
646                            'reverted-user-id' => $revertedUser->getId(),
647                            'reverted-revision-id' => $undidRevId,
648                            'method' => 'undo',
649                            'summary' => $summary,
650                        ],
651                        'agent' => $userIdentity,
652                    ] );
653                }
654            }
655        }
656    }
657
658    /**
659     * @param UserIdentity $user
660     * @return int
661     */
662    private function getEditCount( UserIdentity $user ) {
663        $editCount = $this->userEditTracker->getUserEditCount( $user ) ?: 0;
664        // When this code runs from a maintenance script or unit tests
665        // the deferred update incrementing edit count runs right away
666        // so the edit count is right. Otherwise it lags by one.
667        if ( wfIsCLI() ) {
668            return $editCount;
669        }
670        return $editCount + 1;
671    }
672
673    /**
674     * Handler for LocalUserCreated hook.
675     * @see https://www.mediawiki.org/wiki/Manual:Hooks/LocalUserCreated
676     * @param User $user User object that was created.
677     * @param bool $autocreated True when account was auto-created
678     */
679    public function onLocalUserCreated( $user, $autocreated ) {
680        if ( !$autocreated ) {
681            Event::create( [
682                'type' => 'welcome',
683                'agent' => $user,
684            ] );
685        }
686
687        $seenTime = SeenTime::newFromUser( $user );
688
689        // Set seen time to UNIX epoch, so initially all notifications are unseen.
690        $seenTime->setTime( wfTimestamp( TS_MW, 1 ), 'all' );
691    }
692
693    /**
694     * Handler for UserGroupsChanged hook.
695     * @see https://www.mediawiki.org/wiki/Manual:Hooks/UserGroupsChanged
696     *
697     * @param UserIdentity $userId user that was changed
698     * @param string[] $add strings corresponding to groups added
699     * @param string[] $remove strings corresponding to groups removed
700     * @param User|bool $performer
701     * @param string|bool $reason Reason given by the user changing the rights
702     * @param array $oldUGMs
703     * @param array $newUGMs
704     */
705    public function onUserGroupsChanged( $userId, $add, $remove, $performer, $reason, $oldUGMs, $newUGMs ) {
706        if ( !$performer ) {
707            // TODO: Implement support for autopromotion
708            return;
709        }
710
711        if ( $userId->getWikiId() !== WikiAwareEntity::LOCAL ) {
712            // TODO: Support external users
713            return;
714        }
715
716        $user = $this->userFactory->newFromUserIdentity( $userId );
717
718        if ( $user->equals( $performer ) ) {
719            // Don't notify for self changes
720            return;
721        }
722
723        // If any old groups are in $add, those groups are having their expiry
724        // changed, not actually being added
725        $expiryChanged = [];
726        $reallyAdded = [];
727        foreach ( $add as $group ) {
728            if ( isset( $oldUGMs[$group] ) ) {
729                $expiryChanged[] = $group;
730            } else {
731                $reallyAdded[] = $group;
732            }
733        }
734
735        if ( $expiryChanged ) {
736            // use a separate notification for these, so the notification text doesn't
737            // get too long
738            Event::create(
739                [
740                    'type' => 'user-rights',
741                    'extra' => [
742                        'user' => $user->getId(),
743                        'expiry-changed' => $expiryChanged,
744                        'reason' => $reason,
745                    ],
746                    'agent' => $performer,
747                ]
748            );
749        }
750
751        if ( $reallyAdded || $remove ) {
752            Event::create(
753                [
754                    'type' => 'user-rights',
755                    'extra' => [
756                        'user' => $user->getId(),
757                        'add' => $reallyAdded,
758                        'remove' => $remove,
759                        'reason' => $reason,
760                    ],
761                    'agent' => $performer,
762                ]
763            );
764        }
765    }
766
767    /**
768     * Handler for LinksUpdateComplete hook.
769     * @see https://www.mediawiki.org/wiki/Manual:Hooks/LinksUpdateComplete
770     * @param LinksUpdate $linksUpdate
771     * @param mixed $ticket
772     */
773    public function onLinksUpdateComplete( $linksUpdate, $ticket ) {
774        // Rollback or undo should not trigger link notification
775        if ( $linksUpdate->getRevisionRecord() ) {
776            $revId = $linksUpdate->getRevisionRecord()->getId();
777            if ( isset( self::$revertedRevIds[$revId] ) ) {
778                return;
779            }
780        }
781
782        // Handle only
783        // 1. content namespace pages &&
784        // 2. non-transcluding pages &&
785        // 3. non-redirect pages
786        if ( !$this->namespaceInfo->isContent( $linksUpdate->getTitle()->getNamespace() )
787            || !$linksUpdate->isRecursive() || $linksUpdate->getTitle()->isRedirect()
788        ) {
789            return;
790        }
791
792        $revRecord = $linksUpdate->getRevisionRecord();
793        $revid = $revRecord ? $revRecord->getId() : null;
794        $user = $revRecord ? $revRecord->getUser() : null;
795
796        // link notification is boundless as you can include infinite number of links in a page
797        // db insert is expensive, limit it to a reasonable amount, we can increase this limit
798        // once the storage is on Redis
799        $max = 10;
800        // Only create notifications for links to content namespace pages
801        // @Todo - use one big insert instead of individual insert inside foreach loop
802        foreach ( $linksUpdate->getAddedLinks() as $title ) {
803            if ( $this->namespaceInfo->isContent( $title->getNamespace() ) ) {
804                if ( $title->isRedirect() ) {
805                    continue;
806                }
807
808                $linkFromPageId = $linksUpdate->getTitle()->getArticleID();
809                // T318523: Don't send page-linked notifications for pages created by bot users.
810                $articleAuthor = EchoUserLocator::getArticleAuthorByArticleId( $title->getArticleID() );
811                if ( $articleAuthor && $articleAuthor->isBot() ) {
812                    continue;
813                }
814                Event::create( [
815                    'type' => 'page-linked',
816                    'title' => $title,
817                    'agent' => $user,
818                    'extra' => [
819                        'target-page' => $linkFromPageId,
820                        'link-from-page-id' => $linkFromPageId,
821                        'revid' => $revid,
822                    ]
823                ] );
824                $max--;
825            }
826            if ( $max < 0 ) {
827                break;
828            }
829        }
830    }
831
832    /**
833     * Handler for BeforePageDisplay hook.
834     * @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforePageDisplay
835     * @param OutputPage $out
836     * @param Skin $skin Skin being used.
837     */
838    public function onBeforePageDisplay( $out, $skin ): void {
839        $user = $out->getUser();
840
841        if ( !$user->isRegistered() ) {
842            if ( ExtensionRegistry::getInstance()->isLoaded( 'CentralAuth' ) ) {
843                $out->addModules( [ 'ext.echo.centralauth' ] );
844            }
845            return;
846        }
847
848        if ( $this->shouldDisplayTalkAlert( $user, $out->getTitle() ) ) {
849            // Load the module for the Orange alert
850            $out->addModuleStyles( 'ext.echo.styles.alert' );
851        }
852
853        // Load the module for the Notifications flyout
854        $out->addModules( [ 'ext.echo.init' ] );
855        // Load the styles for the Notifications badge
856        $out->addModuleStyles( [
857            'ext.echo.styles.badge',
858            'oojs-ui.styles.icons-alerts'
859        ] );
860    }
861
862    private function processMarkAsRead( User $user, WebRequest $request, Title $title ) {
863        $subtractions = [
864            AttributeManager::ALERT => 0,
865            AttributeManager::MESSAGE => 0
866        ];
867
868        // Attempt to mark a notification as read when visiting a page
869        $eventIds = [];
870        if ( $title->getArticleID() ) {
871            $eventMapper = new EventMapper();
872            $events = $eventMapper->fetchUnreadByUserAndPage( $user, $title->getArticleID() );
873
874            foreach ( $events as $event ) {
875                $subtractions[$event->getSection()]++;
876                $eventIds[] = $event->getId();
877            }
878        }
879
880        // Attempt to mark as read the event IDs in the ?markasread= parameter, if present
881        $markAsReadIds = array_filter( explode( '|', $request->getText( 'markasread' ) ) );
882        $markAsReadWiki = $request->getText( 'markasreadwiki', WikiMap::getCurrentWikiId() );
883        $markAsReadLocal = !$this->config->get( ConfigNames::CrossWikiNotifications ) ||
884            $markAsReadWiki === WikiMap::getCurrentWikiId();
885        if ( $markAsReadIds ) {
886            if ( $markAsReadLocal ) {
887                // gather the IDs that we didn't already find with target_pages
888                $eventsToMarkAsRead = [];
889                foreach ( $markAsReadIds as $markAsReadId ) {
890                    $markAsReadId = intval( $markAsReadId );
891                    if ( $markAsReadId !== 0 && !in_array( $markAsReadId, $eventIds ) ) {
892                        $eventsToMarkAsRead[] = $markAsReadId;
893                    }
894                }
895
896                if ( $eventsToMarkAsRead ) {
897                    // fetch the notifications to adjust the counters
898                    $notifMapper = new NotificationMapper();
899                    $notifs = $notifMapper->fetchByUserEvents( $user, $eventsToMarkAsRead );
900
901                    foreach ( $notifs as $notif ) {
902                        if ( !$notif->getReadTimestamp() ) {
903                            $subtractions[$notif->getEvent()->getSection()]++;
904                            $eventIds[] = intval( $notif->getEvent()->getId() );
905                        }
906                    }
907                }
908            } else {
909                $markAsReadIds = array_map( 'intval', $markAsReadIds );
910                // Look up the notifications on the foreign wiki
911                $notifUser = NotifUser::newFromUser( $user );
912                $notifInfo = $notifUser->getForeignNotificationInfo( $markAsReadIds, $markAsReadWiki, $request );
913                foreach ( $notifInfo as $id => $info ) {
914                    $subtractions[$info['section']]++;
915                }
916
917                // Schedule a deferred update to mark these notifications as read on the foreign wiki
918                DeferredUpdates::addCallableUpdate(
919                    static function () use ( $user, $markAsReadIds, $markAsReadWiki, $request ) {
920                        $notifUser = NotifUser::newFromUser( $user );
921                        $notifUser->markReadForeign( $markAsReadIds, $markAsReadWiki, $request );
922                    }
923                );
924            }
925        }
926
927        // Schedule a deferred update to mark local target_page and ?markasread= notifications as read
928        if ( $eventIds ) {
929            DeferredUpdates::addCallableUpdate( static function () use ( $user, $eventIds ) {
930                $notifUser = NotifUser::newFromUser( $user );
931                $notifUser->markRead( $eventIds );
932            } );
933        }
934
935        return $subtractions;
936    }
937
938    /**
939     * Determine if a talk page alert should be displayed.
940     * We need to check:
941     * - User actually has new messages
942     * - User is not viewing their user talk page, as user_newtalk will not have been cleared yet.
943     *   (bug T107655).
944     *
945     * @param User $user
946     * @param Title $title
947     * @return bool
948     */
949    private function shouldDisplayTalkAlert( $user, $title ) {
950        $userHasNewMessages = $this->talkPageNotificationManager->userHasNewMessages( $user );
951
952        return $userHasNewMessages && !$user->getTalkPage()->equals( $title );
953    }
954
955    /**
956     * Handler for SkinTemplateNavigation::Universal hook.
957     * Adds "Notifications" items to the notifications content navigation.
958     * SkinTemplate automatically merges these into the personal tools for older skins.
959     * @see https://www.mediawiki.org/wiki/Manual:Hooks/SkinTemplateNavigation::Universal
960     * @param SkinTemplate $skinTemplate
961     * @param array &$links Array of URLs to append to.
962     */
963    public function onSkinTemplateNavigation__Universal( $skinTemplate, &$links ): void {
964        $user = $skinTemplate->getUser();
965        if ( !$user->isRegistered() ) {
966            return;
967        }
968
969        $title = $skinTemplate->getTitle();
970        $out = $skinTemplate->getOutput();
971
972        $subtractions = $this->processMarkAsRead( $user, $out->getRequest(), $title );
973
974        // Add a "My notifications" item to personal URLs
975        $notifUser = NotifUser::newFromUser( $user );
976        $msgCount = $notifUser->getMessageCount() - $subtractions[AttributeManager::MESSAGE];
977        $alertCount = $notifUser->getAlertCount() - $subtractions[AttributeManager::ALERT];
978        // But make sure we never show a negative number (T130853)
979        $msgCount = max( 0, $msgCount );
980        $alertCount = max( 0, $alertCount );
981
982        $msgNotificationTimestamp = $notifUser->getLastUnreadMessageTime();
983        $alertNotificationTimestamp = $notifUser->getLastUnreadAlertTime();
984
985        $seenTime = SeenTime::newFromUser( $user );
986        if ( $title->isSpecial( 'Notifications' ) ) {
987            // If this is the Special:Notifications page, seenTime to now
988            $seenTime->setTime( wfTimestamp( TS_MW ), AttributeManager::ALL );
989        }
990        $seenAlertTime = $seenTime->getTime( 'alert', TS_ISO_8601 );
991        $seenMsgTime = $seenTime->getTime( 'message', TS_ISO_8601 );
992
993        $out->addJsConfigVars( 'wgEchoSeenTime', [
994            'alert' => $seenAlertTime,
995            'notice' => $seenMsgTime,
996        ] );
997
998        $msgFormattedCount = NotificationController::formatNotificationCount( $msgCount );
999        $alertFormattedCount = NotificationController::formatNotificationCount( $alertCount );
1000
1001        $url = SpecialPage::getTitleFor( 'Notifications' )->getLocalURL();
1002
1003        $skinName = strtolower( $skinTemplate->getSkinName() );
1004        $isMinervaSkin = $skinName === 'minerva';
1005        // HACK: inverted icons only work in the "MediaWiki" OOUI theme
1006        // Avoid flashes in skins that don't use it (T111821)
1007        $out::setupOOUI( $skinName, $out->getLanguage()->getDir() );
1008        $bellIconClass = $isMinervaSkin ? 'oo-ui-icon-bellOutline' : 'oo-ui-icon-bell';
1009
1010        $msgLinkClasses = [ "mw-echo-notifications-badge", "mw-echo-notification-badge-nojs", "oo-ui-icon-tray" ];
1011        $alertLinkClasses = [ "mw-echo-notifications-badge", "mw-echo-notification-badge-nojs", $bellIconClass ];
1012
1013        $hasUnseen = false;
1014        if (
1015            // no unread notifications
1016            $msgCount !== 0 &&
1017            // should already always be false if count === 0
1018            $msgNotificationTimestamp !== false &&
1019            // there are no unseen notifications
1020            ( $seenMsgTime === null ||
1021                $seenMsgTime < $msgNotificationTimestamp->getTimestamp( TS_ISO_8601 ) )
1022        ) {
1023            $msgLinkClasses[] = 'mw-echo-unseen-notifications';
1024            $hasUnseen = true;
1025        } elseif ( $msgCount === 0 ) {
1026            $msgLinkClasses[] = 'mw-echo-notifications-badge-all-read';
1027        }
1028
1029        if ( $msgCount > NotifUser::MAX_BADGE_COUNT ) {
1030            $msgLinkClasses[] = 'mw-echo-notifications-badge-long-label';
1031        }
1032
1033        if (
1034            // no unread notifications
1035            $alertCount !== 0 &&
1036            // should already always be false if count === 0
1037            $alertNotificationTimestamp !== false &&
1038            // all notifications have already been seen
1039            ( $seenAlertTime === null ||
1040                $seenAlertTime < $alertNotificationTimestamp->getTimestamp( TS_ISO_8601 ) )
1041        ) {
1042            $alertLinkClasses[] = 'mw-echo-unseen-notifications';
1043            $hasUnseen = true;
1044        } elseif ( $alertCount === 0 ) {
1045            $alertLinkClasses[] = 'mw-echo-notifications-badge-all-read';
1046        }
1047
1048        if ( $alertCount > NotifUser::MAX_BADGE_COUNT ) {
1049            $alertLinkClasses[] = 'mw-echo-notifications-badge-long-label';
1050        }
1051
1052        $mytalk = $links['user-menu']['mytalk'] ?? false;
1053        if (
1054            $mytalk &&
1055            $this->shouldDisplayTalkAlert( $user, $title ) &&
1056            ( new HookRunner( $this->hookContainer ) )->onBeforeDisplayOrangeAlert( $user, $title )
1057        ) {
1058            // Create new talk alert inheriting from the talk link data.
1059            $links['notifications']['talk-alert'] = array_merge(
1060                $links['user-menu']['mytalk'],
1061                [
1062                    // Hardcode id, which is needed to dismiss the talk alert notification
1063                    'id' => 'pt-talk-alert',
1064                    // If Vector hook ran anicon will have  been copied to the link class.
1065                    // We must reset it.
1066                    'link-class' => [],
1067                    'text' => $skinTemplate->msg( 'echo-new-messages' )->text(),
1068                    'class' => [ 'mw-echo-alert' ],
1069                    // unset icon
1070                    'icon' => null,
1071                ]
1072            );
1073
1074            // If there's exactly one new user talk message, then link directly to it from the alert.
1075            $notificationMapper = new NotificationMapper();
1076            $notifications = $notificationMapper->fetchUnreadByUser( $user, 2, null, [ 'edit-user-talk' ] );
1077            if ( count( $notifications ) === 1 ) {
1078                $presModel = EchoEventPresentationModel::factory(
1079                    current( $notifications )->getEvent(),
1080                    $out->getLanguage(),
1081                    $user
1082                );
1083                $links['notifications']['talk-alert']['href'] = $presModel->getPrimaryLink()['url'];
1084            }
1085        }
1086
1087        $links['notifications']['notifications-alert'] = [
1088            'href' => $url,
1089            'text' => $skinTemplate->msg( 'echo-notification-alert', $alertCount )->text(),
1090            'active' => ( $url == $title->getLocalURL() ),
1091            'link-class' => $alertLinkClasses,
1092            'icon' => 'bell',
1093            'data' => [
1094                'event-name' => 'ui.notifications',
1095                'counter-num' => $alertCount,
1096                'counter-text' => $alertFormattedCount,
1097            ],
1098            // This item used to be part of personal tools, and much CSS relies on it using this id.
1099            'id' => 'pt-notifications-alert',
1100        ];
1101
1102        $links['notifications']['notifications-notice'] = [
1103            'href' => $url,
1104            'text' => $skinTemplate->msg( 'echo-notification-notice', $msgCount )->text(),
1105            'active' => ( $url == $title->getLocalURL() ),
1106            'link-class' => $msgLinkClasses,
1107            'icon' => 'tray',
1108            'data' => [
1109                'counter-num' => $msgCount,
1110                'counter-text' => $msgFormattedCount,
1111            ],
1112            // This item used to be part of personal tools, and much CSS relies on it using this id.
1113            'id' => 'pt-notifications-notice',
1114        ];
1115
1116        if ( $hasUnseen ) {
1117            // Record that the user is going to see an indicator that they have unseen notifications
1118            // This is part of tracking how likely users are to click a badge with unseen notifications.
1119            // The other part is the 'echo.unseen.click' counter, see ext.echo.init.js.
1120            $this->statsdDataFactory->increment( 'echo.unseen' );
1121        }
1122    }
1123
1124    /**
1125     * Handler for AbortTalkPageEmailNotification hook.
1126     * @see https://www.mediawiki.org/wiki/Manual:Hooks/AbortTalkPageEmailNotification
1127     * @param User $targetUser
1128     * @param Title $title
1129     * @return bool
1130     */
1131    public function onAbortTalkPageEmailNotification( $targetUser, $title ) {
1132        // Send legacy talk page email notification if
1133        // 1. echo is disabled for them or
1134        // 2. echo talk page notification is disabled
1135        if ( !isset( $this->config->get( ConfigNames::Notifications )['edit-user-talk'] ) ) {
1136            // Legacy talk page email notification
1137            return true;
1138        }
1139
1140        // Echo talk page email notification
1141        return false;
1142    }
1143
1144    /**
1145     * Handler for AbortWatchlistEmailNotification hook.
1146     * @see https://www.mediawiki.org/wiki/Manual:Hooks/AbortWatchlistEmailNotification
1147     * @param User $targetUser
1148     * @param Title $title
1149     * @param EmailNotification $emailNotification The email notification object that sends non-echo notifications
1150     * @return bool
1151     */
1152    public function onSendWatchlistEmailNotification( $targetUser, $title, $emailNotification ) {
1153        if ( $this->config->get( ConfigNames::WatchlistNotifications ) &&
1154            isset( $this->config->get( ConfigNames::Notifications )["watchlist-change"] )
1155        ) {
1156            // Let echo handle watchlist notifications entirely
1157            return false;
1158        }
1159        $eventName = false;
1160        // The edit-user-talk and edit-user-page events effectively duplicate watchlist notifications.
1161        // If we are sending Echo notification emails, suppress the watchlist notifications.
1162        if ( $title->inNamespace( NS_USER_TALK ) && $targetUser->getTalkPage()->equals( $title ) ) {
1163            $eventName = 'edit-user-talk';
1164        } elseif ( $title->inNamespace( NS_USER ) && $targetUser->getUserPage()->equals( $title ) ) {
1165            $eventName = 'edit-user-page';
1166        }
1167
1168        if ( $eventName !== false ) {
1169            $events = $this->attributeManager->getUserEnabledEvents( $targetUser, 'email' );
1170            if ( in_array( $eventName, $events ) ) {
1171                // Do not send watchlist email notification, the user will receive an Echo notification
1172                return false;
1173            }
1174        }
1175
1176        // Proceed to send watchlist email notification
1177        return true;
1178    }
1179
1180    /**
1181     * @param array &$modifiedTimes
1182     * @param OutputPage $out
1183     */
1184    public function onOutputPageCheckLastModified( &$modifiedTimes, $out ) {
1185        $req = $out->getRequest();
1186        if ( $req->getRawVal( 'action' ) === 'raw' || $req->getRawVal( 'action' ) === 'render' ) {
1187            // Optimisation: Avoid expensive SeenTime compute on non-skin responses (T279213)
1188            return;
1189        }
1190
1191        $user = $out->getUser();
1192        if ( $user->isRegistered() ) {
1193            $notifUser = NotifUser::newFromUser( $user );
1194            $lastUpdate = $notifUser->getGlobalUpdateTime();
1195            if ( $lastUpdate !== false ) {
1196                $modifiedTimes['notifications-global'] = $lastUpdate;
1197            }
1198
1199            $modifiedTimes['notifications-seen-alert'] = SeenTime::newFromUser( $user )->getTime( 'alert' );
1200            $modifiedTimes['notifications-seen-message'] = SeenTime::newFromUser( $user )->getTime( 'message' );
1201        }
1202    }
1203
1204    /**
1205     * Handler for GetNewMessagesAlert hook.
1206     * We're using the GetNewMessagesAlert hook instead of the
1207     * ArticleEditUpdateNewTalk hook since we still want the user_newtalk data
1208     * to be updated and available to client-side tools and the API.
1209     * @see https://www.mediawiki.org/wiki/Manual:Hooks/GetNewMessagesAlert
1210     * @param string &$newMessagesAlert An alert that the user has new messages
1211     *     or an empty string if the user does not (empty by default)
1212     * @param array $newtalks This will be empty if the user has no new messages
1213     *     or an Array containing links and revisions if there are new messages
1214     * @param User $user The user who is loading the page
1215     * @param OutputPage $out
1216     * @return bool Should return false to prevent the new messages alert (OBOD)
1217     *     or true to allow the new messages alert
1218     */
1219    public function onGetNewMessagesAlert( &$newMessagesAlert, $newtalks, $user, $out ) {
1220        // If the user has the notifications flyout turned on and is receiving
1221        // notifications for talk page messages, disable the new messages alert.
1222        if ( $user->isRegistered()
1223            && isset( $this->config->get( ConfigNames::Notifications )['edit-user-talk'] )
1224            && ( new HookRunner( $this->hookContainer ) )->onEchoCanAbortNewMessagesAlert()
1225        ) {
1226            // hide new messages alert
1227            return false;
1228        } else {
1229            // show new messages alert
1230            return true;
1231        }
1232    }
1233
1234    /**
1235     * Handler for RollbackComplete hook.
1236     * @see https://www.mediawiki.org/wiki/Manual:Hooks/RollbackComplete
1237     *
1238     * @param WikiPage $wikiPage The article that was edited
1239     * @param UserIdentity $agent The user who did the rollback
1240     * @param RevisionRecord $newRevision The revision the page was reverted back to
1241     * @param RevisionRecord $oldRevision The revision of the top edit that was reverted
1242     */
1243    public function onRollbackComplete(
1244        $wikiPage,
1245        $agent,
1246        $newRevision,
1247        $oldRevision
1248    ) {
1249        $revertedUser = $oldRevision->getUser();
1250        $latestRevision = $wikiPage->getRevisionRecord();
1251
1252        if (
1253            $revertedUser &&
1254            // No notifications for anonymous users
1255            $revertedUser->isRegistered() &&
1256            // No notifications for null rollbacks
1257            !$oldRevision->hasSameContent( $newRevision )
1258        ) {
1259            Event::create( [
1260                'type' => 'reverted',
1261                'title' => $wikiPage->getTitle(),
1262                'extra' => [
1263                    'revid' => $latestRevision->getId(),
1264                    'reverted-user-id' => $revertedUser->getId(),
1265                    'reverted-revision-id' => $oldRevision->getId(),
1266                    'method' => 'rollback',
1267                ],
1268                'agent' => $agent,
1269            ] );
1270        }
1271    }
1272
1273    /**
1274     * Handler for UserSaveSettings hook.
1275     * @see https://www.mediawiki.org/wiki/Manual:Hooks/UserSaveSettings
1276     * @param User $user whose settings were saved
1277     */
1278    public function onUserSaveSettings( $user ) {
1279        // Extensions like AbuseFilter might create an account, but
1280        // the tables we need might not exist. Bug 57335
1281        if ( !defined( 'MW_UPDATER' ) ) {
1282            // Reset the notification count since it may have changed due to user
1283            // option changes. This covers both explicit changes in the preferences
1284            // and changes made through the options API (since both call this hook).
1285            DeferredUpdates::addCallableUpdate( static function () use ( $user ) {
1286                if ( !$user->isRegistered() ) {
1287                    // It's possible the user account was deleted before the deferred
1288                    // update runs (T318081)
1289                    return;
1290                }
1291                NotifUser::newFromUser( $user )->resetNotificationCount();
1292            } );
1293        }
1294    }
1295
1296    /**
1297     * Some of Echo's subscription user preferences are mapped to existing user preferences defined in
1298     * core MediaWiki. This returns the map of Echo preference names to core preference names.
1299     *
1300     * @return array
1301     */
1302    public static function getVirtualUserOptions() {
1303        $config = MediaWikiServices::getInstance()->getMainConfig();
1304        $options = [];
1305        $options['echo-subscriptions-email-edit-user-talk'] = 'enotifusertalkpages';
1306        if ( $config->get( ConfigNames::WatchlistNotifications ) ) {
1307            $options['echo-subscriptions-email-watchlist'] = 'enotifwatchlistpages';
1308            $options['echo-subscriptions-email-minor-watchlist'] = 'enotifminoredits';
1309        }
1310        return $options;
1311    }
1312
1313    /**
1314     * Handler for LoadUserOptions hook.
1315     * @see https://www.mediawiki.org/wiki/Manual:Hooks/LoadUserOptions
1316     * @param UserIdentity $user User whose options were loaded
1317     * @param array &$options Options can be modified
1318     */
1319    public function onLoadUserOptions( UserIdentity $user, &$options ): void {
1320        foreach ( self::getVirtualUserOptions() as $echoPref => $mwPref ) {
1321            // Use the existing core option's value for the Echo option
1322            if ( isset( $options[ $mwPref ] ) ) {
1323                $options[ $echoPref ] = $options[ $mwPref ];
1324            }
1325        }
1326    }
1327
1328    /**
1329     * Handler for SaveUserOptions hook.
1330     * @see https://www.mediawiki.org/wiki/Manual:Hooks/SaveUserOptions
1331     * @param UserIdentity $user User whose options are being saved
1332     * @param array &$modifiedOptions Options can be modified
1333     * @param array $originalOptions
1334     */
1335    public function onSaveUserOptions( UserIdentity $user, array &$modifiedOptions, array $originalOptions ) {
1336        foreach ( self::getVirtualUserOptions() as $echoPref => $mwPref ) {
1337            // Save virtual option values in corresponding real option values
1338            if ( isset( $modifiedOptions[ $echoPref ] ) ) {
1339                $modifiedOptions[ $mwPref ] = $modifiedOptions[ $echoPref ];
1340                unset( $modifiedOptions[ $echoPref ] );
1341            }
1342        }
1343    }
1344
1345    /**
1346     * Convert all values in an array to integers and filter out zeroes.
1347     *
1348     * @param array $numbers
1349     *
1350     * @return int[]
1351     */
1352    protected static function mapToInt( array $numbers ) {
1353        $data = [];
1354
1355        foreach ( $numbers as $value ) {
1356            $int = intval( $value );
1357            if ( $int === 0 ) {
1358                continue;
1359            }
1360            $data[] = $int;
1361        }
1362
1363        return $data;
1364    }
1365
1366    /**
1367     * Handler for UserClearNewTalkNotification hook.
1368     * @see https://www.mediawiki.org/wiki/Manual:Hooks/UserClearNewTalkNotification
1369     * @param UserIdentity $user User whose talk page notification should be marked as read
1370     * @param int $oldid
1371     */
1372    public function onUserClearNewTalkNotification( $user, $oldid ) {
1373        if ( $user->isRegistered() ) {
1374            DeferredUpdates::addCallableUpdate( static function () use ( $user ) {
1375                NotifUser::newFromUser( $user )->clearUserTalkNotifications();
1376            } );
1377        }
1378    }
1379
1380    /**
1381     * Handler for EmailUserComplete hook.
1382     * @see https://www.mediawiki.org/wiki/Manual:Hooks/EmailUserComplete
1383     * @param MailAddress $address Adress of receiving user
1384     * @param MailAddress $from Adress of sending user
1385     * @param string $subject Subject of the mail
1386     * @param string $text Text of the mail
1387     */
1388    public function onEmailUserComplete( $address, $from, $subject, $text ) {
1389        if ( $from->name === $address->name ) {
1390            // nothing to notify
1391            return;
1392        }
1393        $userTo = User::newFromName( $address->name );
1394        $userFrom = User::newFromName( $from->name );
1395
1396        $autoSubject = wfMessage( 'defemailsubject', $from->name )->inContentLanguage()->text();
1397        if ( $subject === $autoSubject ) {
1398            $autoFooter = "\n\n-- \n" . wfMessage( 'emailuserfooter', $from->name, $address->name )
1399                ->inContentLanguage()->text();
1400            $textWithoutFooter = preg_replace( '/' . preg_quote( $autoFooter, '/' ) . '$/', '', $text );
1401            $preview = $this->contentLanguage->truncateForVisual( $textWithoutFooter, 125 );
1402        } else {
1403            $preview = $subject;
1404        }
1405
1406        Event::create( [
1407            'type' => 'emailuser',
1408            'extra' => [
1409                'to-user-id' => $userTo->getId(),
1410                'preview' => $preview,
1411            ],
1412            'agent' => $userFrom,
1413        ] );
1414    }
1415
1416    /**
1417     * Sets custom login message for redirect from notification page
1418     *
1419     * @param array &$messages
1420     */
1421    public function onLoginFormValidErrorMessages( array &$messages ) {
1422        $messages[] = 'echo-notification-loginrequired';
1423    }
1424
1425    public static function getConfigVars( RL\Context $context, Config $config ) {
1426        return [
1427            'EchoMaxNotificationCount' => NotifUser::MAX_BADGE_COUNT,
1428            'EchoPollForUpdates' => $config->get( ConfigNames::PollForUpdates )
1429        ];
1430    }
1431
1432    /**
1433     * @param WikiPage $article
1434     * @param User $user
1435     * @param string $reason
1436     * @param int $articleId
1437     * @param Content|null $content
1438     * @param LogEntry $logEntry
1439     * @param int $archivedRevisionCount
1440     */
1441    public function onArticleDeleteComplete(
1442        $article,
1443        $user,
1444        $reason,
1445        $articleId,
1446        $content,
1447        $logEntry,
1448        $archivedRevisionCount
1449    ) {
1450        DeferredUpdates::addCallableUpdate( static function () use ( $articleId ) {
1451            $eventMapper = new EventMapper();
1452            $eventIds = $eventMapper->fetchIdsByPage( $articleId );
1453            ModerationController::moderate( $eventIds, true );
1454        } );
1455    }
1456
1457    /**
1458     * @param Title $title
1459     * @param bool $create
1460     * @param string $comment
1461     * @param int $oldPageId
1462     * @param array $restoredPages
1463     */
1464    public function onArticleUndelete( $title, $create, $comment, $oldPageId, $restoredPages ) {
1465        if ( $create ) {
1466            DeferredUpdates::addCallableUpdate( static function () use ( $oldPageId ) {
1467                $eventMapper = new EventMapper();
1468                $eventIds = $eventMapper->fetchIdsByPage( $oldPageId );
1469                ModerationController::moderate( $eventIds, false );
1470            } );
1471        }
1472    }
1473
1474    /**
1475     * Handler for SpecialMuteModifyFormFields hook
1476     *
1477     * @param UserIdentity|null $target
1478     * @param User $user
1479     * @param array &$fields
1480     */
1481    public function onSpecialMuteModifyFormFields( $target, $user, &$fields ) {
1482        $echoPerUserBlacklist = $this->config->get( ConfigNames::PerUserBlacklist );
1483        if ( $echoPerUserBlacklist ) {
1484            $id = $target ? $this->centralIdLookup->centralIdFromLocalUser( $target ) : 0;
1485            $list = MultiUsernameFilter::splitIds(
1486                $this->userOptionsManager->getOption( $user, 'echo-notifications-blacklist' )
1487            );
1488            $fields[ 'echo-notifications-blacklist'] = [
1489                'type' => 'check',
1490                'label-message' => [
1491                    'echo-specialmute-label-mute-notifications',
1492                    $target ? $target->getName() : ''
1493                ],
1494                'default' => in_array( $id, $list, true ),
1495            ];
1496        }
1497    }
1498
1499    /**
1500     * @param RecentChange $change
1501     * @return bool|void
1502     */
1503    public function onRecentChange_save( $change ) {
1504        if ( !$this->config->get( 'EchoWatchlistNotifications' ) ) {
1505            return;
1506        }
1507        if ( $change->getAttribute( 'rc_minor' ) ) {
1508            $type = 'minor-watchlist-change';
1509        } else {
1510            $type = 'watchlist-change';
1511        }
1512        Event::create( [
1513            'type' => $type,
1514            'title' => $change->getTitle(),
1515            'extra' => [
1516                'page_title' => $change->getPage()->getDBkey(),
1517                'page_namespace' => $change->getPage()->getNamespace(),
1518                'revid' => $change->getAttribute( "rc_this_oldid" ),
1519                'logid' => $change->getAttribute( "rc_logid" ),
1520                'status' => $change->mExtra["pageStatus"],
1521                'timestamp' => $change->getAttribute( "rc_timestamp" ),
1522                'emailonce' => $this->config->get( ConfigNames::WatchlistEmailOncePerPage ),
1523            ],
1524            'agent' => $change->getPerformerIdentity(),
1525        ] );
1526    }
1527
1528    /**
1529     * Hook handler for ApiMain::moduleManager.
1530     * Used here to put the echopushsubscriptions API module behind our push feature flag.
1531     * TODO: Register this the usual way in extension.json when we don't need the feature flag
1532     *  anymore.
1533     * @param ApiModuleManager $moduleManager
1534     */
1535    public function onApiMain__ModuleManager( $moduleManager ) {
1536        $pushEnabled = $this->config->get( 'EchoEnablePush' );
1537        if ( $pushEnabled ) {
1538            $moduleManager->addModule(
1539                'echopushsubscriptions',
1540                'action',
1541                ApiEchoPushSubscriptions::class
1542            );
1543        }
1544    }
1545
1546}