Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
21.79% covered (danger)
21.79%
17 / 78
26.67% covered (danger)
26.67%
4 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
VariantHooks
21.79% covered (danger)
21.79%
17 / 78
26.67% covered (danger)
26.67%
4 / 15
885.73
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 onGetPreferences
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 onUserGetDefaultOptions
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 onResourceLoaderExcludeUserOptions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 onResourceLoaderGetConfigVars
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isGrowthCampaign
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 shouldCampaignSkipWelcomeSurvey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getCampaign
n/a
0 / 0
n/a
0 / 0
3
 onAuthChangeFormFields
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 onLocalUserCreated
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
7.46
 onPostLoginRedirect
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 onCentralAuthPostLoginRedirect
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 onSkinAddFooterLinks
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 onSpecialCreateAccountBenefits
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 shouldShowNewLandingPageHtml
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 onSpecialPageBeforeExecute
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace GrowthExperiments;
4
5use GrowthExperiments\NewcomerTasks\CampaignConfig;
6use MediaWiki\Auth\Hook\LocalUserCreatedHook;
7use MediaWiki\Config\Config;
8use MediaWiki\Context\IContextSource;
9use MediaWiki\Context\RequestContext;
10use MediaWiki\Hook\PostLoginRedirectHook;
11use MediaWiki\Hook\SkinAddFooterLinksHook;
12use MediaWiki\Hook\SpecialCreateAccountBenefitsHook;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Minerva\Skins\SkinMinerva;
15use MediaWiki\Preferences\Hook\GetPreferencesHook;
16use MediaWiki\Registration\ExtensionRegistry;
17use MediaWiki\ResourceLoader as RL;
18use MediaWiki\ResourceLoader\Hook\ResourceLoaderExcludeUserOptionsHook;
19use MediaWiki\ResourceLoader\Hook\ResourceLoaderGetConfigVarsHook;
20use MediaWiki\SpecialPage\Hook\AuthChangeFormFieldsHook;
21use MediaWiki\SpecialPage\Hook\SpecialPageBeforeExecuteHook;
22use MediaWiki\SpecialPage\SpecialPageFactory;
23use MediaWiki\User\Hook\UserGetDefaultOptionsHook;
24use MediaWiki\User\Options\UserOptionsManager;
25use Skin;
26
27/**
28 * Hooks related to feature flags used for A/B testing and opt-in.
29 * At present only a single feature flag is handled.
30 */
31class VariantHooks implements
32    AuthChangeFormFieldsHook,
33    GetPreferencesHook,
34    LocalUserCreatedHook,
35    PostLoginRedirectHook,
36    ResourceLoaderExcludeUserOptionsHook,
37    ResourceLoaderGetConfigVarsHook,
38    SkinAddFooterLinksHook,
39    SpecialCreateAccountBenefitsHook,
40    SpecialPageBeforeExecuteHook,
41    UserGetDefaultOptionsHook
42{
43    /** Default A/B testing variant (control group). */
44    public const VARIANT_CONTROL = 'control';
45    /** A/B testing variant (treatment group) for the community-updates experiment. */
46    public const VARIANT_COMMUNITY_UPDATES_MODULE = 'community-updates-module';
47    public const VARIANT_NO_LINK_RECOMMENDATION = 'no-link-recommendation';
48
49    /**
50     * This defines the allowed values for the variant preference. The default value is defined
51     * via $wgGEHomepageDefaultVariant.
52     */
53    public const VARIANTS = [
54        // 'A' doesn't exist anymore; was: not pre-initiated, impact module in main column,
55        //     full size start module
56        // 'B' doesn't exist anymore; was a pre-initiated version of A
57        // 'C' doesn't exist anymore; was pre-initiated, impact module in side column,
58        //     smaller start module
59        // 'D' doesn't exist anymore; was not pre-initiated, onboarding embedded in suggested
60        //     edits module, otherwise like C
61        // 'linkrecommendation' Doesn't exist anymore. Opted users into the link-recommendation task type
62        //     experiment; this is now default behavior for the control group.
63        // 'imagerecommendation' Doesn't exist anymore. Opted users into the image-recommendation task type
64        //     experiment; this is now default behavior for the control group.
65        // 'oldimpact' Doesn't exist anymore. Was used for A/B testing the new Impact module.
66        //     Removed as part of consolidating on the new Impact module implementation.
67        //     See task T350077 for more details.
68        self::VARIANT_COMMUNITY_UPDATES_MODULE,
69        self::VARIANT_CONTROL,
70        self::VARIANT_NO_LINK_RECOMMENDATION,
71    ];
72
73    /** User option name for storing variants. */
74    public const USER_PREFERENCE = 'growthexperiments-homepage-variant';
75    /** @var string User option name for storing the campaign associated with account creation */
76    public const GROWTH_CAMPAIGN = 'growthexperiments-campaign';
77
78    private UserOptionsManager $userOptionsManager;
79    private CampaignConfig $campaignConfig;
80    private SpecialPageFactory $specialPageFactory;
81    private Config $config;
82
83    /**
84     * @param UserOptionsManager $userOptionsManager
85     * @param CampaignConfig $campaignConfig
86     * @param SpecialPageFactory $specialPageFactory
87     */
88    public function __construct(
89        UserOptionsManager $userOptionsManager,
90        CampaignConfig $campaignConfig,
91        SpecialPageFactory $specialPageFactory,
92        Config $config
93    ) {
94        $this->userOptionsManager = $userOptionsManager;
95        $this->campaignConfig = $campaignConfig;
96        $this->specialPageFactory = $specialPageFactory;
97        $this->config = $config;
98    }
99
100    /** @inheritDoc */
101    public function onGetPreferences( $user, &$preferences ) {
102        $preferences[self::USER_PREFERENCE] = [
103            'type' => 'api',
104        ];
105        $preferences[self::GROWTH_CAMPAIGN] = [
106            'type' => 'api',
107        ];
108    }
109
110    /**
111     * Register defaults for variant-related preferences.
112     *
113     * @param array &$defaultOptions
114     */
115    public function onUserGetDefaultOptions( &$defaultOptions ) {
116        $defaultOptions += [
117            self::USER_PREFERENCE => $this->config->get( 'GEHomepageDefaultVariant' ),
118        ];
119    }
120
121    /** @inheritDoc */
122    public function onResourceLoaderExcludeUserOptions(
123        array &$keysToExclude,
124        RL\Context $context
125    ): void {
126        $keysToExclude = array_merge( $keysToExclude, [
127            self::GROWTH_CAMPAIGN,
128        ] );
129    }
130
131    // Note: we intentionally do not make $wgGEHomepageDefaultVariant the default value in the
132    // UserGetDefaultOptions sense. That would result in the variant not being recorded if it's
133    // the same as the default, and thus changing when the default is changed, and in an A/B test
134    // we don't want that.
135
136    /** @inheritDoc */
137    public function onResourceLoaderGetConfigVars( array &$vars, $skin, Config $config ): void {
138        $vars['wgGEUserVariants'] = self::VARIANTS;
139        $vars['wgGEDefaultUserVariant'] = $config->get( 'GEHomepageDefaultVariant' );
140    }
141
142    /**
143     * Check if this is a Growth campaign by inspecting the campaign query parameter.
144     *
145     * @param string $campaignParameter
146     * @param CampaignConfig $campaignConfig
147     * @return bool
148     */
149    public static function isGrowthCampaign(
150        string $campaignParameter, CampaignConfig $campaignConfig
151    ): bool {
152        if ( !$campaignParameter ) {
153            return false;
154        }
155
156        return $campaignConfig->getCampaignIndexFromCampaignTerm( $campaignParameter ) !== null;
157    }
158
159    /**
160     * Check whether the welcome survey should be skipped by asking the $campaignConfig
161     * for the value given in the "campaign" query parameter
162     *
163     * @param string $campaign
164     * @param CampaignConfig $campaignConfig
165     * @return bool
166     */
167    public static function shouldCampaignSkipWelcomeSurvey(
168        string $campaign, CampaignConfig $campaignConfig
169    ): bool {
170        return $campaign && $campaignConfig->shouldSkipWelcomeSurvey( $campaign );
171    }
172
173    /**
174     * Get the campaign from the user's saved options, falling back to the request parameter if
175     * the user's option isn't set. This is needed because the query parameter can get lost
176     * during CentralAuth redirection.
177     *
178     * @param IContextSource $context
179     * @return string
180     * @codeCoverageIgnore
181     */
182    public static function getCampaign( IContextSource $context ): string {
183        $campaignFromRequestQueryParameter = $context->getRequest()->getVal( 'campaign', '' );
184        if ( defined( 'MW_NO_SESSION' ) ) {
185            // If we're in a ResourceLoader context, don't attempt to get the campaign string
186            // from the user's preferences, as it's not allowed.
187            return $campaignFromRequestQueryParameter;
188        }
189
190        $user = $context->getUser();
191        if ( !$user->isSafeToLoad() ) {
192            return $campaignFromRequestQueryParameter;
193        }
194        return MediaWikiServices::getInstance()->getUserOptionsLookup()->getOption(
195            $user, self::GROWTH_CAMPAIGN,
196            $campaignFromRequestQueryParameter
197        );
198    }
199
200    /**
201     * Pass through the campaign flag for use by LocalUserCreated.
202     *
203     * @inheritDoc
204     */
205    public function onAuthChangeFormFields( $requests, $fieldInfo, &$formDescriptor, $action ) {
206        $campaign = self::getCampaign( RequestContext::getMain() );
207        // This is probably not strictly necessary; the Campaign extension sets this hidden field.
208        // But if it's not there for whatever reason, add it here so we are sure it's available
209        // in LocalUserCreated hook.
210        if ( $campaign && !isset( $formDescriptor['campaign'] ) ) {
211            $formDescriptor['campaign'] = [
212                'type' => 'hidden',
213                'name' => 'campaign',
214                'default' => $campaign,
215            ];
216        }
217    }
218
219    /**
220     * @inheritDoc
221     */
222    public function onLocalUserCreated( $user, $autocreated ) {
223        if ( $autocreated || $user->isTemp() ) {
224            return;
225        }
226        $context = RequestContext::getMain();
227        if ( self::isGrowthCampaign( self::getCampaign( $context ), $this->campaignConfig ) ) {
228            $this->userOptionsManager->setOption( $user, self::GROWTH_CAMPAIGN, $this->getCampaign( $context ) );
229        }
230    }
231
232    /**
233     * Go directly to the homepage after signup if the user is in a campaign which has the
234     * "skip welcome survey" flag set.
235     * @inheritDoc
236     */
237    public function onPostLoginRedirect( &$returnTo, &$returnToQuery, &$type ) {
238        if ( $type !== 'signup' ) {
239            return;
240        }
241        if ( ExtensionRegistry::getInstance()->isLoaded( 'CentralAuth' ) ) {
242            // Handled by onCentralAuthPostLoginRedirect
243            return;
244        }
245        $context = RequestContext::getMain();
246        if ( self::isGrowthCampaign( self::getCampaign( $context ), $this->campaignConfig )
247            && self::shouldCampaignSkipWelcomeSurvey( self::getCampaign( $context ), $this->campaignConfig )
248        ) {
249            $returnTo = $this->specialPageFactory->getTitleForAlias( 'Homepage' )->getPrefixedText();
250            $type = 'successredirect';
251            return false;
252        }
253    }
254
255    /**
256     * CentralAuth-compatible version of onPostLoginRedirect().
257     * @param string &$returnTo
258     * @param string &$returnToQuery
259     * @param bool $stickHTTPS
260     * @param string $type
261     * @param string &$injectedHtml
262     * @return bool|void
263     */
264    public function onCentralAuthPostLoginRedirect(
265        string &$returnTo, string &$returnToQuery, bool $stickHTTPS, string $type, string &$injectedHtml
266    ) {
267        if ( $type !== 'signup' ) {
268            return;
269        }
270        $context = RequestContext::getMain();
271        if ( self::isGrowthCampaign( self::getCampaign( $context ), $this->campaignConfig )
272            && self::shouldCampaignSkipWelcomeSurvey( self::getCampaign( $context ), $this->campaignConfig )
273        ) {
274            $returnTo = $this->specialPageFactory->getTitleForAlias( 'Homepage' )->getPrefixedText();
275            $injectedHtml = '';
276            return false;
277        }
278    }
279
280    /** @inheritDoc */
281    public function onSkinAddFooterLinks( Skin $skin, string $key, array &$footerItems ) {
282        $context = $skin->getContext();
283        if ( $key !== 'info' || !self::isGrowthCampaign( self::getCampaign( $context ), $this->campaignConfig ) ) {
284            return;
285        }
286        $footerItems['signupcampaign-legal'] = CampaignBenefitsBlock::getLegalFooter( $context );
287        $context->getOutput()->addModuleStyles( [ 'ext.growthExperiments.Account.styles' ] );
288    }
289
290    /** @inheritDoc */
291    public function onSpecialCreateAccountBenefits( ?string &$html, array $info, array &$options ) {
292        if ( !$this->shouldShowNewLandingPageHtml( $info['context'] ) ) {
293            return true;
294        }
295        $options['beforeForm'] = $info['context']->getSkin() instanceof SkinMinerva;
296        $benefitsBlock = new CampaignBenefitsBlock( $info['context'], $info['form'], $this->campaignConfig );
297        $html = $benefitsBlock->getHtml();
298        return false;
299    }
300
301    /**
302     * Check if the campaign field is set.
303     * @param IContextSource $context
304     * @return bool
305     */
306    private function shouldShowNewLandingPageHtml( IContextSource $context ): bool {
307        $campaignValue = $context->getRequest()->getRawVal( 'campaign' );
308        $campaignName = $this->campaignConfig->getCampaignIndexFromCampaignTerm( $campaignValue );
309        if ( $campaignName ) {
310            $signupPageTemplate = $this->campaignConfig->getSignupPageTemplate( $campaignName );
311            if ( in_array( $signupPageTemplate, [ 'hero', 'video' ], true ) ) {
312                return true;
313            } elseif ( $signupPageTemplate !== null ) {
314                Util::logText( 'Unknown signup page template',
315                    [ 'campaign' => $campaignName, 'template' => $signupPageTemplate ] );
316            }
317        }
318        return false;
319    }
320
321    /**
322     * Remove the default Minerva "warning" that only serves aesthetic purposes but
323     * do not remove real warnings.
324     * @inheritDoc
325     */
326    public function onSpecialPageBeforeExecute( $special, $subPage ) {
327        if ( $special->getName() === 'CreateAccount'
328            && $this->shouldShowNewLandingPageHtml( $special->getContext() )
329            && $special->getSkin() instanceof SkinMinerva
330            && $special->getRequest()->getVal( 'notice' ) === 'mobile-frontend-generic-login-new'
331        ) {
332            $special->getRequest()->setVal( 'notice', null );
333        }
334    }
335}