Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
2.92% |
21 / 720 |
|
4.00% |
2 / 50 |
CRAP | |
0.00% |
0 / 1 |
HomepageHooks | |
2.92% |
21 / 720 |
|
4.00% |
2 / 50 |
34276.85 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
12 | |||
onSpecialPage_initList | |
0.00% |
0 / 39 |
|
0.00% |
0 / 1 |
12 | |||
isHomepageEnabled | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
12 | |||
isHomepageEnabledGloballyAndForUser | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
getClickId | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
onWikimediaEventsShouldSchemaEditAttemptStepOversample | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
onBeforePageDisplay | |
0.00% |
0 / 84 |
|
0.00% |
0 / 1 |
380 | |||
onSkinMinervaOptionsInit | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
onSkinTemplateNavigation__Universal | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
240 | |||
titleIsUserPageOrUserTalk | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
personalUrlsBuilder | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
onGetPreferences | |
0.00% |
0 / 62 |
|
0.00% |
0 / 1 |
20 | |||
onUserGetDefaultOptions | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
2 | |||
onResourceLoaderExcludeUserOptions | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
onAuthChangeFormFields | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
6 | |||
getGrowthFeaturesOptInOptOutOverride | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
onLocalUserCreated | |
0.00% |
0 / 80 |
|
0.00% |
0 / 1 |
420 | |||
onListDefinedTags | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
onChangeTagsListActive | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
updateProfileMenuEntry | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
6 | |||
updateHomeMenuEntry | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
onSidebarBeforeOutput | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
getZeroContributionsHtml | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
2 | |||
onSpecialContributionsBeforeMainOutput | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
onConfirmEmailComplete | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
onSiteNoticeAfter | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
onSearchDataForIndex2 | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
onSearchDataForIndex | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
doSearchDataForIndex | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
72 | |||
lessCallback | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
110 | |||
getTaskTypesJson | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
getDefaultTaskTypesJson | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getTopicsJson | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
12 | |||
getAQSConfigJson | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getSuggestedEditsConfigJson | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
6 | |||
getConfigurationLoaderForResourceLoader | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
userHasPersonalToolsPrefEnabled | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
userHasDisabledVe | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
userPrefersSourceEditor | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getPersonalToolsHomepageLinkUrl | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
onFormatAutocomments | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
maybeOverridePreferredEditorWithVE | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
onCirrusSearchAddQueryFeatures | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
20 | |||
onPageSaveComplete | |
0.00% |
0 / 31 |
|
0.00% |
0 / 1 |
90 | |||
onRecentChange_save | |
38.10% |
8 / 21 |
|
0.00% |
0 / 1 |
23.18 | |||
onCirrusSearchScoreBuilder | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
onContributeCards | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
6 | |||
setMessageLocalizer | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setOutputPage | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setUserIdentity | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | // phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName |
3 | |
4 | namespace GrowthExperiments; |
5 | |
6 | use ChangeTags; |
7 | use CirrusSearch\Search\CirrusIndexField; |
8 | use CirrusSearch\Search\Rescore\BoostFunctionBuilder; |
9 | use CirrusSearch\Search\SearchContext; |
10 | use CirrusSearch\SearchConfig; |
11 | use CirrusSearch\Wikimedia\WeightedTagsHooks; |
12 | use ContentHandler; |
13 | use ExtensionRegistry; |
14 | use GrowthExperiments\Config\GrowthConfigLoaderStaticTrait; |
15 | use GrowthExperiments\Homepage\SiteNoticeGenerator; |
16 | use GrowthExperiments\HomepageModules\Help; |
17 | use GrowthExperiments\HomepageModules\Mentorship; |
18 | use GrowthExperiments\HomepageModules\SuggestedEdits; |
19 | use GrowthExperiments\LevelingUp\LevelingUpHooks; |
20 | use GrowthExperiments\LevelingUp\LevelingUpManager; |
21 | use GrowthExperiments\LevelingUp\NotificationGetStartedJob; |
22 | use GrowthExperiments\LevelingUp\NotificationKeepGoingJob; |
23 | use GrowthExperiments\Mentorship\MentorPageMentorManager; |
24 | use GrowthExperiments\NewcomerTasks\AddLink\LinkRecommendationHelper; |
25 | use GrowthExperiments\NewcomerTasks\AddLink\LinkRecommendationStore; |
26 | use GrowthExperiments\NewcomerTasks\CampaignConfig; |
27 | use GrowthExperiments\NewcomerTasks\ConfigurationLoader\ConfigurationLoader; |
28 | use GrowthExperiments\NewcomerTasks\GrowthArticleTopicFeature; |
29 | use GrowthExperiments\NewcomerTasks\NewcomerTasksChangeTagsManager; |
30 | use GrowthExperiments\NewcomerTasks\NewcomerTasksInfo; |
31 | use GrowthExperiments\NewcomerTasks\NewcomerTasksUserOptionsLookup; |
32 | use GrowthExperiments\NewcomerTasks\Recommendation; |
33 | use GrowthExperiments\NewcomerTasks\Task\TaskSet; |
34 | use GrowthExperiments\NewcomerTasks\Task\TaskSetFilters; |
35 | use GrowthExperiments\NewcomerTasks\TaskSuggester\SearchStrategy\SearchStrategy; |
36 | use GrowthExperiments\NewcomerTasks\TaskSuggester\SearchTaskSuggester; |
37 | use GrowthExperiments\NewcomerTasks\TaskSuggester\TaskSuggesterFactory; |
38 | use GrowthExperiments\NewcomerTasks\TaskSuggester\UnderlinkedFunctionScoreBuilder; |
39 | use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationTaskTypeHandler; |
40 | use GrowthExperiments\NewcomerTasks\TaskType\LinkRecommendationTaskType; |
41 | use GrowthExperiments\NewcomerTasks\TaskType\LinkRecommendationTaskTypeHandler; |
42 | use GrowthExperiments\NewcomerTasks\TaskType\SectionImageRecommendationTaskTypeHandler; |
43 | use GrowthExperiments\NewcomerTasks\TaskType\StructuredTaskTypeHandler; |
44 | use GrowthExperiments\NewcomerTasks\TaskType\TaskType; |
45 | use GrowthExperiments\NewcomerTasks\TaskType\TaskTypeHandler; |
46 | use GrowthExperiments\NewcomerTasks\TaskType\TaskTypeHandlerRegistry; |
47 | use GrowthExperiments\NewcomerTasks\TaskType\TemplateBasedTaskType; |
48 | use GrowthExperiments\NewcomerTasks\TaskType\TemplateBasedTaskTypeHandler; |
49 | use GrowthExperiments\Specials\SpecialClaimMentee; |
50 | use GrowthExperiments\Specials\SpecialHomepage; |
51 | use GrowthExperiments\Specials\SpecialImpact; |
52 | use GrowthExperiments\Specials\SpecialNewcomerTasksInfo; |
53 | use GrowthExperiments\UserImpact\UserImpactLookup; |
54 | use GrowthExperiments\UserImpact\UserImpactStore; |
55 | use IContextSource; |
56 | use IDBAccessObject; |
57 | use JobQueueGroup; |
58 | use JobSpecification; |
59 | use MediaWiki\Auth\Hook\LocalUserCreatedHook; |
60 | use MediaWiki\ChangeTags\Hook\ChangeTagsListActiveHook; |
61 | use MediaWiki\ChangeTags\Hook\ListDefinedTagsHook; |
62 | use MediaWiki\Config\Config; |
63 | use MediaWiki\Config\ConfigException; |
64 | use MediaWiki\Content\Hook\SearchDataForIndexHook; |
65 | use MediaWiki\Deferred\DeferredUpdates; |
66 | use MediaWiki\Hook\BeforePageDisplayHook; |
67 | use MediaWiki\Hook\FormatAutocommentsHook; |
68 | use MediaWiki\Hook\RecentChange_saveHook; |
69 | use MediaWiki\Hook\SidebarBeforeOutputHook; |
70 | use MediaWiki\Hook\SiteNoticeAfterHook; |
71 | use MediaWiki\Hook\SkinTemplateNavigation__UniversalHook; |
72 | use MediaWiki\Hook\SpecialContributionsBeforeMainOutputHook; |
73 | use MediaWiki\Html\Html; |
74 | use MediaWiki\MediaWikiServices; |
75 | use MediaWiki\Minerva\SkinOptions; |
76 | use MediaWiki\Output\OutputPage; |
77 | use MediaWiki\Parser\ParserOutput; |
78 | use MediaWiki\Preferences\Hook\GetPreferencesHook; |
79 | use MediaWiki\ResourceLoader as RL; |
80 | use MediaWiki\ResourceLoader\Hook\ResourceLoaderExcludeUserOptionsHook; |
81 | use MediaWiki\Revision\RevisionRecord; |
82 | use MediaWiki\SpecialPage\Hook\AuthChangeFormFieldsHook; |
83 | use MediaWiki\SpecialPage\Hook\SpecialPage_initListHook; |
84 | use MediaWiki\SpecialPage\SpecialPage; |
85 | use MediaWiki\SpecialPage\SpecialPageFactory; |
86 | use MediaWiki\Specials\Contribute\Card\ContributeCard; |
87 | use MediaWiki\Specials\Contribute\Card\ContributeCardActionLink; |
88 | use MediaWiki\Specials\Contribute\Hook\ContributeCardsHook; |
89 | use MediaWiki\Specials\SpecialContributions; |
90 | use MediaWiki\Status\Status; |
91 | use MediaWiki\Storage\Hook\PageSaveCompleteHook; |
92 | use MediaWiki\Title\NamespaceInfo; |
93 | use MediaWiki\Title\Title; |
94 | use MediaWiki\Title\TitleFactory; |
95 | use MediaWiki\Title\TitleValue; |
96 | use MediaWiki\User\Hook\ConfirmEmailCompleteHook; |
97 | use MediaWiki\User\Hook\UserGetDefaultOptionsHook; |
98 | use MediaWiki\User\Options\UserOptionsLookup; |
99 | use MediaWiki\User\Options\UserOptionsManager; |
100 | use MediaWiki\User\User; |
101 | use MediaWiki\User\UserIdentity; |
102 | use MediaWiki\User\UserIdentityUtils; |
103 | use MessageLocalizer; |
104 | use OOUI\ButtonWidget; |
105 | use PrefixingStatsdDataFactoryProxy; |
106 | use RequestContext; |
107 | use SearchEngine; |
108 | use Skin; |
109 | use SkinTemplate; |
110 | use StatusValue; |
111 | use stdClass; |
112 | use Wikimedia\Rdbms\DBReadOnlyError; |
113 | use Wikimedia\Rdbms\ILoadBalancer; |
114 | use 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 | */ |
121 | class 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 | } |