Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
37.44% covered (danger)
37.44%
85 / 227
7.14% covered (danger)
7.14%
1 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialHomepage
37.44% covered (danger)
37.44%
85 / 227
7.14% covered (danger)
7.14%
1 / 14
795.48
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.71% covered (warning)
88.71%
55 / 62
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 / 22
0.00% covered (danger)
0.00%
0 / 1
56
 getModuleGroups
0.00% covered (danger)
0.00%
0 / 13
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 / 24
0.00% covered (danger)
0.00%
0 / 1
30
 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 / 11
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
56.82% covered (warning)
56.82%
25 / 44
0.00% covered (danger)
0.00%
0 / 1
23.59
1<?php
2
3namespace GrowthExperiments\Specials;
4
5use ErrorPageError;
6use ExtensionRegistry;
7use GrowthExperiments\DashboardModule\IDashboardModule;
8use GrowthExperiments\EventLogging\SpecialHomepageLogger;
9use GrowthExperiments\ExperimentUserManager;
10use GrowthExperiments\Homepage\HomepageModuleRegistry;
11use GrowthExperiments\HomepageHooks;
12use GrowthExperiments\HomepageModules\BaseModule;
13use GrowthExperiments\HomepageModules\SuggestedEdits;
14use GrowthExperiments\Mentorship\MentorManager;
15use GrowthExperiments\TourHooks;
16use GrowthExperiments\Util;
17use IBufferingStatsdDataFactory;
18use InvalidArgumentException;
19use MediaWiki\Config\Config;
20use MediaWiki\Config\ConfigException;
21use MediaWiki\Deferred\DeferredUpdates;
22use MediaWiki\Html\Html;
23use MediaWiki\Logger\LoggerFactory;
24use MediaWiki\SpecialPage\SpecialPage;
25use MediaWiki\Title\TitleFactory;
26use MediaWiki\User\Options\UserOptionsManager;
27use PrefixingStatsdDataFactoryProxy;
28use Throwable;
29use UserNotLoggedIn;
30
31class 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}