Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
33.33% covered (danger)
33.33%
85 / 255
6.67% covered (danger)
6.67%
1 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialHomepage
33.33% covered (danger)
33.33%
85 / 255
6.67% covered (danger)
6.67%
1 / 15
1054.74
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 10
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.52% covered (warning)
88.52%
54 / 61
0.00% covered (danger)
0.00%
0 / 1
9.12
 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 / 8
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
57.78% covered (warning)
57.78%
26 / 45
0.00% covered (danger)
0.00%
0 / 1
22.84
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 Throwable;
28use UserNotLoggedIn;
29use Wikimedia\Stats\IBufferingStatsdDataFactory;
30use Wikimedia\Stats\PrefixingStatsdDataFactoryProxy;
31
32class 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}