Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 188 |
|
0.00% |
0 / 24 |
CRAP | |
0.00% |
0 / 1 |
Impact | |
0.00% |
0 / 188 |
|
0.00% |
0 / 24 |
1406 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
getJsConfigVars | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
getUnactivatedModuleCssClass | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getCssClasses | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
isOwnData | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
shouldShowForOtherUser | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
getHeaderText | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
getBody | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
getMobileSummaryBody | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
2 | |||
getScoreCardMarkup | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getScoreCardsMarkup | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
getRecentActivityMarkup | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
2 | |||
getArticlesListMarkup | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
2 | |||
getBaseMarkup | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
2 | |||
getHeaderIconName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getModules | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setUserDataIsFor | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getState | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
20 | |||
isUnactivated | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getJsData | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
getActionData | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
6 | |||
getUserImpact | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getFormattedUserImpact | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
hasMainspaceEdits | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\HomepageModules; |
4 | |
5 | use Exception; |
6 | use GrowthExperiments\ExperimentUserManager; |
7 | use GrowthExperiments\UserDatabaseHelper; |
8 | use GrowthExperiments\UserImpact\ComputedUserImpactLookup; |
9 | use GrowthExperiments\UserImpact\ExpensiveUserImpact; |
10 | use GrowthExperiments\UserImpact\UserImpactFormatter; |
11 | use GrowthExperiments\UserImpact\UserImpactStore; |
12 | use MediaWiki\Config\Config; |
13 | use MediaWiki\Context\IContextSource; |
14 | use MediaWiki\Html\Html; |
15 | use MediaWiki\User\UserIdentity; |
16 | |
17 | /** |
18 | * Class for the Impact module. |
19 | */ |
20 | class Impact extends BaseModule { |
21 | |
22 | private UserIdentity $userIdentity; |
23 | private UserImpactStore $userImpactStore; |
24 | private UserImpactFormatter $userImpactFormatter; |
25 | private UserDatabaseHelper $userDatabaseHelper; |
26 | private bool $isSuggestedEditsEnabledForUser; |
27 | private bool $isSuggestedEditsActivatedForUser; |
28 | |
29 | /** @var ExpensiveUserImpact|null|false Lazy-loaded if false */ |
30 | private $userImpact = false; |
31 | /** @var array|null|false Lazy-loaded if false */ |
32 | private $formattedUserImpact = false; |
33 | private bool $forceShowingForOther = false; |
34 | private ?array $hasMainspaceEditsCache = null; |
35 | |
36 | /** |
37 | * @param IContextSource $ctx |
38 | * @param Config $wikiConfig |
39 | * @param ExperimentUserManager $experimentUserManager |
40 | * @param UserIdentity $userIdentity |
41 | * @param UserImpactStore $userImpactStore |
42 | * @param UserImpactFormatter $userImpactFormatter |
43 | * @param UserDatabaseHelper $userDatabaseHelper |
44 | * @param bool $isSuggestedEditsEnabled |
45 | * @param bool $isSuggestedEditsActivated |
46 | */ |
47 | public function __construct( |
48 | IContextSource $ctx, |
49 | Config $wikiConfig, |
50 | ExperimentUserManager $experimentUserManager, |
51 | UserIdentity $userIdentity, |
52 | UserImpactStore $userImpactStore, |
53 | UserImpactFormatter $userImpactFormatter, |
54 | UserDatabaseHelper $userDatabaseHelper, |
55 | bool $isSuggestedEditsEnabled, |
56 | bool $isSuggestedEditsActivated |
57 | ) { |
58 | parent::__construct( 'impact', $ctx, $wikiConfig, $experimentUserManager ); |
59 | $this->userIdentity = $userIdentity; |
60 | $this->userImpactStore = $userImpactStore; |
61 | $this->userImpactFormatter = $userImpactFormatter; |
62 | $this->userDatabaseHelper = $userDatabaseHelper; |
63 | $this->isSuggestedEditsEnabledForUser = $isSuggestedEditsEnabled; |
64 | $this->isSuggestedEditsActivatedForUser = $isSuggestedEditsActivated; |
65 | } |
66 | |
67 | /** @inheritDoc */ |
68 | protected function getJsConfigVars() { |
69 | return [ |
70 | 'GEImpactRelevantUserName' => $this->userIdentity->getName(), |
71 | 'GEImpactRelevantUserId' => $this->userIdentity->getId(), |
72 | 'GEImpactRelevantUserUnactivated' => $this->isUnactivated(), |
73 | 'GEImpactThirdPersonRender' => $this->shouldShowForOtherUser(), |
74 | 'GEImpactIsSuggestedEditsEnabledForUser' => $this->isSuggestedEditsEnabledForUser, |
75 | 'GEImpactIsSuggestedEditsActivatedForUser' => $this->isSuggestedEditsActivatedForUser, |
76 | ]; |
77 | } |
78 | |
79 | /** |
80 | * @return string |
81 | */ |
82 | private function getUnactivatedModuleCssClass() { |
83 | // The following classes are used here: |
84 | // * growthexperiments-homepage-module-impact-unactivated-desktop |
85 | // * growthexperiments-homepage-module-impact-unactivated-mobile-details |
86 | // * growthexperiments-homepage-module-impact-unactivated-mobile-overlay |
87 | // * growthexperiments-homepage-module-impact-unactivated-mobile-summary |
88 | return 'growthexperiments-homepage-module-impact-unactivated-' . $this->getMode(); |
89 | } |
90 | |
91 | /** |
92 | * @inheritDoc |
93 | */ |
94 | protected function getCssClasses() { |
95 | $unactivatedClasses = []; |
96 | if ( $this->isUnactivated() ) { |
97 | $unactivatedClasses[] = $this->getUnactivatedModuleCssClass(); |
98 | } |
99 | return array_merge( parent::getCssClasses(), $unactivatedClasses ); |
100 | } |
101 | |
102 | /** |
103 | * Check if the user requesting the data matches the user data is requested |
104 | * |
105 | * @return bool |
106 | */ |
107 | private function isOwnData(): bool { |
108 | return $this->getContext()->getUser()->equals( $this->userIdentity ); |
109 | } |
110 | |
111 | /** |
112 | * Check if texts should show first person or third person |
113 | * |
114 | * @return bool |
115 | */ |
116 | private function shouldShowForOtherUser(): bool { |
117 | return !$this->isOwnData() || $this->forceShowingForOther; |
118 | } |
119 | |
120 | /** @inheritDoc */ |
121 | protected function getHeaderText() { |
122 | $headerText = 'growthexperiments-homepage-impact-header'; |
123 | if ( $this->shouldShowForOtherUser() ) { |
124 | $headerText = 'growthexperiments-specialimpact-showing-for-other-user'; |
125 | } |
126 | return $this->getContext() |
127 | ->msg( $headerText ) |
128 | ->params( $this->userIdentity->getName() ) |
129 | ->text(); |
130 | } |
131 | |
132 | /** @inheritDoc */ |
133 | protected function getBody() { |
134 | return Html::rawElement( 'div', |
135 | [ |
136 | 'id' => 'impact-vue-root', |
137 | 'class' => 'ext-growthExperiments-impact-app-root' |
138 | ], |
139 | $this->getBaseMarkup() |
140 | ) . |
141 | Html::element( 'p', |
142 | [ 'class' => 'growthexperiments-homepage-impact-no-js-fallback' ], |
143 | $this->msg( 'growthexperiments-homepage-impact-no-js-fallback' )->text() |
144 | ); |
145 | } |
146 | |
147 | /** @inheritDoc */ |
148 | protected function getMobileSummaryBody() { |
149 | return Html::rawElement( 'div', |
150 | [ |
151 | 'id' => 'impact-vue-root--mobile', |
152 | 'class' => [ |
153 | 'ext-growthExperiments-impact-app-root', |
154 | 'ext-growthExperiments-impact-app-root--mobile' |
155 | ] |
156 | ], |
157 | $this->getRecentActivityMarkup() |
158 | ) . |
159 | Html::element( 'p', |
160 | [ 'class' => 'growthexperiments-homepage-impact-no-js-fallback' ], |
161 | $this->msg( 'growthexperiments-homepage-impact-no-js-fallback' )->text() |
162 | ); |
163 | } |
164 | |
165 | /** |
166 | * ScoreCard server markup. A wrapper using only top-level styles from ScoreCard.less. |
167 | * |
168 | * @param int $index Card index, not relevant at the moment. |
169 | * @return string HTML content of a scorecard |
170 | * @see modules/vue-components/CScoreCard.{less,vue} |
171 | */ |
172 | private function getScoreCardMarkup( int $index ): string { |
173 | return Html::rawElement( 'div', [ |
174 | 'class' => 'ext-growthExperiments-ScoreCard' |
175 | ] ); |
176 | } |
177 | |
178 | /** |
179 | * ScoreCards server markup. A wrapper using only top-level styles from ScoreCards.less. |
180 | * |
181 | * @see modules/vue-components/CScoreCards.{less,vue} |
182 | * @return string HTML content of the scorecards section |
183 | */ |
184 | private function getScoreCardsMarkup(): string { |
185 | return Html::rawElement( 'div', |
186 | [ |
187 | 'class' => 'ext-growthExperiments-ScoreCards' |
188 | ], |
189 | implode( '', array_map( [ $this, 'getScoreCardMarkup' ], [ 1, 2, 3, 4 ] ) ) |
190 | ); |
191 | } |
192 | |
193 | /** |
194 | * RecentActivity server markup. Uses only styles from Skeleton.less, mimics |
195 | * RecentActivity.vue content |
196 | * |
197 | * @see modules/ext.growthExperiments.Homepage.Impact/components/RecentActivity.vue |
198 | * @return string HTML content of the recent activity section |
199 | */ |
200 | private function getRecentActivityMarkup(): string { |
201 | return Html::rawElement( 'div', [], |
202 | Html::rawElement( 'div', [ |
203 | 'class' => [ |
204 | 'ext-growthExperiments-Skeleton', |
205 | 'ext-growthExperiments-Skeleton--darken' |
206 | ] |
207 | ] ) . |
208 | Html::rawElement( 'div', [ |
209 | 'class' => [ |
210 | 'ext-growthExperiments-Skeleton', |
211 | 'ext-growthExperiments-Skeleton--double' |
212 | ] |
213 | ] ) . |
214 | Html::rawElement( 'div', [ |
215 | 'class' => [ |
216 | 'ext-growthExperiments-Skeleton', |
217 | 'ext-growthExperiments-Skeleton--triple' |
218 | ] |
219 | ] ) |
220 | ); |
221 | } |
222 | |
223 | /** |
224 | * ArticlesList server markup. Uses only styles from Skeleton.less, Impact.less and App.less. |
225 | * |
226 | * @param int $numberOfArticles The number of article skeletons to render |
227 | * @return string HTML content of the articles list section |
228 | * @see modules/ext.growthExperiments.Homepage.Impact/components/{App,Impact}.less |
229 | */ |
230 | private function getArticlesListMarkup( int $numberOfArticles = 5 ): string { |
231 | // Articles list |
232 | return Html::rawElement( 'div', [], |
233 | Html::rawElement( 'div', [ |
234 | 'class' => [ |
235 | 'ext-growthExperiments-ArticleListHeading', |
236 | 'ext-growthExperiments-Skeleton', |
237 | 'ext-growthExperiments-Skeleton--darken' |
238 | ] |
239 | ] ) . |
240 | implode( "\n", array_map( |
241 | static function ( $index ) { |
242 | // Article animation delay starting at 400ms and increased 200ms for each article |
243 | $delay = 400 + ( $index * 200 ); |
244 | return Html::rawElement( 'div', [ |
245 | 'class' => [ |
246 | 'ext-growthExperiments-ArticleLoading' |
247 | ] |
248 | ], |
249 | Html::rawElement( 'div', [ |
250 | 'class' => [ |
251 | 'ext-growthExperiments-ArticleLoading__image', |
252 | 'ext-growthExperiments-Skeleton', |
253 | 'ext-growthExperiments-Skeleton--delay-' . $delay |
254 | ] |
255 | ] ) . |
256 | Html::rawElement( 'div', [ |
257 | 'class' => [ |
258 | 'ext-growthExperiments-ArticleLoading__text', |
259 | 'ext-growthExperiments-Skeleton', |
260 | 'ext-growthExperiments-Skeleton--darken', |
261 | 'ext-growthExperiments-Skeleton--delay-' . $delay |
262 | ] |
263 | ] ) |
264 | ); |
265 | }, array_keys( array_fill( 0, $numberOfArticles, 1 ) ) ) |
266 | ) |
267 | ); |
268 | } |
269 | |
270 | /** |
271 | * Impact application server markup. Does not use any styles from the CSS classes added. |
272 | * Should be kept in sync with Vue application component tree (App.vue > Layout.vue > Impact.vue). |
273 | * |
274 | * @see modules/ext.growthExperiments.Homepage.Impact/components/Impact.less |
275 | * @return string HTML content of the recent activity section |
276 | */ |
277 | private function getBaseMarkup(): string { |
278 | return Html::rawElement( 'div', |
279 | [ |
280 | 'class' => 'ext-growthExperiments-App--UserImpact' |
281 | ], |
282 | Html::rawElement( 'div', |
283 | [ |
284 | // The following classes are generated here: |
285 | // * ext-growthExperiments-Layout--desktop |
286 | // * ext-growthExperiments-Layout--mobile-details |
287 | // * ext-growthExperiments-Layout--mobile-overlay |
288 | // * ext-growthExperiments-Layout--mobile-summary |
289 | 'class' => 'ext-growthExperiments-Layout--' . $this->getMode() |
290 | ], |
291 | Html::rawElement( 'div', |
292 | [ |
293 | 'class' => 'ext-growthExperiments-Impact' |
294 | ], |
295 | Html::rawElement( 'div', |
296 | [], |
297 | $this->getScoreCardsMarkup() . |
298 | $this->getRecentActivityMarkup() . |
299 | $this->getArticlesListMarkup() |
300 | ) |
301 | ) |
302 | ) |
303 | ); |
304 | } |
305 | |
306 | /** @inheritDoc */ |
307 | protected function getHeaderIconName() { |
308 | return 'chart'; |
309 | } |
310 | |
311 | /** @inheritDoc */ |
312 | protected function getModules() { |
313 | return [ 'ext.growthExperiments.Homepage.Impact' ]; |
314 | } |
315 | |
316 | /** |
317 | * Set the relevant user to return data for. This will also force the module to display third person texts even if |
318 | * the user requesting it is the same the data is requested |
319 | * |
320 | * @param UserIdentity $user |
321 | */ |
322 | public function setUserDataIsFor( UserIdentity $user ) { |
323 | $this->userIdentity = $user; |
324 | $this->forceShowingForOther = true; |
325 | } |
326 | |
327 | /** |
328 | * @inheritDoc |
329 | */ |
330 | public function getState() { |
331 | if ( ( $this->canRender() |
332 | // On null (first 1000 edits are non-mainspace) assume rest are non-mainspace as well |
333 | // (chances are it's some kind of bot or role account). |
334 | && $this->hasMainspaceEdits() ) |
335 | // Always show the module activated when a user is looking to another user data |
336 | || $this->shouldShowForOtherUser() |
337 | ) { |
338 | return self::MODULE_STATE_ACTIVATED; |
339 | } |
340 | return self::MODULE_STATE_UNACTIVATED; |
341 | } |
342 | |
343 | /** |
344 | * Check if impact module is unactivated. |
345 | * |
346 | * @return bool |
347 | */ |
348 | private function isUnactivated(): bool { |
349 | return $this->getState() === self::MODULE_STATE_UNACTIVATED; |
350 | } |
351 | |
352 | /** @inheritDoc */ |
353 | public function getJsData( $mode ) { |
354 | $data = parent::getJsData( $mode ); |
355 | $userImpact = $this->getUserImpact(); |
356 | $formattedUserImpact = $this->getFormattedUserImpact(); |
357 | // If the impact data's page view information is considered to be stale, then don't export |
358 | // it here. The client-side app's request will be able to get a fresh data generation, and |
359 | // it's ok for that to take longer. We wouldn't want to have the user wait here, though, as |
360 | // this blocks page render. |
361 | if ( !$userImpact || $userImpact->isPageViewDataStale() ) { |
362 | $data['impact'] = null; |
363 | } else { |
364 | $data['impact'] = $formattedUserImpact; |
365 | } |
366 | return $data; |
367 | } |
368 | |
369 | /** |
370 | * @inheritDoc |
371 | */ |
372 | public function getActionData(): array { |
373 | $userImpact = $this->getUserImpact(); |
374 | $data = [ |
375 | 'no_cached_user_impact' => !$userImpact |
376 | ]; |
377 | if ( $userImpact ) { |
378 | $formattedUserImpact = $this->getFormattedUserImpact(); |
379 | $data = [ |
380 | 'timeframe_in_days' => ComputedUserImpactLookup::PAGEVIEW_DAYS, |
381 | 'timeframe_edits_count' => $userImpact->getTotalEditsCount(), |
382 | 'thanks_count' => $userImpact->getReceivedThanksCount(), |
383 | 'last_edit_timestamp' => $userImpact->getLastEditTimestamp(), |
384 | 'longest_streak_days_count' => $userImpact->getLongestEditingStreakCount(), |
385 | 'top_articles_views_count' => $formattedUserImpact['topViewedArticlesCount'], |
386 | 'total_pageviews_count' => $formattedUserImpact['totalPageviewsCount'], |
387 | ]; |
388 | } |
389 | return array_merge( parent::getActionData(), $data ); |
390 | } |
391 | |
392 | /** |
393 | * Get user impact, with an in-process cache. |
394 | * |
395 | * @return ExpensiveUserImpact|null |
396 | */ |
397 | private function getUserImpact(): ?ExpensiveUserImpact { |
398 | if ( $this->userImpact !== false ) { |
399 | return $this->userImpact; |
400 | } |
401 | $this->userImpact = $this->userImpactStore->getExpensiveUserImpact( $this->userIdentity ); |
402 | return $this->userImpact; |
403 | } |
404 | |
405 | /** |
406 | * Get the output of UserImpactFormatter::format(), with an in-process cache. |
407 | * @return array |
408 | * @throws Exception |
409 | */ |
410 | private function getFormattedUserImpact(): array { |
411 | if ( $this->formattedUserImpact !== false ) { |
412 | return $this->formattedUserImpact; |
413 | } |
414 | $userImpact = $this->getUserImpact(); |
415 | $this->formattedUserImpact = $userImpact ? |
416 | $this->userImpactFormatter->format( $userImpact, $this->getContext()->getLanguage()->getCode() ) : |
417 | []; |
418 | return $this->formattedUserImpact; |
419 | } |
420 | |
421 | /** @return bool|null */ |
422 | private function hasMainspaceEdits(): ?bool { |
423 | // The cache has four states: true/false/null (valid hasMainspaceEdits() return values) |
424 | // and uninitialized. Use an array hack to differentiate. |
425 | if ( !$this->hasMainspaceEditsCache ) { |
426 | $this->hasMainspaceEditsCache = [ |
427 | $this->userDatabaseHelper->hasMainspaceEdits( $this->userIdentity ), |
428 | ]; |
429 | } |
430 | return $this->hasMainspaceEditsCache[0]; |
431 | } |
432 | } |