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