Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
21.79% |
17 / 78 |
|
26.67% |
4 / 15 |
CRAP | |
0.00% |
0 / 1 |
VariantHooks | |
21.79% |
17 / 78 |
|
26.67% |
4 / 15 |
885.73 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
onGetPreferences | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
onUserGetDefaultOptions | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
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 GrowthExperiments\NewcomerTasks\CampaignConfig; |
6 | use MediaWiki\Auth\Hook\LocalUserCreatedHook; |
7 | use MediaWiki\Config\Config; |
8 | use MediaWiki\Context\IContextSource; |
9 | use MediaWiki\Context\RequestContext; |
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\Registration\ExtensionRegistry; |
17 | use MediaWiki\ResourceLoader as RL; |
18 | use MediaWiki\ResourceLoader\Hook\ResourceLoaderExcludeUserOptionsHook; |
19 | use MediaWiki\ResourceLoader\Hook\ResourceLoaderGetConfigVarsHook; |
20 | use MediaWiki\SpecialPage\Hook\AuthChangeFormFieldsHook; |
21 | use MediaWiki\SpecialPage\Hook\SpecialPageBeforeExecuteHook; |
22 | use MediaWiki\SpecialPage\SpecialPageFactory; |
23 | use MediaWiki\User\Hook\UserGetDefaultOptionsHook; |
24 | use MediaWiki\User\Options\UserOptionsManager; |
25 | use 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 | */ |
31 | class 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 | } |