Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
37.44% |
85 / 227 |
|
7.14% |
1 / 14 |
CRAP | |
0.00% |
0 / 1 |
SpecialHomepage | |
37.44% |
85 / 227 |
|
7.14% |
1 / 14 |
795.48 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
88.71% |
55 / 62 |
|
0.00% |
0 / 1 |
9.12 | |||
handleDisabledPreference | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
getDescription | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getModules | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
56 | |||
getModuleGroups | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
generatePageviewToken | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
renderDesktop | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
30 | |||
renderMobileDetails | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
renderMobileSummary | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
12 | |||
getModuleRenderHtmlSafe | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
outputJsData | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
42 | |||
handleNewcomerTask | |
56.82% |
25 / 44 |
|
0.00% |
0 / 1 |
23.59 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\Specials; |
4 | |
5 | use ErrorPageError; |
6 | use ExtensionRegistry; |
7 | use GrowthExperiments\DashboardModule\IDashboardModule; |
8 | use GrowthExperiments\EventLogging\SpecialHomepageLogger; |
9 | use GrowthExperiments\ExperimentUserManager; |
10 | use GrowthExperiments\Homepage\HomepageModuleRegistry; |
11 | use GrowthExperiments\HomepageHooks; |
12 | use GrowthExperiments\HomepageModules\BaseModule; |
13 | use GrowthExperiments\HomepageModules\SuggestedEdits; |
14 | use GrowthExperiments\Mentorship\MentorManager; |
15 | use GrowthExperiments\TourHooks; |
16 | use GrowthExperiments\Util; |
17 | use IBufferingStatsdDataFactory; |
18 | use InvalidArgumentException; |
19 | use MediaWiki\Config\Config; |
20 | use MediaWiki\Config\ConfigException; |
21 | use MediaWiki\Deferred\DeferredUpdates; |
22 | use MediaWiki\Html\Html; |
23 | use MediaWiki\Logger\LoggerFactory; |
24 | use MediaWiki\SpecialPage\SpecialPage; |
25 | use MediaWiki\Title\TitleFactory; |
26 | use MediaWiki\User\Options\UserOptionsManager; |
27 | use PrefixingStatsdDataFactoryProxy; |
28 | use Throwable; |
29 | use UserNotLoggedIn; |
30 | |
31 | class SpecialHomepage extends SpecialPage { |
32 | |
33 | private HomepageModuleRegistry $moduleRegistry; |
34 | private IBufferingStatsdDataFactory $statsdDataFactory; |
35 | private ExperimentUserManager $experimentUserManager; |
36 | private MentorManager $mentorManager; |
37 | private Config $wikiConfig; |
38 | private UserOptionsManager $userOptionsManager; |
39 | |
40 | /** |
41 | * @var string Unique identifier for this specific rendering of Special:Homepage. |
42 | * Used by various EventLogging schemas to correlate events. |
43 | */ |
44 | private $pageviewToken; |
45 | |
46 | /** @var PrefixingStatsdDataFactoryProxy */ |
47 | private $perDbNameStatsdDataFactory; |
48 | |
49 | /** @var TitleFactory */ |
50 | private $titleFactory; |
51 | |
52 | /** |
53 | * @param HomepageModuleRegistry $moduleRegistry |
54 | * @param IBufferingStatsdDataFactory $statsdDataFactory |
55 | * @param PrefixingStatsdDataFactoryProxy $perDbNameStatsdDataFactory |
56 | * @param ExperimentUserManager $experimentUserManager |
57 | * @param MentorManager $mentorManager |
58 | * @param Config $wikiConfig |
59 | * @param UserOptionsManager $userOptionsManager |
60 | * @param TitleFactory $titleFactory |
61 | */ |
62 | public function __construct( |
63 | HomepageModuleRegistry $moduleRegistry, |
64 | IBufferingStatsdDataFactory $statsdDataFactory, |
65 | PrefixingStatsdDataFactoryProxy $perDbNameStatsdDataFactory, |
66 | ExperimentUserManager $experimentUserManager, |
67 | MentorManager $mentorManager, |
68 | Config $wikiConfig, |
69 | UserOptionsManager $userOptionsManager, |
70 | TitleFactory $titleFactory |
71 | ) { |
72 | parent::__construct( 'Homepage', '', false ); |
73 | $this->moduleRegistry = $moduleRegistry; |
74 | $this->statsdDataFactory = $statsdDataFactory; |
75 | $this->pageviewToken = $this->generatePageviewToken(); |
76 | $this->experimentUserManager = $experimentUserManager; |
77 | $this->mentorManager = $mentorManager; |
78 | $this->wikiConfig = $wikiConfig; |
79 | $this->userOptionsManager = $userOptionsManager; |
80 | $this->perDbNameStatsdDataFactory = $perDbNameStatsdDataFactory; |
81 | $this->titleFactory = $titleFactory; |
82 | } |
83 | |
84 | /** @inheritDoc */ |
85 | protected function getGroupName() { |
86 | return 'growth-tools'; |
87 | } |
88 | |
89 | /** |
90 | * @inheritDoc |
91 | * @param string $par |
92 | * @throws ConfigException |
93 | * @throws ErrorPageError |
94 | * @throws UserNotLoggedIn |
95 | */ |
96 | public function execute( $par = '' ) { |
97 | $startTime = microtime( true ); |
98 | // Use in client-side performance instrumentation; export as milliseconds as that is what mw.now() uses. |
99 | $this->getOutput()->addJsConfigVars( 'GEHomepageStartTime', round( $startTime * 1000 ) ); |
100 | $this->requireNamedUser(); |
101 | parent::execute( $par ); |
102 | $this->handleDisabledPreference(); |
103 | // Redirect the user to the newcomer task if the page ID in $par can be used |
104 | // to construct a Title object. |
105 | if ( $this->handleNewcomerTask( $par ) ) { |
106 | return; |
107 | } |
108 | |
109 | $out = $this->getContext()->getOutput(); |
110 | $isMobile = Util::isMobile( $out->getSkin() ); |
111 | $loggingEnabled = $this->getConfig()->get( 'GEHomepageLoggingEnabled' ); |
112 | $userVariant = $this->experimentUserManager->getVariant( $this->getUser() ); |
113 | $out->addJsConfigVars( [ |
114 | 'wgGEHomepagePageviewToken' => $this->pageviewToken, |
115 | 'wgGEHomepageLoggingEnabled' => $loggingEnabled, |
116 | 'wgGEUseNewImpactModule' => $this->getConfig()->get( 'GEUseNewImpactModule' ) |
117 | ] ); |
118 | $out->addModules( 'ext.growthExperiments.Homepage' ); |
119 | $out->enableOOUI(); |
120 | $out->addModuleStyles( 'ext.growthExperiments.Homepage.styles' ); |
121 | |
122 | $out->addHTML( Html::openElement( 'div', [ |
123 | 'class' => 'growthexperiments-homepage-container ' . |
124 | 'growthexperiments-homepage-container-user-variant-' . $userVariant |
125 | ] ) ); |
126 | $modules = $this->getModules( $isMobile, $par ); |
127 | |
128 | if ( $isMobile ) { |
129 | if ( |
130 | array_key_exists( $par, $modules ) && |
131 | $modules[$par]->supports( IDashboardModule::RENDER_MOBILE_DETAILS ) |
132 | ) { |
133 | $mode = IDashboardModule::RENDER_MOBILE_DETAILS; |
134 | $this->renderMobileDetails( $modules[$par] ); |
135 | } else { |
136 | $mode = IDashboardModule::RENDER_MOBILE_SUMMARY; |
137 | $this->renderMobileSummary(); |
138 | } |
139 | } else { |
140 | $mode = IDashboardModule::RENDER_DESKTOP; |
141 | Util::maybeAddGuidedTour( |
142 | $out, |
143 | TourHooks::TOUR_COMPLETED_HOMEPAGE_WELCOME, |
144 | 'ext.guidedTour.tour.homepage_welcome', |
145 | $this->userOptionsManager |
146 | ); |
147 | $this->renderDesktop(); |
148 | } |
149 | |
150 | $out->addHTML( Html::closeElement( 'div' ) ); |
151 | $this->outputJsData( $mode, $modules ); |
152 | $this->getOutput()->addBodyClasses( |
153 | 'growthexperiments-homepage-user-variant-' . |
154 | $this->experimentUserManager->getVariant( $this->getUser() ) |
155 | ); |
156 | $this->statsdDataFactory->timing( |
157 | 'timing.growthExperiments.specialHomepage.serverSideRender.' . ( $isMobile ? 'mobile' : 'desktop' ), |
158 | microtime( true ) - $startTime |
159 | ); |
160 | |
161 | if ( $loggingEnabled && |
162 | ExtensionRegistry::getInstance()->isLoaded( 'EventLogging' ) && |
163 | count( $modules ) ) { |
164 | $logger = new SpecialHomepageLogger( |
165 | $this->pageviewToken, |
166 | $this->getContext()->getUser(), |
167 | $this->getRequest(), |
168 | $isMobile, |
169 | $modules |
170 | ); |
171 | DeferredUpdates::addCallableUpdate( static function () use ( $logger ) { |
172 | $logger->log(); |
173 | } ); |
174 | } |
175 | } |
176 | |
177 | /** |
178 | * @throws ConfigException |
179 | * @throws ErrorPageError |
180 | */ |
181 | private function handleDisabledPreference() { |
182 | if ( !HomepageHooks::isHomepageEnabled( $this->getUser() ) ) { |
183 | throw new ErrorPageError( |
184 | 'growthexperiments-homepage-tab', |
185 | 'growthexperiments-homepage-enable-preference' |
186 | ); |
187 | } |
188 | } |
189 | |
190 | /** |
191 | * Overridden in order to inject the current user's name as message parameter |
192 | * |
193 | * @inheritDoc |
194 | */ |
195 | public function getDescription() { |
196 | return $this->msg( 'growthexperiments-homepage-specialpage-title' ) |
197 | ->params( $this->getUser()->getName() ); |
198 | } |
199 | |
200 | /** |
201 | * @param bool $isMobile |
202 | * @param string|null $par Path passed into SpecialHomepage::execute() |
203 | * @return BaseModule[] |
204 | */ |
205 | private function getModules( bool $isMobile, $par = '' ) { |
206 | $mentorshipState = $this->mentorManager->getMentorshipStateForUser( $this->getUser() ); |
207 | $moduleConfig = array_filter( [ |
208 | 'banner' => true, |
209 | 'welcomesurveyreminder' => true, |
210 | 'startemail' => true, |
211 | // Only load start-startediting code (the uninitiated view of suggested edits) for desktop users who |
212 | // haven't activated SE yet. |
213 | 'start-startediting' => SuggestedEdits::isEnabledForAnyone( |
214 | $this->getContext()->getConfig() |
215 | ) && ( !$par && !$isMobile && |
216 | !SuggestedEdits::isActivated( $this->getUser(), $this->userOptionsManager ) |
217 | ), |
218 | 'suggested-edits' => SuggestedEdits::isEnabled( $this->getConfig() ), |
219 | 'impact' => true, |
220 | 'mentorship' => $this->wikiConfig->get( 'GEMentorshipEnabled' ) && |
221 | $mentorshipState === MentorManager::MENTORSHIP_ENABLED, |
222 | 'mentorship-optin' => $this->wikiConfig->get( 'GEMentorshipEnabled' ) && |
223 | $mentorshipState === MentorManager::MENTORSHIP_OPTED_OUT, |
224 | 'help' => true, |
225 | ] ); |
226 | $modules = []; |
227 | foreach ( $moduleConfig as $moduleId => $_ ) { |
228 | $modules[$moduleId] = $this->moduleRegistry->get( $moduleId, $this->getContext() ); |
229 | } |
230 | return $modules; |
231 | } |
232 | |
233 | /** |
234 | * @return string[][][] |
235 | */ |
236 | private function getModuleGroups(): array { |
237 | $isSuggestedEditsEnabled = SuggestedEdits::isEnabledForAnyone( |
238 | $this->getContext()->getConfig() |
239 | ); |
240 | return [ |
241 | 'main' => [ |
242 | 'primary' => [ 'banner', 'welcomesurveyreminder', 'startemail' ], |
243 | 'secondary' => $isSuggestedEditsEnabled ? |
244 | [ 'start-startediting', 'suggested-edits' ] : |
245 | [ 'impact' ] |
246 | ], |
247 | 'sidebar' => [ |
248 | 'primary' => $isSuggestedEditsEnabled ? [ 'impact' ] : [], |
249 | 'secondary' => [ 'mentorship', 'mentorship-optin', 'help' ] |
250 | ] |
251 | ]; |
252 | } |
253 | |
254 | /** |
255 | * Returns 32-character random string. |
256 | * The token is used for client-side logging and can be retrieved on Special:Homepage via the |
257 | * wgGEHomepagePageviewToken JS variable. |
258 | * @return string |
259 | */ |
260 | private function generatePageviewToken() { |
261 | return \Wikimedia\base_convert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 ); |
262 | } |
263 | |
264 | private function renderDesktop() { |
265 | $out = $this->getContext()->getOutput(); |
266 | $modules = $this->getModules( false ); |
267 | $out->addBodyClasses( 'growthexperiments-homepage-desktop' ); |
268 | foreach ( $this->getModuleGroups() as $group => $subGroups ) { |
269 | $out->addHTML( Html::openElement( 'div', [ |
270 | 'class' => "growthexperiments-homepage-group-$group " . |
271 | "growthexperiments-homepage-group-$group-user-variant-" . |
272 | $this->experimentUserManager->getVariant( $this->getUser() ), |
273 | ] ) ); |
274 | foreach ( $subGroups as $subGroup => $moduleNames ) { |
275 | $out->addHTML( Html::openElement( 'div', [ |
276 | 'class' => "growthexperiments-homepage-group-$group-subgroup-$subGroup " . |
277 | "growthexperiments-homepage-group-$group-subgroup-$subGroup-user-variant-" . |
278 | $this->experimentUserManager->getVariant( $this->getUser() ) |
279 | ] ) ); |
280 | foreach ( $moduleNames as $moduleName ) { |
281 | /** @var IDashboardModule $module */ |
282 | $module = $modules[$moduleName] ?? null; |
283 | if ( !$module ) { |
284 | continue; |
285 | } |
286 | $module->setPageURL( $this->getPageTitle()->getLinkURL() ); |
287 | $html = $this->getModuleRenderHtmlSafe( $module, IDashboardModule::RENDER_DESKTOP ); |
288 | $out->addHTML( $html ); |
289 | } |
290 | $out->addHTML( Html::closeElement( 'div' ) ); |
291 | } |
292 | $out->addHTML( Html::closeElement( 'div' ) ); |
293 | } |
294 | } |
295 | |
296 | /** |
297 | * @param IDashboardModule $module |
298 | */ |
299 | private function renderMobileDetails( IDashboardModule $module ) { |
300 | $out = $this->getContext()->getOutput(); |
301 | $out->addBodyClasses( 'growthexperiments-homepage-mobile-details' ); |
302 | $html = $this->getModuleRenderHtmlSafe( $module, IDashboardModule::RENDER_MOBILE_DETAILS ); |
303 | $this->getOutput()->addHTML( $html ); |
304 | } |
305 | |
306 | private function renderMobileSummary() { |
307 | $out = $this->getContext()->getOutput(); |
308 | $modules = $this->getModules( true ); |
309 | $isOpeningOverlay = $this->getContext()->getRequest()->getFuzzyBool( 'overlay' ); |
310 | $out->addBodyClasses( [ |
311 | 'growthexperiments-homepage-mobile-summary', |
312 | $isOpeningOverlay ? 'growthexperiments-homepage-mobile-summary--opening-overlay' : '' |
313 | ] ); |
314 | foreach ( $modules as $moduleName => $module ) { |
315 | $module->setPageURL( $this->getPageTitle()->getLinkURL() ); |
316 | $html = $this->getModuleRenderHtmlSafe( $module, IDashboardModule::RENDER_MOBILE_SUMMARY ); |
317 | $this->getOutput()->addHTML( $html ); |
318 | } |
319 | } |
320 | |
321 | /** |
322 | * Get the module render HTML for a particular mode, catching exceptions by default. |
323 | * |
324 | * If GEDeveloperSetup is on, then throw the exceptions. |
325 | * @param IDashboardModule $module |
326 | * @param string $mode |
327 | * @throws Throwable |
328 | * @return string |
329 | */ |
330 | private function getModuleRenderHtmlSafe( IDashboardModule $module, string $mode ): string { |
331 | $html = ''; |
332 | try { |
333 | $html = $module->render( $mode ); |
334 | } catch ( Throwable $throwable ) { |
335 | if ( $this->getConfig()->get( 'GEDeveloperSetup' ) ) { |
336 | throw $throwable; |
337 | } |
338 | Util::logException( $throwable, [ 'origin' => __METHOD__ ] ); |
339 | } |
340 | return $html; |
341 | } |
342 | |
343 | /** |
344 | * @param string $mode One of RENDER_DESKTOP, RENDER_MOBILE_SUMMARY, RENDER_MOBILE_DETAILS |
345 | * @param IDashboardModule[] $modules |
346 | */ |
347 | private function outputJsData( $mode, array $modules ) { |
348 | $out = $this->getContext()->getOutput(); |
349 | |
350 | $data = []; |
351 | $html = ''; |
352 | foreach ( $modules as $moduleName => $module ) { |
353 | try { |
354 | $data[$moduleName] = $module->getJsData( $mode ); |
355 | if ( isset( $data[$moduleName]['overlay'] ) ) { |
356 | $html .= $data[$moduleName]['overlay']; |
357 | unset( $data[$moduleName]['overlay'] ); |
358 | } |
359 | } catch ( Throwable $throwable ) { |
360 | if ( $this->getConfig()->get( 'GEDeveloperSetup' ) ) { |
361 | throw $throwable; |
362 | } |
363 | Util::logException( $throwable, [ 'origin' => __METHOD__ ] ); |
364 | } |
365 | } |
366 | $out->addJsConfigVars( 'homepagemodules', $data ); |
367 | |
368 | if ( $mode === IDashboardModule::RENDER_MOBILE_SUMMARY ) { |
369 | $out->addJsConfigVars( 'homepagemobile', true ); |
370 | $out->addModules( 'ext.growthExperiments.Homepage.mobile' ); |
371 | $out->addHTML( Html::rawElement( |
372 | 'div', |
373 | [ 'class' => 'growthexperiments-homepage-overlay-container' ], |
374 | $html |
375 | ) ); |
376 | } |
377 | } |
378 | |
379 | /** |
380 | * @param string|null $par The URL path arguments after Special:Homepage |
381 | * @return bool |
382 | */ |
383 | private function handleNewcomerTask( string $par = null ): bool { |
384 | if ( !$par || strpos( $par, 'newcomertask/' ) !== 0 || |
385 | !SuggestedEdits::isEnabled( $this->getConfig() ) ) { |
386 | return false; |
387 | } |
388 | $request = $this->getRequest(); |
389 | $titleId = (int)explode( '/', $par )[1]; |
390 | if ( !$titleId ) { |
391 | return false; |
392 | } |
393 | $title = $this->titleFactory->newFromID( $titleId ); |
394 | if ( !$title ) { |
395 | // Will bring the user back to Special:Homepage, since we couldn't load a title. |
396 | return false; |
397 | } |
398 | |
399 | $clickId = $request->getVal( 'geclickid' ); |
400 | $newcomerTaskToken = $request->getVal( 'genewcomertasktoken' ); |
401 | $taskTypeId = $request->getVal( 'getasktype', '' ); |
402 | $missing = []; |
403 | if ( !$clickId ) { |
404 | $missing[] = 'geclickid'; |
405 | } |
406 | if ( !$newcomerTaskToken ) { |
407 | $missing[] = 'genewcomertasktoken'; |
408 | } |
409 | if ( !$taskTypeId ) { |
410 | $missing[] = 'getasktype'; |
411 | } |
412 | if ( count( $missing ) ) { |
413 | // Something is broken in our client-side code; these params should always be present. |
414 | $errorMessage = sprintf( |
415 | 'Invalid parameters passed to Special:Homepage/newcomertask. Missing params: %s', |
416 | implode( ',', $missing ) |
417 | ); |
418 | LoggerFactory::getInstance( 'GrowthExperiments' )->error( $errorMessage ); |
419 | if ( $this->getConfig()->get( 'GEDeveloperSetup' ) ) { |
420 | // For developer setup wikis (local + beta/CI), throw an exception so we can |
421 | // catch the issue in testing/CI. For production, we should |
422 | // let the user go on to the task, even if we lose analytics for that interaction. |
423 | throw new InvalidArgumentException( $errorMessage ); |
424 | } |
425 | } |
426 | |
427 | $suggestedEdits = $this->getModules( Util::isMobile( $this->getSkin() ) )[ 'suggested-edits' ]; |
428 | $redirectParams = array_merge( |
429 | [ |
430 | 'getasktype' => $request->getVal( 'getasktype' ), |
431 | // This query parameter allows us to load the help panel for the suggested edit session, |
432 | // even if the user has the preference (probably unknowingly) disabled. |
433 | 'gesuggestededit' => 1, |
434 | 'geclickid' => $clickId, |
435 | 'genewcomertasktoken' => $newcomerTaskToken, |
436 | // Query parameter to show the onboarding Vue dialog |
437 | 'new-onboarding' => $request->getVal( 'new-onboarding' ) |
438 | ], |
439 | $suggestedEdits instanceof SuggestedEdits ? $suggestedEdits->getRedirectParams( $taskTypeId ) : [] |
440 | ); |
441 | $this->perDbNameStatsdDataFactory->increment( 'GrowthExperiments.NewcomerTask.' . $taskTypeId . '.Click' ); |
442 | |
443 | $this->getOutput()->redirect( |
444 | $title->getFullUrlForRedirect( $redirectParams ) |
445 | ); |
446 | return true; |
447 | } |
448 | |
449 | } |