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