Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
19.07% covered (danger)
19.07%
140 / 734
13.46% covered (danger)
13.46%
7 / 52
CRAP
0.00% covered (danger)
0.00%
0 / 1
HomepageHooks
19.07% covered (danger)
19.07%
140 / 734
13.46% covered (danger)
13.46%
7 / 52
21399.77
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 onSpecialPage_initList
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
3
 isHomepageEnabled
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 isHomepageEnabledGloballyAndForUser
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getClickId
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 onWikimediaEventsShouldSchemaEditAttemptStepOversample
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 onBeforePageDisplay
0.00% covered (danger)
0.00%
0 / 84
0.00% covered (danger)
0.00%
0 / 1
380
 onSkinMinervaOptionsInit
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 onSkinTemplateNavigation__Universal
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
240
 titleIsUserPageOrUserTalk
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 personalUrlsBuilder
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 onGetPreferences
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
20
 onUserGetDefaultOptions
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 onResourceLoaderExcludeUserOptions
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 onAuthChangeFormFields
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 getGrowthFeaturesOptInOptOutOverride
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 onLocalUserCreated
0.00% covered (danger)
0.00%
0 / 78
0.00% covered (danger)
0.00%
0 / 1
380
 onListDefinedTags
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 onChangeTagsListActive
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 updateProfileMenuEntry
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 updateHomeMenuEntry
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 onSidebarBeforeOutput
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 getZeroContributionsHtml
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 onSpecialContributionsBeforeMainOutput
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 onConfirmEmailComplete
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 onSiteNoticeAfter
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 onSearchDataForIndex2
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onSearchDataForIndex
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 doSearchDataForIndex
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
9.37
 clearLinkRecommendationRecordForPage
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 lessCallback
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
110
 getTaskTypesJson
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 getDefaultTaskTypesJson
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTopicsJson
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 getAQSConfigJson
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSuggestedEditsConfigJson
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 getConfigurationLoaderForResourceLoader
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 userHasPersonalToolsPrefEnabled
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 userHasDisabledVe
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 userPrefersSourceEditor
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPersonalToolsHomepageLinkUrl
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 onFormatAutocomments
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 maybeOverridePreferredEditorWithVE
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 onCirrusSearchAddQueryFeatures
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 onPageSaveComplete
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 onRecentChange_save
38.10% covered (danger)
38.10%
8 / 21
0.00% covered (danger)
0.00%
0 / 1
23.18
 onCirrusSearchScoreBuilder
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 onContributeCards
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
6
 setMessageLocalizer
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setOutputPage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setUserIdentity
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 trackRevertedNewcomerTaskEdit
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
3
4namespace GrowthExperiments;
5
6use CirrusSearch\Search\CirrusIndexField;
7use CirrusSearch\Search\Rescore\BoostFunctionBuilder;
8use CirrusSearch\Search\SearchContext;
9use CirrusSearch\SearchConfig;
10use CirrusSearch\Wikimedia\WeightedTagsHooks;
11use GrowthExperiments\Config\GrowthConfigLoaderStaticTrait;
12use GrowthExperiments\Homepage\SiteNoticeGenerator;
13use GrowthExperiments\HomepageModules\Help;
14use GrowthExperiments\HomepageModules\Mentorship;
15use GrowthExperiments\HomepageModules\SuggestedEdits;
16use GrowthExperiments\LevelingUp\LevelingUpManager;
17use GrowthExperiments\LevelingUp\NotificationGetStartedJob;
18use GrowthExperiments\LevelingUp\NotificationKeepGoingJob;
19use GrowthExperiments\Mentorship\MentorPageMentorManager;
20use GrowthExperiments\NewcomerTasks\AddLink\LinkRecommendationHelper;
21use GrowthExperiments\NewcomerTasks\AddLink\LinkRecommendationStore;
22use GrowthExperiments\NewcomerTasks\CampaignConfig;
23use GrowthExperiments\NewcomerTasks\ConfigurationLoader\ConfigurationLoader;
24use GrowthExperiments\NewcomerTasks\GrowthArticleTopicFeature;
25use GrowthExperiments\NewcomerTasks\NewcomerTasksChangeTagsManager;
26use GrowthExperiments\NewcomerTasks\NewcomerTasksInfo;
27use GrowthExperiments\NewcomerTasks\NewcomerTasksUserOptionsLookup;
28use GrowthExperiments\NewcomerTasks\Recommendation;
29use GrowthExperiments\NewcomerTasks\Task\TaskSet;
30use GrowthExperiments\NewcomerTasks\Task\TaskSetFilters;
31use GrowthExperiments\NewcomerTasks\TaskSuggester\SearchStrategy\SearchStrategy;
32use GrowthExperiments\NewcomerTasks\TaskSuggester\SearchTaskSuggester;
33use GrowthExperiments\NewcomerTasks\TaskSuggester\TaskSuggesterFactory;
34use GrowthExperiments\NewcomerTasks\TaskSuggester\UnderlinkedFunctionScoreBuilder;
35use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationTaskTypeHandler;
36use GrowthExperiments\NewcomerTasks\TaskType\LinkRecommendationTaskType;
37use GrowthExperiments\NewcomerTasks\TaskType\LinkRecommendationTaskTypeHandler;
38use GrowthExperiments\NewcomerTasks\TaskType\SectionImageRecommendationTaskTypeHandler;
39use GrowthExperiments\NewcomerTasks\TaskType\StructuredTaskTypeHandler;
40use GrowthExperiments\NewcomerTasks\TaskType\TaskType;
41use GrowthExperiments\NewcomerTasks\TaskType\TaskTypeHandler;
42use GrowthExperiments\NewcomerTasks\TaskType\TaskTypeHandlerRegistry;
43use GrowthExperiments\NewcomerTasks\TaskType\TemplateBasedTaskType;
44use GrowthExperiments\NewcomerTasks\TaskType\TemplateBasedTaskTypeHandler;
45use GrowthExperiments\Specials\SpecialClaimMentee;
46use GrowthExperiments\Specials\SpecialHomepage;
47use GrowthExperiments\Specials\SpecialImpact;
48use GrowthExperiments\Specials\SpecialNewcomerTasksInfo;
49use GrowthExperiments\UserImpact\UserImpactLookup;
50use GrowthExperiments\UserImpact\UserImpactStore;
51use JobQueueGroup;
52use JobSpecification;
53use MediaWiki\Auth\Hook\LocalUserCreatedHook;
54use MediaWiki\ChangeTags\Hook\ChangeTagsListActiveHook;
55use MediaWiki\ChangeTags\Hook\ListDefinedTagsHook;
56use MediaWiki\Config\Config;
57use MediaWiki\Config\ConfigException;
58use MediaWiki\Content\ContentHandler;
59use MediaWiki\Content\Hook\SearchDataForIndexHook;
60use MediaWiki\Context\IContextSource;
61use MediaWiki\Context\RequestContext;
62use MediaWiki\Deferred\DeferredUpdates;
63use MediaWiki\Hook\FormatAutocommentsHook;
64use MediaWiki\Hook\RecentChange_saveHook;
65use MediaWiki\Hook\SidebarBeforeOutputHook;
66use MediaWiki\Hook\SiteNoticeAfterHook;
67use MediaWiki\Hook\SkinTemplateNavigation__UniversalHook;
68use MediaWiki\Hook\SpecialContributionsBeforeMainOutputHook;
69use MediaWiki\Html\Html;
70use MediaWiki\MediaWikiServices;
71use MediaWiki\Minerva\SkinOptions;
72use MediaWiki\Output\Hook\BeforePageDisplayHook;
73use MediaWiki\Output\OutputPage;
74use MediaWiki\Parser\ParserOutput;
75use MediaWiki\Preferences\Hook\GetPreferencesHook;
76use MediaWiki\Registration\ExtensionRegistry;
77use MediaWiki\ResourceLoader as RL;
78use MediaWiki\ResourceLoader\Hook\ResourceLoaderExcludeUserOptionsHook;
79use MediaWiki\Revision\RevisionRecord;
80use MediaWiki\SpecialPage\Hook\AuthChangeFormFieldsHook;
81use MediaWiki\SpecialPage\Hook\SpecialPage_initListHook;
82use MediaWiki\SpecialPage\SpecialPage;
83use MediaWiki\SpecialPage\SpecialPageFactory;
84use MediaWiki\Specials\Contribute\Card\ContributeCard;
85use MediaWiki\Specials\Contribute\Card\ContributeCardActionLink;
86use MediaWiki\Specials\Contribute\Hook\ContributeCardsHook;
87use MediaWiki\Status\Status;
88use MediaWiki\Storage\EditResult;
89use MediaWiki\Storage\Hook\PageSaveCompleteHook;
90use MediaWiki\Title\NamespaceInfo;
91use MediaWiki\Title\Title;
92use MediaWiki\Title\TitleFactory;
93use MediaWiki\Title\TitleValue;
94use MediaWiki\User\Hook\ConfirmEmailCompleteHook;
95use MediaWiki\User\Hook\UserGetDefaultOptionsHook;
96use MediaWiki\User\Options\UserOptionsLookup;
97use MediaWiki\User\Options\UserOptionsManager;
98use MediaWiki\User\User;
99use MediaWiki\User\UserIdentity;
100use MediaWiki\User\UserIdentityUtils;
101use MessageLocalizer;
102use OOUI\ButtonWidget;
103use SearchEngine;
104use Skin;
105use SkinTemplate;
106use StatusValue;
107use stdClass;
108use Wikimedia\Rdbms\DBReadOnlyError;
109use Wikimedia\Rdbms\IDBAccessObject;
110use Wikimedia\Rdbms\ILoadBalancer;
111use Wikimedia\Stats\PrefixingStatsdDataFactoryProxy;
112use WikiPage;
113
114/**
115 * Hook implementations that related directly or indirectly to Special:Homepage.
116 *
117 * Most suggested edits related hooks are defined here.
118 */
119class HomepageHooks implements
120    SpecialPage_initListHook,
121    BeforePageDisplayHook,
122    SkinTemplateNavigation__UniversalHook,
123    SidebarBeforeOutputHook,
124    GetPreferencesHook,
125    UserGetDefaultOptionsHook,
126    ResourceLoaderExcludeUserOptionsHook,
127    AuthChangeFormFieldsHook,
128    LocalUserCreatedHook,
129    ListDefinedTagsHook,
130    ChangeTagsListActiveHook,
131    SpecialContributionsBeforeMainOutputHook,
132    ConfirmEmailCompleteHook,
133    SiteNoticeAfterHook,
134    SearchDataForIndexHook,
135    FormatAutocommentsHook,
136    PageSaveCompleteHook,
137    RecentChange_saveHook,
138    ContributeCardsHook
139{
140    use GrowthConfigLoaderStaticTrait;
141
142    public const HOMEPAGE_PREF_ENABLE = 'growthexperiments-homepage-enable';
143    public const HOMEPAGE_PREF_PT_LINK = 'growthexperiments-homepage-pt-link';
144    /** @var string User options key for storing whether the user has seen the notice. */
145    public const HOMEPAGE_MOBILE_DISCOVERY_NOTICE_SEEN = 'homepage_mobile_discovery_notice_seen';
146    public const CONFIRMEMAIL_QUERY_PARAM = 'specialconfirmemail';
147    private const VE_PREF_DISABLE_BETA = 'visualeditor-betatempdisable';
148    private const VE_PREF_EDITOR = 'visualeditor-editor';
149
150    public const GROWTH_FORCE_OPTIN = 1;
151    public const GROWTH_FORCE_OPTOUT = 2;
152    public const GROWTH_FORCE_NONE = 3;
153
154    /** @var string Query string used on Special:CreateAccount to force enable/disable Growth features */
155    public const REGISTRATION_GROWTHEXPERIMENTS_ENABLED = 'geEnabled';
156
157    private Config $config;
158    private ILoadBalancer $lb;
159    private UserOptionsManager $userOptionsManager;
160    private UserOptionsLookup $userOptionsLookup;
161    private UserIdentityUtils $userIdentityUtils;
162    private NamespaceInfo $namespaceInfo;
163    private TitleFactory $titleFactory;
164    private ConfigurationLoader $configurationLoader;
165    private CampaignConfig $campaignConfig;
166    private ExperimentUserManager $experimentUserManager;
167    private TaskTypeHandlerRegistry $taskTypeHandlerRegistry;
168    private TaskSuggesterFactory $taskSuggesterFactory;
169    private NewcomerTasksUserOptionsLookup $newcomerTasksUserOptionsLookup;
170    private LinkRecommendationStore $linkRecommendationStore;
171    private LinkRecommendationHelper $linkRecommendationHelper;
172    private NewcomerTasksInfo $suggestionsInfo;
173    private PrefixingStatsdDataFactoryProxy $perDbNameStatsdDataFactory;
174    private JobQueueGroup $jobQueueGroup;
175    private SpecialPageFactory $specialPageFactory;
176    private NewcomerTasksChangeTagsManager $newcomerTasksChangeTagsManager;
177    private ?MessageLocalizer $messageLocalizer;
178    private ?OutputPage $outputPage;
179    private ?UserIdentity $userIdentity;
180    private UserImpactLookup $userImpactLookup;
181    private UserImpactStore $userImpactStore;
182
183    /** @var bool Are we in a context where it is safe to access the primary DB? */
184    private $canAccessPrimary;
185
186    /**
187     * @param Config $config Uses PHP globals
188     * @param ILoadBalancer $lb
189     * @param UserOptionsManager $userOptionsManager
190     * @param UserOptionsLookup $userOptionsLookup
191     * @param UserIdentityUtils $userIdentityUtils
192     * @param NamespaceInfo $namespaceInfo
193     * @param TitleFactory $titleFactory
194     * @param PrefixingStatsdDataFactoryProxy $perDbNameStatsdDataFactory
195     * @param JobQueueGroup $jobQueueGroup
196     * @param ConfigurationLoader $configurationLoader
197     * @param CampaignConfig $campaignConfig
198     * @param ExperimentUserManager $experimentUserManager
199     * @param TaskTypeHandlerRegistry $taskTypeHandlerRegistry
200     * @param TaskSuggesterFactory $taskSuggesterFactory
201     * @param NewcomerTasksUserOptionsLookup $newcomerTasksUserOptionsLookup
202     * @param LinkRecommendationStore $linkRecommendationStore
203     * @param LinkRecommendationHelper $linkRecommendationHelper
204     * @param SpecialPageFactory $specialPageFactory
205     * @param NewcomerTasksChangeTagsManager $newcomerTasksChangeTagsManager
206     * @param NewcomerTasksInfo $suggestionsInfo
207     * @param UserImpactLookup $userImpactLookup
208     * @param UserImpactStore $userImpactStore
209     */
210    public function __construct(
211        Config $config,
212        ILoadBalancer $lb,
213        UserOptionsManager $userOptionsManager,
214        UserOptionsLookup $userOptionsLookup,
215        UserIdentityUtils $userIdentityUtils,
216        NamespaceInfo $namespaceInfo,
217        TitleFactory $titleFactory,
218        PrefixingStatsdDataFactoryProxy $perDbNameStatsdDataFactory,
219        JobQueueGroup $jobQueueGroup,
220        ConfigurationLoader $configurationLoader,
221        CampaignConfig $campaignConfig,
222        ExperimentUserManager $experimentUserManager,
223        TaskTypeHandlerRegistry $taskTypeHandlerRegistry,
224        TaskSuggesterFactory $taskSuggesterFactory,
225        NewcomerTasksUserOptionsLookup $newcomerTasksUserOptionsLookup,
226        LinkRecommendationStore $linkRecommendationStore,
227        LinkRecommendationHelper $linkRecommendationHelper,
228        SpecialPageFactory $specialPageFactory,
229        NewcomerTasksChangeTagsManager $newcomerTasksChangeTagsManager,
230        NewcomerTasksInfo $suggestionsInfo,
231        UserImpactLookup $userImpactLookup,
232        UserImpactStore $userImpactStore
233    ) {
234        $this->config = $config;
235        $this->lb = $lb;
236        $this->userOptionsManager = $userOptionsManager;
237        $this->userOptionsLookup = $userOptionsLookup;
238        $this->userIdentityUtils = $userIdentityUtils;
239        $this->namespaceInfo = $namespaceInfo;
240        $this->titleFactory = $titleFactory;
241        $this->perDbNameStatsdDataFactory = $perDbNameStatsdDataFactory;
242        $this->jobQueueGroup = $jobQueueGroup;
243        $this->configurationLoader = $configurationLoader;
244        $this->campaignConfig = $campaignConfig;
245        $this->experimentUserManager = $experimentUserManager;
246        $this->taskTypeHandlerRegistry = $taskTypeHandlerRegistry;
247        $this->taskSuggesterFactory = $taskSuggesterFactory;
248        $this->newcomerTasksUserOptionsLookup = $newcomerTasksUserOptionsLookup;
249        $this->linkRecommendationStore = $linkRecommendationStore;
250        $this->linkRecommendationHelper = $linkRecommendationHelper;
251        $this->specialPageFactory = $specialPageFactory;
252        $this->newcomerTasksChangeTagsManager = $newcomerTasksChangeTagsManager;
253        $this->suggestionsInfo = $suggestionsInfo;
254        $this->userImpactLookup = $userImpactLookup;
255        $this->userImpactStore = $userImpactStore;
256
257        // Ideally this would be injected but the way hook handlers are defined makes that hard.
258        $this->canAccessPrimary = defined( 'MEDIAWIKI_JOB_RUNNER' )
259            || MW_ENTRY_POINT === 'cli'
260            || RequestContext::getMain()->getRequest()->wasPosted();
261    }
262
263    /**
264     * Register Homepage, Impact, ClaimMentee and NewcomerTasksInfo special pages.
265     *
266     * @param array &$list
267     * @throws ConfigException
268     */
269    public function onSpecialPage_initList( &$list ) {
270        if ( self::isHomepageEnabled() ) {
271            $pageViewInfoEnabled = ExtensionRegistry::getInstance()->isLoaded( 'PageViewInfo' );
272            $list['Homepage'] = [
273                'class' => SpecialHomepage::class,
274                'services' => [
275                    'GrowthExperimentsHomepageModuleRegistry',
276                    'StatsdDataFactory',
277                    'PerDbNameStatsdDataFactory',
278                    'GrowthExperimentsExperimentUserManager',
279                    'GrowthExperimentsMentorManager',
280                    'GrowthExperimentsCommunityConfig',
281                    'UserOptionsManager',
282                    'TitleFactory',
283                ]
284            ];
285            if ( $pageViewInfoEnabled ) {
286                $list['Impact'] = [
287                    'class' => SpecialImpact::class,
288                    'services' => [
289                        'UserFactory',
290                        'UserNameUtils',
291                        'UserNamePrefixSearch',
292                        'GrowthExperimentsHomepageModuleRegistry',
293                    ]
294                ];
295            }
296            $list[ 'ClaimMentee' ] = [
297                'class' => SpecialClaimMentee::class,
298                'services' => [
299                    'GrowthExperimentsMentorProvider',
300                    'GrowthExperimentsChangeMentorFactory',
301                    'GrowthExperimentsCommunityConfig'
302                ]
303            ];
304
305            $list[ 'NewcomerTasksInfo' ] = [
306                'class' => SpecialNewcomerTasksInfo::class,
307                'services' => [
308                    'GrowthExperimentsSuggestionsInfo'
309                ]
310            ];
311        }
312    }
313
314    /**
315     * @param UserIdentity|null $user
316     * @return bool
317     * @throws ConfigException
318     */
319    public static function isHomepageEnabled( ?UserIdentity $user = null ): bool {
320        // keep the dependencies minimal, this is used from other hooks as well
321        $services = MediaWikiServices::getInstance();
322        return (
323            $services->getMainConfig()->get( 'GEHomepageEnabled' ) &&
324            (
325                $user === null ||
326                $services->getUserOptionsLookup()->getBoolOption(
327                    $user,
328                    self::HOMEPAGE_PREF_ENABLE
329                )
330            )
331        );
332    }
333
334    /**
335     * Similar to ::isHomepageEnabled, but using dependency-injected services.
336     *
337     * @param UserIdentity $user
338     * @return bool
339     */
340    private function isHomepageEnabledGloballyAndForUser( UserIdentity $user ): bool {
341        return $this->config->get( 'GEHomepageEnabled' ) &&
342            $this->userOptionsLookup->getBoolOption( $user, self::HOMEPAGE_PREF_ENABLE );
343    }
344
345    /**
346     * Get the click ID from the URL if set (from clicking a suggested edit card).
347     *
348     * @param IContextSource $context
349     * @return string|null
350     */
351    public static function getClickId( IContextSource $context ) {
352        if ( SuggestedEdits::isEnabled( $context->getConfig() ) ) {
353            return $context->getRequest()->getVal( 'geclickid' ) ?: null;
354        }
355        return null;
356    }
357
358    /**
359     * @param IContextSource $context
360     * @param bool &$shouldOversample
361     */
362    public static function onWikimediaEventsShouldSchemaEditAttemptStepOversample(
363        IContextSource $context, &$shouldOversample
364    ) {
365        if ( self::getClickId( $context ) ) {
366            // Force WikimediaEvents to log EditAttemptStep on every request
367            $shouldOversample = true;
368        }
369    }
370
371    /**
372     * @param OutputPage $out
373     * @param Skin $skin
374     * @throws ConfigException
375     */
376    public function onBeforePageDisplay( $out, $skin ): void {
377        $context = $out->getContext();
378        $isSuggestedEditsEnabled = SuggestedEdits::isEnabled( $context->getConfig() );
379        if (
380            Util::isMobile( $skin ) &&
381            // Optimisation: isHomepageEnabled() is non-trivial, check it last
382            self::isHomepageEnabled( $skin->getUser() )
383        ) {
384            $out->addModuleStyles( 'ext.growthExperiments.mobileMenu.icons' );
385        }
386        if ( $context->getTitle()->inNamespaces( NS_MAIN, NS_TALK ) &&
387            $isSuggestedEditsEnabled
388        ) {
389            // Manage the suggested edit session.
390            $out->addModules( 'ext.growthExperiments.SuggestedEditSession' );
391        }
392
393        if ( $isSuggestedEditsEnabled ) {
394            $isLevelingUpEnabledForUser = LevelingUpManager::isEnabledForUser(
395                $context->getUser(),
396                $this->config,
397                $this->experimentUserManager
398            );
399            $out->addJsConfigVars( [
400                // Always output these config vars since they are used by ext.growthExperiments.DataStore
401                // which can be included in any module
402                'GEHomepageSuggestedEditsEnableTopics' => SuggestedEdits::isTopicMatchingEnabled(
403                    $context,
404                    $this->userOptionsLookup
405                ),
406                'wgGETopicsMatchModeEnabled' => $this->config->get( 'GETopicsMatchModeEnabled' ),
407                'wgGEStructuredTaskRejectionReasonTextInputEnabled' =>
408                    $this->config->get( 'GEStructuredTaskRejectionReasonTextInputEnabled' ),
409                // Always output, it's used throughout the suggested editing session.
410                'wgGELevelingUpEnabledForUser' => $isLevelingUpEnabledForUser,
411            ] );
412        }
413
414        $clickId = self::getClickId( $context );
415        if ( $context->getTitle()->inNamespaces( NS_MAIN, NS_TALK ) && $clickId ) {
416            // The user just clicked on a suggested edit task card; we need to initialize the
417            // suggested edit session.
418
419            // Override the edit session ID.
420            // The suggested edit session is tracked on the client side, because it is
421            // specific to the browser tab, but some of the EditAttemptStep events it
422            // needs to be associated with happen early on page load so setting this
423            // on the JS side might be too late. So, we use JS to propagate the clickId
424            // to all edit links, and then use this code to set the JS variable for the
425            // pageview that's initiated by clicking on the edit link. This might be overkill.
426            $out->addJsConfigVars( [
427                'wgWMESchemaEditAttemptStepSessionId' => $clickId,
428            ] );
429
430            $recommendationProvider = $taskType = null;
431            $taskTypeId = $context->getRequest()->getVal( 'getasktype' );
432            if ( !$taskTypeId ) {
433                Util::logText( 'Click ID present but task type ID missing' );
434            } else {
435                $taskType = $this->configurationLoader->getTaskTypes()[$taskTypeId] ?? null;
436                if ( !$taskType ) {
437                    Util::logText( "No such task type: {taskTypeId}", [
438                        'taskTypeId' => $taskTypeId,
439                    ] );
440                } else {
441                    $taskTypeHandler = $this->taskTypeHandlerRegistry->getByTaskType( $taskType );
442                    if ( $taskTypeHandler instanceof StructuredTaskTypeHandler ) {
443                        $recommendationProvider = $taskTypeHandler->getRecommendationProvider();
444                    }
445                }
446            }
447
448            if ( $taskType ) {
449                $levelingUpTryNewTaskOptOuts = $this->userOptionsLookup->getOption(
450                    $context->getUser(),
451                    LevelingUpManager::TASK_TYPE_PROMPT_OPT_OUTS_PREF,
452                    json_encode( [] )
453                );
454                $levelingUpTryNewTaskOptOuts = json_decode( $levelingUpTryNewTaskOptOuts ) ?? [];
455                $out->addJsConfigVars( [
456                    'wgGESuggestedEditTaskType' => $taskType->getId(),
457                    'wgGELevelingUpTryNewTaskOptOuts' => $levelingUpTryNewTaskOptOuts,
458                ] );
459
460                if ( $recommendationProvider ) {
461                    $recommendation = $recommendationProvider->get( $context->getTitle(), $taskType );
462                    if ( $recommendation instanceof Recommendation ) {
463                        $serializedRecommendation = $recommendation->toArray();
464                    } else {
465                        Util::logStatus( $recommendation );
466                        $serializedRecommendation = [
467                            'error' => Status::wrap( $recommendation )->getWikiText( false, false, 'en' ),
468                        ];
469                    }
470
471                    $taskSet = $this->taskSuggesterFactory->create()->suggest(
472                        $context->getUser(),
473                        new TaskSetFilters(
474                            $this->newcomerTasksUserOptionsLookup->getTaskTypeFilter( $context->getUser() ),
475                            $this->newcomerTasksUserOptionsLookup->getTopics( $context->getUser() ),
476                            $this->newcomerTasksUserOptionsLookup->getTopicsMatchMode( $context->getUser() )
477                        ),
478                        1
479                    );
480                    $qualityGateConfig = $taskSet instanceof TaskSet ? $taskSet->getQualityGateConfig() : [];
481                    // If the user's gone over the dailyLimit for a task, return an error.
482                    if ( $qualityGateConfig[$taskType->getId()]['dailyLimit'] ?? false ) {
483                        $serializedRecommendation = [ 'error' => 'Daily limit exceeded for ' . $taskType->getId() ];
484                    }
485                    $out->addJsConfigVars( [ 'wgGESuggestedEditQualityGateConfig' => $qualityGateConfig ] );
486                    $out->addJsConfigVars( [
487                        'wgGESuggestedEditData' => $serializedRecommendation,
488                    ] );
489                }
490
491                $this->maybeOverridePreferredEditorWithVE( $taskType, $skin->getUser() );
492            }
493        }
494
495        // Config vars used to modify the suggested edits topics based on campaign
496        // (see ext.growthExperiments.Homepage.SuggestedEdits/Topics.js)
497        if ( ( !$skin->getTitle() || $skin->getTitle()->isSpecial( 'Homepage' ) ) &&
498            SuggestedEdits::isEnabled( $context->getConfig() ) ) {
499            $out->addJsConfigVars( [
500                'wgGETopicsToExclude' => $this->campaignConfig->getTopicsToExcludeForUser(
501                    $context->getUser()
502                ),
503                'wgGETopicsMatchModeEnabled' => $this->config->get( 'GETopicsMatchModeEnabled' )
504            ] );
505        }
506    }
507
508    /**
509     * @param SkinTemplate $skin
510     * @param SkinOptions $skinOptions
511     * @throws ConfigException
512     */
513    public static function onSkinMinervaOptionsInit(
514        SkinTemplate $skin,
515        SkinOptions $skinOptions
516    ) {
517        $title = $skin->getTitle();
518        if ( $title && (
519            $title->isSpecial( 'Homepage' ) ||
520            self::titleIsUserPageOrUserTalk( $title, $skin->getUser() )
521        ) ) {
522            if ( self::isHomepageEnabled( $skin->getUser() ) ) {
523                $skinOptions->setMultiple( [
524                    SkinOptions::TALK_AT_TOP => true,
525                    SkinOptions::TABS_ON_SPECIALS => true,
526                ] );
527            }
528        }
529    }
530
531    /**
532     * Make sure user pages have "User", "talk" and "homepage" tabs.
533     *
534     * @param SkinTemplate $skin
535     * @param array &$links
536     */
537    public function onSkinTemplateNavigation__Universal( $skin, &$links ): void {
538        $user = $skin->getUser();
539        $this->personalUrlsBuilder( $skin, $links, $user );
540        if ( !self::isHomepageEnabled( $user ) ) {
541            return;
542        }
543
544        $isMobile = Util::isMobile( $skin );
545        if ( $isMobile && $this->userHasPersonalToolsPrefEnabled( $user ) ) {
546            $this->updateProfileMenuEntry( $links );
547        }
548
549        $title = $skin->getTitle();
550        $homepageTitle = SpecialPage::getTitleFor( 'Homepage' );
551        $userpage = $user->getUserPage();
552        $usertalk = $user->getTalkPage();
553
554        $isHomepage = $title->equals( $homepageTitle );
555        $isUserSpace = $title->equals( $userpage ) || $title->isSubpageOf( $userpage );
556        $isUserTalkSpace = $title->equals( $usertalk ) || $title->isSubpageOf( $usertalk );
557
558        if ( $isHomepage || $isUserSpace || $isUserTalkSpace ) {
559            unset( $links['namespaces']['special'] );
560            unset( $links['namespaces']['user'] );
561            unset( $links['namespaces']['user_talk'] );
562
563            // T250554: If user currently views a subpage, direct him to the subpage talk page
564            if ( !$isHomepage ) {
565                $subjectpage = $this->namespaceInfo->getSubjectPage( $title );
566                $talkpage = $this->namespaceInfo->getTalkPage( $title );
567
568                if ( $subjectpage instanceof TitleValue ) {
569                    $subjectpage = Title::newFromLinkTarget( $subjectpage );
570                }
571                if ( $talkpage instanceof TitleValue ) {
572                    $talkpage = Title::newFromLinkTarget( $talkpage );
573                }
574            } else {
575                $subjectpage = $userpage;
576                $talkpage = $usertalk;
577            }
578
579            $homepageUrlQuery = $isHomepage ? '' : wfArrayToCgi( [
580                'source' => $isUserSpace ? 'userpagetab' : 'usertalkpagetab',
581                'namespace' => $title->getNamespace(),
582            ] );
583            $links['namespaces']['homepage'] = $skin->tabAction(
584                $homepageTitle, 'growthexperiments-homepage-tab', $isHomepage, $homepageUrlQuery
585            );
586
587            $links['namespaces']['user'] = $skin->tabAction(
588                $subjectpage, wfMessage( 'nstab-user', $user->getName() ), $isUserSpace, '', !$isMobile
589            );
590
591            $links['namespaces']['user_talk'] = $skin->tabAction(
592                $talkpage, 'talk', $isUserTalkSpace, '', !$isMobile
593            );
594            // Enable talk overlay on talk page tab
595            $links['namespaces']['user_talk']['context'] = 'talk';
596            if ( $isMobile ) {
597                $skin->getOutput()->addModules( 'skins.minerva.talk' );
598            }
599        }
600    }
601
602    private static function titleIsUserPageOrUserTalk( Title $title, User $user ) {
603        $userpage = $user->getUserPage();
604        $usertalk = $user->getTalkPage();
605        return $title->equals( $userpage ) ||
606            $title->isSubpageOf( $userpage ) ||
607            $title->equals( $usertalk ) ||
608            $title->isSubpageOf( $usertalk );
609    }
610
611    /**
612     * Conditionally make the userpage link go to the homepage.
613     *
614     * @param SkinTemplate $skin
615     * @param array &$links
616     * @param User $user
617     * @throws ConfigException
618     */
619    public function personalUrlsBuilder( $skin, &$links, $user ): void {
620        if ( Util::isMobile( $skin ) || !self::isHomepageEnabled( $user ) ) {
621            return;
622        }
623
624        if ( $this->userHasPersonalToolsPrefEnabled( $user ) ) {
625            $links['user-menu']['userpage']['href'] = $this->getPersonalToolsHomepageLinkUrl(
626                $skin->getTitle()->getNamespace()
627            );
628            $links['user-page']['userpage']['href'] = $this->getPersonalToolsHomepageLinkUrl(
629                $skin->getTitle()->getNamespace()
630            );
631            // Make the link blue
632            unset( $links['user-menu']['userpage']['link-class'] );
633            // Remove the "this page doesn't exist" part of the tooltip
634            $links['user-menu']['userpage' ]['exists'] = true;
635        }
636    }
637
638    /**
639     * Register preferences to control the homepage.
640     *
641     * @param User $user
642     * @param array &$preferences
643     * @throws ConfigException
644     */
645    public function onGetPreferences( $user, &$preferences ) {
646        if ( !self::isHomepageEnabled() ) {
647            return;
648        }
649
650        $preferences[ self::HOMEPAGE_PREF_ENABLE ] = [
651            'type' => 'toggle',
652            'section' => 'personal/homepage',
653            'label-message' => self::HOMEPAGE_PREF_ENABLE,
654        ];
655
656        $preferences[ self::HOMEPAGE_PREF_PT_LINK ] = [
657            'type' => 'toggle',
658            'section' => 'personal/homepage',
659            'label-message' => self::HOMEPAGE_PREF_PT_LINK,
660            'hide-if' => [ '!==', self::HOMEPAGE_PREF_ENABLE, '1' ],
661        ];
662
663        if ( HelpPanel::isHelpPanelEnabled() ) {
664            $preferences[ HelpPanelHooks::HELP_PANEL_PREFERENCES_TOGGLE ] = [
665                'type' => 'toggle',
666                'section' => 'personal/homepage',
667                'label-message' => HelpPanelHooks::HELP_PANEL_PREFERENCES_TOGGLE
668            ];
669        }
670
671        $preferences[ self::HOMEPAGE_MOBILE_DISCOVERY_NOTICE_SEEN ] = [
672            'type' => 'api',
673        ];
674
675        $preferences[ Mentorship::QUESTION_PREF ] = [
676            'type' => 'api',
677        ];
678
679        $preferences[ SuggestedEdits::ACTIVATED_PREF ] = [
680            'type' => 'api',
681        ];
682
683        $preferences[ SuggestedEdits::PREACTIVATED_PREF ] = [
684            'type' => 'api',
685        ];
686
687        $preferences[ SuggestedEdits::getTopicFiltersPref( $this->config ) ] = [
688            'type' => 'api'
689        ];
690
691        $preferences[ SuggestedEdits::TOPICS_MATCH_MODE_PREF ] = [
692            'type' => 'api'
693        ];
694
695        $preferences[ SuggestedEdits::TASKTYPES_PREF ] = [
696            'type' => 'api'
697        ];
698
699        $preferences[ SuggestedEdits::GUIDANCE_BLUE_DOT_PREF ] = [
700            'type' => 'api'
701        ];
702
703        $preferences[ SuggestedEdits::ADD_LINK_ONBOARDING_PREF ] = [
704            'type' => 'api'
705        ];
706
707        $preferences[ SuggestedEdits::ADD_IMAGE_ONBOARDING_PREF ] = [
708            'type' => 'api'
709        ];
710
711        $preferences[ SuggestedEdits::ADD_IMAGE_CAPTION_ONBOARDING_PREF ] = [
712            'type' => 'api'
713        ];
714
715        $preferences[ SuggestedEdits::ADD_SECTION_IMAGE_CAPTION_ONBOARDING_PREF ] = [
716            'type' => 'api'
717        ];
718
719        $preferences[ SuggestedEdits::ADD_SECTION_IMAGE_ONBOARDING_PREF ] = [
720            'type' => 'api'
721        ];
722
723        if ( LevelingUpManager::isEnabledForAnyone( $this->config ) ) {
724            $preferences[LevelingUpManager::TASK_TYPE_PROMPT_OPT_OUTS_PREF] = [
725                'type' => 'api'
726            ];
727        }
728    }
729
730    /**
731     * Register defaults for homepage-related preferences.
732     *
733     * @param array &$defaultOptions
734     */
735    public function onUserGetDefaultOptions( &$defaultOptions ) {
736        $defaultOptions += [
737            // Set discovery notice seen flag to true; it will be changed for new users in the
738            // LocalUserCreated hook.
739            self::HOMEPAGE_MOBILE_DISCOVERY_NOTICE_SEEN => true,
740            // Disable blue dot on Edit tab for link-recommendation tasks.
741            SuggestedEdits::GUIDANCE_BLUE_DOT_PREF => json_encode( [
742                'vector' => [
743                    LinkRecommendationTaskTypeHandler::ID => true,
744                    ImageRecommendationTaskTypeHandler::ID => true,
745                ],
746                'minerva' => [
747                    LinkRecommendationTaskTypeHandler::ID => true,
748                    ImageRecommendationTaskTypeHandler::ID => true,
749                ]
750            ] ),
751            SuggestedEdits::TOPICS_MATCH_MODE_PREF => SearchStrategy::TOPIC_MATCH_MODE_OR,
752            self::HOMEPAGE_PREF_ENABLE => false,
753            self::HOMEPAGE_PREF_PT_LINK => false,
754        ];
755    }
756
757    /** @inheritDoc */
758    public function onResourceLoaderExcludeUserOptions(
759        array &$keysToExclude,
760        RL\Context $context
761    ): void {
762        $keysToExclude = array_merge( $keysToExclude, [
763            self::HOMEPAGE_PREF_ENABLE,
764            self::HOMEPAGE_PREF_PT_LINK,
765            self::HOMEPAGE_MOBILE_DISCOVERY_NOTICE_SEEN,
766            Mentorship::QUESTION_PREF,
767            SuggestedEdits::PREACTIVATED_PREF,
768        ] );
769    }
770
771    /**
772     * Pass through the debug flag used by LocalUserCreated.
773     * @inheritDoc
774     */
775    public function onAuthChangeFormFields( $requests, $fieldInfo, &$formDescriptor, $action ) {
776        $request = RequestContext::getMain()->getRequest();
777
778        $geForceVariant = $request->getVal( 'geForceVariant' );
779        if ( $geForceVariant !== null ) {
780            $formDescriptor['geForceVariant'] = [
781                'type' => 'hidden',
782                'name' => 'geForceVariant',
783                'default' => $geForceVariant,
784            ];
785        }
786
787        $formDescriptor[self::REGISTRATION_GROWTHEXPERIMENTS_ENABLED] = [
788            'type' => 'hidden',
789            'name' => self::REGISTRATION_GROWTHEXPERIMENTS_ENABLED,
790            'default' => $request->getInt( self::REGISTRATION_GROWTHEXPERIMENTS_ENABLED, -1 )
791        ];
792    }
793
794    /**
795     * Check if a user opted-in or opted-out from Growth features
796     *
797     * @return int One of GROWTH_FORCE_* constants
798     */
799    public static function getGrowthFeaturesOptInOptOutOverride(): int {
800        $enableGrowthFeatures = RequestContext::getMain()
801            ->getRequest()
802            ->getInt( self::REGISTRATION_GROWTHEXPERIMENTS_ENABLED, -1 );
803        if ( $enableGrowthFeatures === 1 ) {
804            return self::GROWTH_FORCE_OPTIN;
805        } elseif ( $enableGrowthFeatures === 0 ) {
806            return self::GROWTH_FORCE_OPTOUT;
807        } else {
808            return self::GROWTH_FORCE_NONE;
809        }
810    }
811
812    /**
813     * Enable the homepage for a percentage of new local accounts.
814     *
815     * @param User $user
816     * @param bool $autocreated
817     * @throws ConfigException
818     */
819    public function onLocalUserCreated( $user, $autocreated ) {
820        if ( $autocreated || !self::isHomepageEnabled() || $user->isTemp() ) {
821            return;
822        }
823
824        $geForceVariant = RequestContext::getMain()->getRequest()
825            ->getVal( 'geForceVariant' );
826        $growthOptInOptOutOverride = self::getGrowthFeaturesOptInOptOutOverride();
827
828        if ( $growthOptInOptOutOverride === self::GROWTH_FORCE_OPTOUT ) {
829            // Growth features cannot be enabled, short-circuit
830            return;
831        }
832
833        // Enable the homepage for a percentage of non-autocreated users.
834        $enablePercentage = $this->config->get( 'GEHomepageNewAccountEnablePercentage' );
835        if (
836            $growthOptInOptOutOverride === self::GROWTH_FORCE_OPTIN ||
837            $geForceVariant !== null ||
838            rand( 0, 99 ) < $enablePercentage
839        ) {
840            $this->perDbNameStatsdDataFactory->increment( 'GrowthExperiments.UsersOptedIntoGrowthFeatures' );
841            $this->userOptionsManager->setOption( $user, self::HOMEPAGE_PREF_ENABLE, 1 );
842            $this->userOptionsManager->setOption( $user, self::HOMEPAGE_PREF_PT_LINK, 1 );
843            // Default option is that the user has seen the tours/notices (so we don't prompt
844            // existing users to view them). We set the option to false on new user accounts
845            // so they see them once (and then the option gets reset for them).
846            $this->userOptionsManager->setOption( $user, TourHooks::TOUR_COMPLETED_HELP_PANEL, 0 );
847            $this->userOptionsManager->setOption( $user, TourHooks::TOUR_COMPLETED_HOMEPAGE_MENTORSHIP, 0 );
848            $this->userOptionsManager->setOption( $user, TourHooks::TOUR_COMPLETED_HOMEPAGE_WELCOME, 0 );
849            $this->userOptionsManager->setOption( $user, TourHooks::TOUR_COMPLETED_HOMEPAGE_DISCOVERY, 0 );
850            $this->userOptionsManager->setOption( $user, self::HOMEPAGE_MOBILE_DISCOVERY_NOTICE_SEEN, 0 );
851
852            if (
853                $this->config->get( 'GEHelpPanelNewAccountEnableWithHomepage' ) &&
854                HelpPanel::isHelpPanelEnabled()
855            ) {
856                $this->userOptionsManager->setOption( $user, HelpPanelHooks::HELP_PANEL_PREFERENCES_TOGGLE, 1 );
857            }
858
859            // Mentorship
860            $mentorshipEnablePercentage = $this->config->get( 'GEMentorshipNewAccountEnablePercentage' );
861            if ( rand( 0, 99 ) >= $mentorshipEnablePercentage ) {
862                // the default value is enabled, to avoid removing mentorship from someone who used
863                // to have it. Only setOption if the result is "do not enable".
864                $this->userOptionsManager->setOption(
865                    $user,
866                    MentorPageMentorManager::MENTORSHIP_ENABLED_PREF,
867                    0
868                );
869            }
870
871            // Variant assignment for forced variants and variant metric logging. Wrapped in a deferred update because
872            // CentralAuth generates the central user in a onLocalUserCreated hook, hence the order of execution is
873            // not guaranteed. This is necessary so the getOption call to the USER_PREFERENCE has a chance to retrieve
874            // a valid central user id. See also hook in ExperimentsHooks.php determining which a variant to assign
875            DeferredUpdates::addCallableUpdate( function () use ( $user, $geForceVariant ) {
876                // Get the variant assigned by ExperimentUserDefaultsManager
877                $variant = $this->userOptionsLookup->getOption( $user, VariantHooks::USER_PREFERENCE );
878                // Maybe override variant with query parameter
879                if ( $geForceVariant !== null
880                    && $this->experimentUserManager->isValidVariant( $geForceVariant )
881                    && $geForceVariant !== $variant
882                ) {
883                    $variant = $geForceVariant;
884                    $this->experimentUserManager->setVariant( $user, $variant );
885                    $this->userOptionsManager->saveOptions( $user );
886                }
887                $this->perDbNameStatsdDataFactory->increment( 'GrowthExperiments.UserVariant.' . $variant );
888            } );
889
890            // Place an empty user impact object in the database table cache, to avoid
891            // making an extra HTTP request on first visit to Special:Homepage.
892            DeferredUpdates::addCallableUpdate( function () use ( $user ) {
893                $userImpact = $this->userImpactLookup->getExpensiveUserImpact(
894                    $user,
895                    IDBAccessObject::READ_LATEST
896                );
897                if ( $userImpact ) {
898                    $this->userImpactStore->setUserImpact( $userImpact );
899                }
900            } );
901
902            if ( SuggestedEdits::isEnabledForAnyone( $this->config ) ) {
903                // Populate the cache of tasks with default task/topic selections
904                // so that when the user lands on Special:Homepage, the request to retrieve tasks
905                // will pull from the cached TaskSet instead of doing time consuming search queries.
906                // With nuances in how mobile/desktop users are onboarded, this may not be always
907                // necessary but does no harm to run for all newly created users.
908                DeferredUpdates::addCallableUpdate( function () use ( $user ) {
909                    $taskSuggester = $this->taskSuggesterFactory->create();
910                    $taskSuggester->suggest(
911                        $user,
912                        new TaskSetFilters(
913                            $this->newcomerTasksUserOptionsLookup->getTaskTypeFilter( $user ),
914                            $this->newcomerTasksUserOptionsLookup->getTopics( $user ),
915                            $this->newcomerTasksUserOptionsLookup->getTopicsMatchMode( $user )
916                        )
917                    );
918                } );
919
920                $jobQueue = $this->jobQueueGroup->get( NotificationKeepGoingJob::JOB_NAME );
921                if ( LevelingUpManager::isEnabledForAnyone( $this->config ) &&
922                    $this->experimentUserManager->isUserInVariant( $user, VariantHooks::VARIANT_CONTROL ) &&
923                    $jobQueue->delayedJobsEnabled() ) {
924                    $this->jobQueueGroup->lazyPush(
925                        new JobSpecification( NotificationKeepGoingJob::JOB_NAME, [
926                            'userId' => $user->getId(),
927                            // Process the job X seconds after account creation (default: 48 hours)
928                            'jobReleaseTimestamp' => (int)wfTimestamp() +
929                                $this->config->get( 'GELevelingUpKeepGoingNotificationSendAfterSeconds' )
930                        ] )
931                    );
932                    $this->jobQueueGroup->lazyPush(
933                        new JobSpecification( NotificationGetStartedJob::JOB_NAME, [
934                            'userId' => $user->getId(),
935                            // Process the job X seconds after account creation (configured in extension.json)
936                            'jobReleaseTimestamp' => (int)wfTimestamp() +
937                                $this->config->get( 'GELevelingUpGetStartedNotificationSendAfterSeconds' )
938                        ] )
939                    );
940                }
941            }
942        } else {
943            $this->perDbNameStatsdDataFactory->increment( 'GrowthExperiments.UsersNotOptedIntoGrowthFeatures' );
944        }
945    }
946
947    /**
948     * ListDefinedTags hook handler
949     *
950     * @see https://www.mediawiki.org/wiki/Manual:Hooks/ListDefinedTags
951     *
952     * @param array &$tags The list of tags.
953     * @throws ConfigException
954     */
955    public function onListDefinedTags( &$tags ) {
956        if ( self::isHomepageEnabled() ) {
957            $tags[] = Help::HELP_MODULE_QUESTION_TAG;
958            $tags[] = Mentorship::MENTORSHIP_MODULE_QUESTION_TAG;
959        }
960        if ( HelpPanel::isHelpPanelEnabled() ) {
961            $tags[] = Mentorship::MENTORSHIP_HELPPANEL_QUESTION_TAG;
962        }
963        if ( SuggestedEdits::isEnabledForAnyone( $this->config ) ) {
964            array_push( $tags, ...$this->taskTypeHandlerRegistry->getChangeTags() );
965        }
966    }
967
968    /**
969     * ChangeTagsListActive hook handler
970     *
971     * @see https://www.mediawiki.org/wiki/Manual:Hooks/ChangeTagsListActive
972     *
973     * @param array &$tags The list of tags.
974     * @throws ConfigException
975     */
976    public function onChangeTagsListActive( &$tags ) {
977        if ( self::isHomepageEnabled() ) {
978            // Help::HELP_MODULE_QUESTION_TAG is no longer active (T232548)
979            $tags[] = Mentorship::MENTORSHIP_MODULE_QUESTION_TAG;
980        }
981        if ( HelpPanel::isHelpPanelEnabled() ) {
982            $tags[] = Mentorship::MENTORSHIP_HELPPANEL_QUESTION_TAG;
983        }
984        if ( SuggestedEdits::isEnabledForAnyone( $this->config ) ) {
985            array_push( $tags, ...$this->taskTypeHandlerRegistry->getChangeTags() );
986        }
987    }
988
989    /**
990     * Helper method to update the "Profile" menu entry in menus
991     * @param array &$links
992     */
993    private function updateProfileMenuEntry( array &$links ) {
994        $userItem = $links['user-menu']['userpage'] ?? [];
995        if ( $userItem ) {
996            $context = RequestContext::getMain();
997            $userItem['href'] = $this->getPersonalToolsHomepageLinkUrl(
998                $context->getTitle()->getNamespace()
999            );
1000            unset( $links['user-menu']['userpage'] );
1001            unset( $links['user-page']['userpage'] );
1002            $links['user-menu'] = [
1003                'homepage' => $userItem,
1004            ] + $links['user-menu'];
1005            $links['user-page'] = [
1006                    'homepage' => $userItem,
1007                    // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset the user-page is always set
1008            ] + $links['user-page'];
1009        }
1010    }
1011
1012    /**
1013     * Helper method to update the "Home" menu entry in the Mobile Menu.
1014     *
1015     * We want "Home" to read "Main Page", because Special:Homepage is intended to be "Home"
1016     * for users with Growth features.
1017     *
1018     * @param array &$sidebar
1019     */
1020    private function updateHomeMenuEntry( array &$sidebar ) {
1021        foreach ( $sidebar['navigation'] ?? [] as $key => $item ) {
1022            $id = $item['id'] ?? null;
1023            if ( $id === 'n-mainpage-description' ) {
1024                // MinervaNeue's BuilderUtil::getDiscoveryTools will override 'text'
1025                // with the message key set in 'msg'.
1026                $item['msg'] = 'mainpage-nstab';
1027                $item['icon'] = 'newspaper';
1028                $sidebar['navigation'][$key] = $item;
1029            }
1030        }
1031    }
1032
1033    /**
1034     * @inheritDoc
1035     */
1036    public function onSidebarBeforeOutput( $skin, &$sidebar ): void {
1037        if ( !Util::isMobile( $skin ) ) {
1038            return;
1039        }
1040        $user = $skin->getUser();
1041        if ( !self::isHomepageEnabled( $user ) ) {
1042            return;
1043        }
1044        if ( $this->userHasPersonalToolsPrefEnabled( $user ) ) {
1045            $this->updateHomeMenuEntry( $sidebar );
1046        }
1047    }
1048
1049    private static function getZeroContributionsHtml( SpecialPage $sp, $wrapperClasses = '' ) {
1050        $linkUrl = SpecialPage::getTitleFor( 'Homepage' )
1051            ->getFullURL( [ 'source' => 'specialcontributions' ] );
1052        return Html::rawElement( 'div', [ 'class' => 'mw-ge-contributions-zero ' . $wrapperClasses ],
1053            Html::element( 'p', [ 'class' => 'mw-ge-contributions-zero-title' ],
1054                $sp->msg( 'growthexperiments-homepage-contributions-zero-title' )
1055                    ->params( $sp->getUser()->getName() )->text()
1056            ) .
1057            Html::element( 'p', [ 'class' => 'mw-ge-contributions-zero-subtitle' ],
1058                $sp->msg( 'growthexperiments-homepage-contributions-zero-subtitle' )
1059                    ->params( $sp->getUser()->getName() )->text()
1060            ) .
1061            new ButtonWidget( [
1062                'label' => $sp->msg( 'growthexperiments-homepage-contributions-zero-button' )
1063                    ->params( $sp->getUser()->getName() )->text(),
1064                'href' => $linkUrl,
1065                'flags' => [ 'primary', 'progressive' ]
1066            ] )
1067        );
1068    }
1069
1070    /**
1071     * @inheritDoc
1072     */
1073    public function onSpecialContributionsBeforeMainOutput( $userId, $user, $sp ) {
1074        if (
1075            $user->equals( $sp->getUser() ) &&
1076            $user->getEditCount() === 0 &&
1077            self::isHomepageEnabled( $user )
1078        ) {
1079            $out = $sp->getOutput();
1080            $out->enableOOUI();
1081            $out->addModuleStyles( 'ext.growthExperiments.Account.styles' );
1082            $out->addHTML( self::getZeroContributionsHtml( $sp ) );
1083        }
1084    }
1085
1086    /**
1087     * @param User $user
1088     */
1089    public function onConfirmEmailComplete( $user ) {
1090        // context user is used for cases when someone else than $user confirms the email,
1091        // and that user doesn't have homepage enabled
1092        if ( self::isHomepageEnabled( RequestContext::getMain()->getUser() ) ) {
1093            RequestContext::getMain()->getOutput()
1094                ->redirect( SpecialPage::getTitleFor( 'Homepage' )
1095                    ->getFullUrlForRedirect( [
1096                        'source' => self::CONFIRMEMAIL_QUERY_PARAM,
1097                        'namespace' => NS_SPECIAL
1098                    ] )
1099            );
1100        }
1101    }
1102
1103    /**
1104     * @param string &$siteNotice
1105     * @param Skin $skin
1106     * @return bool|void
1107     * @throws ConfigException
1108     */
1109    public function onSiteNoticeAfter( &$siteNotice, $skin ) {
1110        global $wgMinervaEnableSiteNotice;
1111        if ( self::isHomepageEnabled( $skin->getUser() ) ) {
1112            $siteNoticeGenerator = new SiteNoticeGenerator(
1113                $this->experimentUserManager,
1114                $this->userOptionsLookup,
1115                $this->jobQueueGroup
1116            );
1117            return $siteNoticeGenerator->setNotice(
1118                $skin->getRequest()->getVal( 'source' ),
1119                $siteNotice,
1120                $skin,
1121                $wgMinervaEnableSiteNotice
1122            );
1123        }
1124    }
1125
1126    /**
1127     * @inheritDoc
1128     * Update link recommendation data in the search index. Used to deindex pages after they
1129     * have been edited (and thus the recommendation does not apply anymore).
1130     */
1131    public function onSearchDataForIndex2(
1132        array &$fields,
1133        ContentHandler $handler,
1134        WikiPage $page,
1135        ParserOutput $output,
1136        SearchEngine $engine,
1137        RevisionRecord $revision
1138    ) {
1139        $this->doSearchDataForIndex( $fields, $page, $revision );
1140    }
1141
1142    /**
1143     * @inheritDoc
1144     * Update link recommendation data in the search index. Used to deindex pages after they
1145     * have been edited (and thus the recommendation does not apply anymore).
1146     */
1147    public function onSearchDataForIndex( &$fields, $handler, $page, $output, $engine ) {
1148        if ( !$this->config->get( 'GENewcomerTasksLinkRecommendationsEnabled' ) ) {
1149            return;
1150        }
1151        $revision = $page->getRevisionRecord();
1152        if ( $revision === null ) {
1153            // should not happen
1154            return;
1155        }
1156        $this->doSearchDataForIndex( $fields, $page, $revision );
1157    }
1158
1159    /**
1160     * Visible for testing
1161     *
1162     * @param array &$fields
1163     * @param WikiPage $page
1164     * @param RevisionRecord $revision
1165     */
1166    public function doSearchDataForIndex( array &$fields, WikiPage $page, RevisionRecord $revision ): void {
1167        if ( !$this->config->get( 'GENewcomerTasksLinkRecommendationsEnabled' ) ) {
1168            return;
1169        }
1170        if ( $this->config->get( 'GETempLinkRecommendationSwitchTagClearHook' ) ) {
1171            return;
1172        }
1173        $revId = $revision->getId();
1174        if ( !$this->canAccessPrimary ) {
1175            // A GET request; the hook might be called for diagnostic purposes, e.g. via
1176            // CirrusSearch\Api\QueryBuildDocument, but not for anything important.
1177            return;
1178        }
1179
1180        // The hook is called after edits, but also on purges or edits to transcluded content,
1181        // so we mustn't delete recommendations that are still valid. Checking whether there is any
1182        // recommendation stored for the current revision should do the trick.
1183        //
1184        // Both revision IDs might be incorrect due to replication lag but usually it won't
1185        // matter. If $page is being edited, the cache has already been refreshed and $revId
1186        // is correct, so we are guaranteed to end up on the delete branch. If this is a purge
1187        // or other re-rendering-related update, and the page has been edited very recently,
1188        // and it already has a recommendation (so the real recommendation revision is larger
1189        // than what we see), we need to avoid erroneously deleting the recommendation - since
1190        // new recommendations are added to the search index asynchronously, it would result
1191        // in the DB and search index getting out of sync.
1192        $linkRecommendation = $this->linkRecommendationStore->getByLinkTarget( $page->getTitle(),
1193            IDBAccessObject::READ_NORMAL, true );
1194        if ( $linkRecommendation && $linkRecommendation->getRevisionId() < $revId ) {
1195            $linkRecommendation = $this->linkRecommendationStore->getByLinkTarget( $page->getTitle(),
1196                IDBAccessObject::READ_LATEST, true );
1197        }
1198        if ( $linkRecommendation && $linkRecommendation->getRevisionId() < $revId ) {
1199            $fields[WeightedTagsHooks::FIELD_NAME][] = LinkRecommendationTaskTypeHandler::WEIGHTED_TAG_PREFIX
1200                . '/' . CirrusIndexField::MULTILIST_DELETE_GROUPING;
1201            try {
1202                $this->linkRecommendationHelper->deleteLinkRecommendation(
1203                    $page->getTitle()->toPageIdentity(), false );
1204            } catch ( DBReadOnlyError $e ) {
1205                // Leaving a dangling DB row behind doesn't cause any problems so just ignore this.
1206            }
1207        }
1208    }
1209
1210    private function clearLinkRecommendationRecordForPage( WikiPage $wikiPage ): void {
1211        try {
1212            $this->linkRecommendationHelper->deleteLinkRecommendation(
1213                $wikiPage->getTitle()->toPageIdentity(), true, true );
1214        } catch ( DBReadOnlyError $e ) {
1215            // Leaving a dangling DB row behind doesn't cause any problems so just ignore this.
1216        }
1217    }
1218
1219    /**
1220     * ResourceLoader callback used by our custom ResourceLoaderFileModuleWithLessVars class.
1221     * @param RL\Context $context
1222     * @return array An array of LESS variables
1223     */
1224    public static function lessCallback( RL\Context $context ) {
1225        $isMobile = $context->getSkin() === 'minerva';
1226        return [
1227            // used in Homepage.SuggestedEdits.less
1228            'cardContainerWrapperHeight' => $isMobile ? '16em' : '20.5em',
1229            'cardImageHeight' => $isMobile ? '128px' : '188px',
1230            'cardWrapperWidth' => $isMobile ? '260px' : '332px',
1231            'cardWrapperWidthLegacy' => '260px',
1232            'cardWrapperPadding' => $isMobile ? '0' : '8px',
1233            'cardWrapperBorderRadius' => $isMobile ? '0' : '2px',
1234            'cardContentTextPadding' => $isMobile ? '0 16px 8px 16px' : '0 8px',
1235            'cardExtractHeight' => $isMobile ? '4.5em' : '3em',
1236            'cardPageviewsTopPadding' => $isMobile ? '10px' : '16px',
1237            'cardPageviewsIconMarginBottom' => $isMobile ? '0' : '4px',
1238        ];
1239    }
1240
1241    /**
1242     * ResourceLoader JSON package callback for getting the task types defined on the wiki.
1243     * @param RL\Context $context
1244     * @return array
1245     *   - on success: [ task type id => task data, ... ]; see TaskType::toArray for data format.
1246     *     Note that the messages in the task data are plaintext and it is the caller's
1247     *     responsibility to escape them.
1248     *   - on error: [ '_error' => error message in wikitext format ]
1249     */
1250    public static function getTaskTypesJson( RL\Context $context ) {
1251        // Based on user variant settings, some task types might need to be hidden for the user,
1252        // but we can't access user identity here, so we return all tasks. User-specific filtering
1253        // will be done on the client side in TaskTypeAbFilter.
1254        $configurationLoader = self::getConfigurationLoaderForResourceLoader( $context );
1255        $taskTypes = $configurationLoader->loadTaskTypes();
1256        if ( $taskTypes instanceof StatusValue ) {
1257            $errorMessages = array_map(
1258                static fn ( $spec ) => $context->msg( $spec )->parse(),
1259                $taskTypes->getMessages()
1260            );
1261            return [
1262                '_error' => $errorMessages[0],
1263            ];
1264        } else {
1265            $taskTypesData = [];
1266            foreach ( $taskTypes as $taskType ) {
1267                $taskTypesData[$taskType->getId()] = $taskType->getViewData( $context );
1268            }
1269            return $taskTypesData;
1270        }
1271    }
1272
1273    /**
1274     * ResourceLoader JSON package callback for getting the default task types when the user
1275     * does not have SuggestedEdits::TASKTYPES_PREF set.
1276     * @param RL\Context $context
1277     * @return string[]
1278     */
1279    public static function getDefaultTaskTypesJson( RL\Context $context ) {
1280        // Like with getTaskTypesJson, we ignore user-specific filtering here.
1281        return SuggestedEdits::DEFAULT_TASK_TYPES;
1282    }
1283
1284    /**
1285     * ResourceLoader JSON package callback for getting the topics defined on the wiki.
1286     * Some UI elements will be disabled if this returns an empty array.
1287     * @param RL\Context $context
1288     * @return array
1289     *   - on success: [ topic id => topic data, ... ]; see Topic::toArray for data format.
1290     *     Note that the messages in the task data are plaintext and it is the caller's
1291     *     responsibility to escape them.
1292     *   - on error: [ '_error' => error message in wikitext format ]
1293     */
1294    public static function getTopicsJson( RL\Context $context ) {
1295        $configurationLoader = self::getConfigurationLoaderForResourceLoader( $context );
1296        $topics = $configurationLoader->loadTopics();
1297        if ( $topics instanceof StatusValue ) {
1298            $errorMessages = array_map(
1299                static fn ( $spec ) => $context->msg( $spec )->parse(),
1300                $topics->getMessages()
1301            );
1302            return [
1303                '_error' => $errorMessages[0],
1304            ];
1305        } else {
1306            $topicsData = [];
1307            foreach ( $topics as $topic ) {
1308                $topicsData[$topic->getId()] = $topic->getViewData( $context );
1309            }
1310            return $topicsData;
1311        }
1312    }
1313
1314    /**
1315     * ResourceLoader JSON package callback for getting the AQS domain to use.
1316     * @return stdClass
1317     */
1318    public static function getAQSConfigJson() {
1319        return MediaWikiServices::getInstance()->getService( '_GrowthExperimentsAQSConfig' );
1320    }
1321
1322    /**
1323     * ResourceLoader JSON package callback for getting config variables that are shared between
1324     * SuggestedEdits and StartEditingDialog
1325     *
1326     * @param RL\Context $context
1327     * @param Config $config
1328     * @return array
1329     */
1330    public static function getSuggestedEditsConfigJson(
1331        RL\Context $context, Config $config
1332    ) {
1333        // Note: GELinkRecommendationsEnabled / GEImageRecommendationsEnabled reflect PHP configuration.
1334        // Checking whether these task types have been disabled in community configuration is the
1335        // frontend code's responsibility (handled in TaskTypeAbFilter).
1336        return [
1337            'GESearchTaskSuggesterDefaultLimit' => SearchTaskSuggester::DEFAULT_LIMIT,
1338            'GERestbaseUrl' => Util::getRestbaseUrl( $config ),
1339            'GENewcomerTasksRemoteArticleOrigin' => $config->get( 'GENewcomerTasksRemoteArticleOrigin' ),
1340            'GEHomepageSuggestedEditsIntroLinks' => self::getGrowthWikiConfig()
1341                ->get( 'GEHomepageSuggestedEditsIntroLinks' ),
1342            'GENewcomerTasksTopicFiltersPref' => SuggestedEdits::getTopicFiltersPref( $config ),
1343            'GELinkRecommendationsEnabled' => $config->get( 'GENewcomerTasksLinkRecommendationsEnabled' )
1344                && $config->get( 'GELinkRecommendationsFrontendEnabled' ),
1345            'GEImageRecommendationsEnabled' => $config->get( 'GENewcomerTasksImageRecommendationsEnabled' ),
1346            'GENewcomerTasksSectionImageRecommendationsEnabled' =>
1347                $config->get( 'GENewcomerTasksSectionImageRecommendationsEnabled' ),
1348        ];
1349    }
1350
1351    /**
1352     * Helper method for ResourceLoader callbacks.
1353     *
1354     * @param RL\Context $context
1355     * @return ConfigurationLoader
1356     */
1357    private static function getConfigurationLoaderForResourceLoader(
1358        RL\Context $context
1359    ): ConfigurationLoader {
1360        $growthServices = GrowthExperimentsServices::wrap( MediaWikiServices::getInstance() );
1361        // Hack - RL\Context is not exposed to services initialization
1362        $configurationValidator = $growthServices->getNewcomerTasksConfigurationValidator();
1363        $configurationValidator->setMessageLocalizer( $context );
1364        return $growthServices->getNewcomerTasksConfigurationLoader();
1365    }
1366
1367    /**
1368     * @param User $user
1369     * @return bool
1370     */
1371    private function userHasPersonalToolsPrefEnabled( User $user ): bool {
1372        return $user->isNamed() &&
1373            $this->userOptionsLookup->getBoolOption( $user, self::HOMEPAGE_PREF_PT_LINK );
1374    }
1375
1376    /**
1377     * Check whether the user has disabled VisualEditor while it's in beta
1378     *
1379     * @param UserIdentity $user
1380     * @param UserOptionsLookup $userOptionsLookup
1381     * @return bool
1382     */
1383    private static function userHasDisabledVe(
1384        UserIdentity $user,
1385        UserOptionsLookup $userOptionsLookup
1386    ): bool {
1387        return $userOptionsLookup->getBoolOption( $user, self::VE_PREF_DISABLE_BETA );
1388    }
1389
1390    /**
1391     * Check whether the user prefers source editor
1392     *
1393     * @param UserIdentity $user
1394     * @param UserOptionsLookup $userOptionsLookup
1395     * @return bool
1396     */
1397    private static function userPrefersSourceEditor(
1398        UserIdentity $user,
1399        UserOptionsLookup $userOptionsLookup
1400    ): bool {
1401        return $userOptionsLookup->getOption( $user, self::VE_PREF_EDITOR ) === 'prefer-wt';
1402    }
1403
1404    /**
1405     * Get URL to Special:Homepage with query parameters appended for EventLogging.
1406     * @param int $namespace
1407     * @return string
1408     */
1409    private function getPersonalToolsHomepageLinkUrl( int $namespace ): string {
1410        return $this->titleFactory->newFromLinkTarget(
1411            new TitleValue( NS_SPECIAL, $this->specialPageFactory->getLocalNameFor( 'Homepage' ) )
1412        )->getLinkURL(
1413            'source=personaltoolslink&namespace=' . $namespace
1414        );
1415    }
1416
1417    /**
1418     * @inheritDoc
1419     * This gets called when the autocomment is rendered.
1420     *
1421     * For add link and add image tasks, localize the edit summary in the viewer's language.
1422     */
1423    public function onFormatAutocomments( &$comment, $pre, $auto, $post, $title,
1424                                          $local, $wikiId
1425    ) {
1426        $allowedMessageKeys = [
1427            'growthexperiments-addlink-summary-summary',
1428            'growthexperiments-addimage-summary-summary',
1429            'growthexperiments-addsectionimage-summary-summary',
1430        ];
1431        $messageParts = explode( ':', $auto, 2 );
1432        $messageKey = $messageParts[ 0 ];
1433        if ( in_array( $messageKey, $allowedMessageKeys ) ) {
1434            $messageParamsStr = $messageParts[ 1 ] ?? '';
1435            $comment = wfMessage( $messageKey )
1436                ->numParams( ...explode( '|', $messageParamsStr ) )
1437                ->parse();
1438        }
1439    }
1440
1441    /**
1442     * Update the user's editor preference based on the given task type and whether the user prefers
1443     * the source editor. The preference is not saved so the override doesn't persist beyond
1444     * the suggested edit session.
1445     *
1446     * @param TaskType $taskType
1447     * @param UserIdentity $user
1448     */
1449    private function maybeOverridePreferredEditorWithVE(
1450        TaskType $taskType, UserIdentity $user
1451    ): void {
1452        if ( $taskType->shouldOpenInEditMode() ) {
1453            return;
1454        }
1455
1456        if ( self::userPrefersSourceEditor( $user, $this->userOptionsManager ) ) {
1457            return;
1458        }
1459
1460        if ( self::userHasDisabledVe( $user, $this->userOptionsManager ) ) {
1461            return;
1462        }
1463
1464        $this->userOptionsManager->setOption(
1465            $user,
1466            self::VE_PREF_EDITOR, 'visualeditor'
1467        );
1468    }
1469
1470    /**
1471     * @param SearchConfig $config
1472     * @param array &$extraFeatures Array holding KeywordFeature objects
1473     */
1474    public static function onCirrusSearchAddQueryFeatures( SearchConfig $config, array &$extraFeatures ) {
1475        $mwServices = MediaWikiServices::getInstance();
1476        $growthServices = GrowthExperimentsServices::wrap( $mwServices );
1477        $configurationLoader = $growthServices->getNewcomerTasksConfigurationLoader();
1478        $taskTypes = $configurationLoader->getTaskTypes();
1479        $infoboxTemplates = $configurationLoader->loadInfoboxTemplates();
1480        // FIXME remove once GrowthExperiments drops support for CC1.0
1481        if ( $infoboxTemplates instanceof StatusValue ) {
1482            $infoboxTemplates = $growthServices->getGrowthWikiConfig()->get( 'GEInfoboxTemplates' );
1483        }
1484        $infoboxTemplatesTest = $growthServices->getGrowthWikiConfig()->get( 'GEInfoboxTemplatesTest' );
1485        $templateCollectionFeature = new TemplateCollectionFeature(
1486            'infobox', $infoboxTemplates, $mwServices->getTitleFactory()
1487        );
1488        if ( $infoboxTemplatesTest ) {
1489            $templateCollectionFeature->addCollection( 'infoboxtest', $infoboxTemplatesTest );
1490        }
1491        foreach ( $taskTypes as $taskType ) {
1492            if ( $taskType instanceof TemplateBasedTaskType ) {
1493                $templateCollectionFeature->addCollection( $taskType->getId(), $taskType->getTemplates() );
1494            }
1495        }
1496        $extraFeatures[] = $templateCollectionFeature;
1497
1498        // FIXME T301030 remove when campaign is over
1499        $extraFeatures[] = new GrowthArticleTopicFeature();
1500    }
1501
1502    /** @inheritDoc */
1503    public function onPageSaveComplete( $wikiPage, $user, $summary, $flags, $revisionRecord, $editResult ): void {
1504        if (
1505            $this->config->get( 'GENewcomerTasksLinkRecommendationsEnabled' ) &&
1506            $this->config->get( 'GETempLinkRecommendationSwitchTagClearHook' ) &&
1507            $wikiPage->getNamespace() === NS_MAIN
1508        ) {
1509            $this->clearLinkRecommendationRecordForPage( $wikiPage );
1510        }
1511
1512        if ( $editResult->isRevert() ) {
1513            $this->trackRevertedNewcomerTaskEdit( $editResult );
1514        }
1515    }
1516
1517    /** @inheritDoc */
1518    public function onRecentChange_save( $recentChange ) {
1519        $context = RequestContext::getMain();
1520        $request = $context->getRequest();
1521        $user = $recentChange->getPerformerIdentity();
1522        if ( !$this->userIdentityUtils->isNamed( $user ) ) {
1523            return;
1524        }
1525        $plugins = $request->getVal( 'plugins', '' );
1526        if ( !$plugins ) {
1527            return;
1528        }
1529        $pluginData = json_decode( $request->getVal( 'data-' . $plugins, '' ), true );
1530
1531        if ( !$pluginData || !isset( $pluginData['taskType'] ) ) {
1532            return;
1533        }
1534        if ( !SuggestedEdits::isEnabledForAnyone( $context->getConfig() ) ) {
1535            return;
1536        }
1537        if ( SuggestedEdits::isActivated( $context->getUser(), $this->userOptionsLookup ) ) {
1538            $taskTypeId = $pluginData['taskType'];
1539            $tags = $this->newcomerTasksChangeTagsManager->getTags(
1540                $taskTypeId,
1541                $recentChange->getPerformerIdentity()
1542            );
1543            if ( $tags->isGood() ) {
1544                $recentChange->addTags( $tags->getValue() );
1545            }
1546        }
1547    }
1548
1549    /**
1550     * @param array $function Function definition. The 'type' field holds the function name.
1551     * @param SearchContext $context
1552     * @param BoostFunctionBuilder|null &$builder Score builder output variable.
1553     * @return bool|void
1554     * @see https://www.mediawiki.org/wiki/Extension:CirrusSearch/Hooks/CirrusSearchScoreBuilder
1555     */
1556    public function onCirrusSearchScoreBuilder(
1557        array $function,
1558        SearchContext $context,
1559        ?BoostFunctionBuilder &$builder
1560    ) {
1561        if ( $function['type'] === UnderlinkedFunctionScoreBuilder::TYPE ) {
1562            $taskTypes = $this->configurationLoader->getTaskTypes();
1563            $linkRecommendationTaskType = $taskTypes[LinkRecommendationTaskTypeHandler::TASK_TYPE_ID] ?? null;
1564            if ( $linkRecommendationTaskType instanceof LinkRecommendationTaskType ) {
1565                $builder = new UnderlinkedFunctionScoreBuilder(
1566                    $linkRecommendationTaskType->getUnderlinkedWeight(),
1567                    $linkRecommendationTaskType->getUnderlinkedMinLength()
1568                );
1569                return false;
1570            }
1571            // Not doing anything will result in a Cirrus error about a non-existent function type,
1572            // which seems like a reasonable way to handle the case of using underlinked weighting
1573            // on a wiki with no link recommendation task type.
1574        }
1575    }
1576
1577    /** @inheritDoc */
1578    public function onContributeCards( array &$cards ): void {
1579        $userIdentity = $this->userIdentity ?? RequestContext::getMain()->getUser();
1580        if ( !$this->isHomepageEnabledGloballyAndForUser( $userIdentity ) ) {
1581            return;
1582        }
1583        $messageLocalizer = $this->messageLocalizer ?? RequestContext::getMain();
1584        $homepageTitle = $this->titleFactory->newFromLinkTarget(
1585            new TitleValue( NS_SPECIAL, $this->specialPageFactory->getLocalNameFor( 'Homepage' ) )
1586        );
1587        $homepageTitle->setFragment( '#/homepage/suggested-edits' );
1588        $cards[] = ( new ContributeCard(
1589            $messageLocalizer->msg( 'growthexperiments-homepage-special-contribute-title' )->text(),
1590            $messageLocalizer->msg( 'growthexperiments-homepage-special-contribute-description' )->text(),
1591            'lightbulb',
1592            new ContributeCardActionLink(
1593                $homepageTitle->getLinkURL( wfArrayToCgi( [
1594                    'source' => 'specialcontribute',
1595                    'namespace' => NS_SPECIAL,
1596                    // on mobile, avoids the flash of Special:Homepage before routing to the
1597                    // Suggested Edits overlay. On desktop, has no effect.
1598                    'overlay' => 1
1599                ] ) ),
1600                $messageLocalizer->msg( 'growthexperiments-homepage-special-contribute-cta' )->text(),
1601            )
1602        ) )->toArray();
1603        $out = $this->outputPage ?? RequestContext::getMain()->getOutput();
1604        $out->addModuleStyles( 'oojs-ui.styles.icons-interactions' );
1605    }
1606
1607    /**
1608     * Allow setting a MessageLocalizer for the class. For testing purposes.
1609     *
1610     * @param MessageLocalizer $messageLocalizer
1611     * @return void
1612     */
1613    public function setMessageLocalizer( MessageLocalizer $messageLocalizer ): void {
1614        $this->messageLocalizer = $messageLocalizer;
1615    }
1616
1617    /**
1618     * Allow setting an OutputPage for the class. For testing purposes.
1619     *
1620     * @param OutputPage $outputPage
1621     * @return void
1622     */
1623    public function setOutputPage( OutputPage $outputPage ): void {
1624        $this->outputPage = $outputPage;
1625    }
1626
1627    /**
1628     * Allow setting the active UserIdentity for the class. For testing purposes.
1629     *
1630     * @param UserIdentity $userIdentity
1631     * @return void
1632     */
1633    public function setUserIdentity( UserIdentity $userIdentity ): void {
1634        $this->userIdentity = $userIdentity;
1635    }
1636
1637    private function trackRevertedNewcomerTaskEdit( EditResult $editResult ): void {
1638        $revId = $editResult->getNewestRevertedRevisionId();
1639        if ( !$revId ) {
1640            return;
1641        }
1642        $tags = MediaWikiServices::getInstance()->getChangeTagsStore()->getTags(
1643            $this->lb->getConnection( DB_REPLICA ),
1644            null,
1645            $revId
1646        );
1647        $growthTasksChangeTags = array_merge(
1648            TemplateBasedTaskTypeHandler::NEWCOMER_TASK_TEMPLATE_BASED_ALL_CHANGE_TAGS,
1649            [
1650                LinkRecommendationTaskTypeHandler::CHANGE_TAG,
1651                ImageRecommendationTaskTypeHandler::CHANGE_TAG,
1652                SectionImageRecommendationTaskTypeHandler::CHANGE_TAG,
1653            ]
1654        );
1655        foreach ( $tags as $tag ) {
1656            // We can use more precise tags, skip this generic one applied to all suggested edits.
1657            if ( $tag === TaskTypeHandler::NEWCOMER_TASK_TAG ||
1658                // ...but make sure the tag is one we care about tracking.
1659                !in_array( $tag, $growthTasksChangeTags ) ) {
1660                continue;
1661            }
1662            // HACK: craft the task type ID from the change tag. We should probably add a method to
1663            // TaskTypeHandlerRegistry to get a TaskType from a change tag.
1664            $taskType = str_replace( 'newcomer task ', '', $tag );
1665            if ( $tag === LinkRecommendationTaskTypeHandler::CHANGE_TAG ) {
1666                $taskType = LinkRecommendationTaskTypeHandler::TASK_TYPE_ID;
1667            } elseif ( $tag === ImageRecommendationTaskTypeHandler::CHANGE_TAG ) {
1668                $taskType = ImageRecommendationTaskTypeHandler::TASK_TYPE_ID;
1669            } elseif ( $tag === SectionImageRecommendationTaskTypeHandler::CHANGE_TAG ) {
1670                $taskType = SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID;
1671            }
1672            $this->perDbNameStatsdDataFactory->increment(
1673                sprintf( 'GrowthExperiments.NewcomerTask.Reverted.%s', $taskType )
1674            );
1675        }
1676    }
1677}