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