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