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