Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 433
0.00% covered (danger)
0.00%
0 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
Impact
0.00% covered (danger)
0.00%
0 / 433
0.00% covered (danger)
0.00%
0 / 32
4970
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 canRender
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getModuleStyles
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getHeaderText
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getBody
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 getMobileSummaryBody
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
12
 generateEditsTable
0.00% covered (danger)
0.00%
0 / 84
0.00% covered (danger)
0.00%
0 / 1
20
 getTotalViewsElement
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 getSubheaderText
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getSubheaderSubtext
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getUnactivatedModuleCssClass
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUnactivatedModuleSubheader
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
2
 getUnactivatedModuleSuggestedEditsButton
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 getUnactivatedModuleBody
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 getSubheader
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getSubheaderTag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFooter
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 getCssClasses
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getState
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 isActivated
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isUnactivatedWithSuggestedEdits
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getArticleContributions
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getTotalPageViews
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 queryArticleEdits
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
6
 getArticleEditCount
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 getArticleOrTotalEditCountText
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 addPageViews
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
42
 getImage
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 daysSince
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getPageViewToolsUrl
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 getHeaderIconName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getEditsTable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace GrowthExperiments\HomepageModules;
4
5use DateTime;
6use Exception;
7use ExtensionRegistry;
8use GrowthExperiments\ExperimentUserManager;
9use IContextSource;
10use MediaWiki\Config\Config;
11use MediaWiki\Extension\PageViewInfo\PageViewService;
12use MediaWiki\Html\Html;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\SpecialPage\SpecialPage;
15use MediaWiki\Title\Title;
16use MediaWiki\Title\TitleFactory;
17use MediaWiki\User\ActorMigration;
18use MediaWiki\Utils\MWTimestamp;
19use OOUI\ButtonWidget;
20use OOUI\IconWidget;
21use PageImages\PageImages;
22use Wikimedia\Rdbms\IConnectionProvider;
23use Wikimedia\Rdbms\SelectQueryBuilder;
24
25/**
26 * This is the "Impact" module. It shows the page views information
27 * of recently edited pages.
28 *
29 * All timestamps in this file are in UTC. That's also what
30 * the pageviews tool expects.
31 *
32 * @package GrowthExperiments\HomepageModules
33 */
34class Impact extends BaseModule {
35
36    private const THUMBNAIL_SIZE = 40;
37
38    /**
39     * @var array
40     */
41    private $contribs = null;
42
43    /**
44     * @var string
45     */
46    private $body = null;
47
48    /**
49     * @var bool
50     */
51    private $pageViewsDataExists = false;
52
53    /**
54     * @var string|null
55     */
56    private $editsTable = null;
57
58    /** @var IConnectionProvider */
59    private $connectionProvider;
60
61    /**
62     * @var bool
63     */
64    private $isSuggestedEditsEnabledForUser;
65
66    /**
67     * @var bool
68     */
69    private $isSuggestedEditsActivatedForUser;
70
71    /**
72     * @var TitleFactory
73     */
74    private $titleFactory;
75
76    /**
77     * @var PageViewService|null
78     */
79    private $pageViewService;
80
81    /**
82     * @param IContextSource $context
83     * @param Config $wikiConfig
84     * @param IConnectionProvider $connectionProvider
85     * @param ExperimentUserManager $experimentUserManager
86     * @param array $suggestedEditsConfig
87     * @param TitleFactory $titleFactory
88     * @param PageViewService|null $pageViewService
89     */
90    public function __construct(
91        IContextSource $context,
92        Config $wikiConfig,
93        IConnectionProvider $connectionProvider,
94        ExperimentUserManager $experimentUserManager,
95        array $suggestedEditsConfig,
96        TitleFactory $titleFactory,
97        PageViewService $pageViewService = null
98    ) {
99        parent::__construct( 'impact', $context, $wikiConfig, $experimentUserManager );
100        $this->connectionProvider = $connectionProvider;
101        $this->isSuggestedEditsEnabledForUser = $suggestedEditsConfig['isSuggestedEditsEnabled'];
102        $this->isSuggestedEditsActivatedForUser = $suggestedEditsConfig['isSuggestedEditsActivated'];
103        $this->titleFactory = $titleFactory;
104        $this->pageViewService = $pageViewService;
105    }
106
107    /** @inheritDoc */
108    public function canRender() {
109        return $this->pageViewService !== null;
110    }
111
112    /**
113     * @inheritDoc
114     */
115    protected function getModuleStyles() {
116        return array_merge(
117            parent::getModuleStyles(),
118            [ 'oojs-ui.styles.icons-media', 'oojs-ui.styles.icons-interactions' ]
119        );
120    }
121
122    /**
123     * @inheritDoc
124     */
125    protected function getHeaderText() {
126        return $this->getContext()
127            ->msg( 'growthexperiments-homepage-impact-header' )
128            ->params( $this->getContext()->getUser()->getName() )
129            ->text();
130    }
131
132    /**
133     * @inheritDoc
134     */
135    protected function getBody() {
136        if ( $this->body !== null ) {
137            return $this->body;
138        }
139        if ( $this->isActivated() ) {
140            $this->body = $this->getEditsTable();
141        } elseif ( $this->isUnactivatedWithSuggestedEdits() ) {
142            $this->body = $this->getUnactivatedModuleBody();
143
144        } else {
145            $this->body = Html::rawElement(
146                'div',
147                [],
148                $this->getContext()
149                    ->msg( 'growthexperiments-homepage-impact-body-no-edit' )
150                    ->params( $this->getContext()->getUser()->getName() )
151                    ->parse()
152            );
153        }
154        return $this->body;
155    }
156
157    /**
158     * @inheritDoc
159     */
160    protected function getMobileSummaryBody() {
161        if ( $this->isActivated() ) {
162            $purposeElement = '';
163            $articleEditsElement = Html::rawElement(
164                'div',
165                [ 'class' => 'growthexperiments-homepage-impact-subheader-text' ],
166                $this->getArticleOrTotalEditCountText()
167            );
168        } elseif ( $this->isUnactivatedWithSuggestedEdits() ) {
169            return $this->getUnactivatedModuleBody() . Html::element(
170                    'div',
171                    [ 'class' => $this->getUnactivatedModuleCssClass() . '-description' ],
172                    $this->getContext()
173                        ->msg( 'growthexperiments-homepage-impact-unactivated-description' )
174                        ->params( $this->getContext()->getUser()->getName() )
175                        ->text()
176                );
177        } else {
178            $purposeElement = Html::element(
179                'div',
180                [ 'class' => 'growthexperiments-homepage-module-text-light' ],
181                $this->getContext()
182                    ->msg( 'growthexperiments-homepage-impact-mobilesummarybody-monitor' )
183                    ->text()
184            );
185            $articleEditsElement = Html::element(
186                'div',
187                [ 'class' => [
188                    'growthexperiments-homepage-module-text-normal',
189                    'growthexperiments-homepage-impact-subheader-text',
190                ] ],
191                $this->getContext()
192                    ->msg( 'growthexperiments-homepage-impact-subheader-text-no-edit' )
193                    ->text()
194            );
195        }
196
197        $totalViewsElement = $this->getTotalViewsElement( $this->isActivated() );
198        $pageViewsElement = Html::element(
199            'div',
200            [ 'class' => [
201                'growthexperiments-homepage-impact-subheader-subtext',
202                'growthexperiments-homepage-module-text-light'
203            ] ],
204            $this->getContext()->msg( 'growthexperiments-homepage-impact-mobilebody-pageviews' )
205                ->numParams( $this->getTotalPageViews() )
206                ->text()
207        );
208
209        return Html::rawElement(
210            'div',
211            [ 'class' => 'growthexperiments-homepage-impact-column-text' ],
212            $purposeElement . $articleEditsElement . $pageViewsElement
213        ) . Html::rawElement(
214            'div',
215            [ 'class' => 'growthexperiments-homepage-impact-column-pageviews' ],
216            $totalViewsElement
217        );
218    }
219
220    /**
221     * Generate the HTML for the edits table.
222     */
223    private function generateEditsTable() {
224        $articleLinkTooltip = $this->getContext()
225            ->msg( 'growthexperiments-homepage-impact-article-link-tooltip' )
226            ->text();
227        $pageviewsTooltip = $this->getContext()
228            ->msg( 'growthexperiments-homepage-impact-pageviews-link-tooltip' )
229            ->text();
230        $emptyImage = new IconWidget( [
231            'icon' => 'image',
232            'classes' => [ 'placeholder-image' ],
233        ] );
234        $emptyViewsWidget = new ButtonWidget( [
235                'classes' => [ 'empty-pageviews' ],
236                'framed' => false,
237                'icon' => 'clock',
238                'title' => $this->getContext()
239                    ->msg( 'growthexperiments-homepage-impact-empty-pageviews-tooltip' )
240                    ->text(),
241                'infusable' => true,
242                'flags' => [ 'progressive' ],
243            ] );
244        $this->editsTable = implode( "\n", array_map(
245            function ( $contrib ) use (
246                $articleLinkTooltip, $pageviewsTooltip, $emptyImage, $emptyViewsWidget
247            ) {
248                $titleText = $contrib['title']->getText();
249                $titlePrefixedText = $contrib['title']->getPrefixedText();
250                $titleUrl = $contrib['title']->getLinkUrl();
251
252                $imageUrl = $this->getImage( $contrib['title'] );
253                $image = $imageUrl ?
254                    Html::element(
255                        'div',
256                        [
257                            'alt' => $titleText,
258                            'title' => $titlePrefixedText,
259                            'class' => 'real-image mw-no-invert',
260                            'style' => 'background-image: url(' . $imageUrl . ');',
261                        ]
262                    ) : $emptyImage;
263                $imageElement = Html::rawElement(
264                    'a',
265                    [
266                        'class' => 'article-image',
267                        'href' => $titleUrl,
268                        'title' => $articleLinkTooltip,
269                        'data-link-id' => 'impact-article-image',
270                    ],
271                    $image
272                );
273
274                $titleElement = Html::rawElement(
275                    'span',
276                    [ 'class' => 'article-title' ],
277                    Html::element(
278                        'a',
279                        [
280                            'href' => $titleUrl,
281                            'title' => $articleLinkTooltip,
282                            'data-link-id' => 'impact-article-title',
283                        ],
284                        $titlePrefixedText
285                    )
286                );
287
288                // Set this flag to check if page views data exists for at least
289                // one article. This is used to determine if the mobile summary
290                // should show the clock icon if all article edits have no page view
291                // data yet. Once the flag is set to true, don't set it again.
292                if ( !$this->pageViewsDataExists ) {
293                    // 'views' is null if no data exists.
294                    $this->pageViewsDataExists = isset( $contrib['views'] );
295                }
296                $viewsElement = isset( $contrib['views'] ) ?
297                    Html::element(
298                        'a',
299                        [
300                            'class' => 'pageviews',
301                            'href' => $this->getPageViewToolsUrl(
302                                $contrib['title'], $contrib['ts']
303                            ),
304                            'title' => $pageviewsTooltip,
305                            'data-link-id' => 'impact-pageviews',
306                        ],
307                        $this->getContext()->getLanguage()->formatNum( $contrib['views'] )
308                    ) : $emptyViewsWidget;
309
310                return Html::rawElement(
311                    'div',
312                    [ 'class' => 'impact-row' ],
313                    $imageElement . $titleElement . $viewsElement
314                );
315            },
316            array_slice( $this->getArticleContributions(), 0, 5 )
317        ) );
318    }
319
320    private function getTotalViewsElement( $showPendingIcon = false ) {
321        $views = $this->getTotalPageViews();
322        if ( $views === 0 && !$this->pageViewsDataExists && $showPendingIcon ) {
323            $views = new IconWidget( [
324                'icon' => 'clock',
325                'flags' => [ 'progressive' ],
326                'framed' => false,
327                'classes' => [ 'empty-pageviews-summary' ]
328            ] );
329        } else {
330            $views = htmlspecialchars( $this->getContext()->getLanguage()->formatNum( $views ) );
331        }
332        return Html::rawElement(
333            'span',
334            [ 'class' => 'growthexperiments-homepage-impact-mobile-totalviews' ],
335            $views
336        );
337    }
338
339    /** @inheritDoc */
340    protected function getSubheaderText() {
341        $textMsgKey = $this->getTotalPageViews() ?
342            'growthexperiments-homepage-impact-subheader-text' :
343            'growthexperiments-homepage-impact-subheader-text-no-pageviews';
344        return Html::element(
345            'p',
346            [ 'class' => 'growthexperiments-homepage-module-text-normal' ],
347            $this->getContext()
348                ->msg( $textMsgKey )
349                ->params( $this->getContext()->getUser()->getName() )
350                ->text()
351        );
352    }
353
354    private function getSubheaderSubtext() {
355        if ( $this->isActivated() ) {
356            return Html::element(
357                'p',
358                [ 'class' => 'growthexperiments-homepage-module-text-light' ],
359                $this->getContext()
360                    ->msg( 'growthexperiments-homepage-impact-subheader-subtext' )
361                    ->params( $this->getContext()->getUser()->getName() )
362                    ->text()
363            );
364        }
365        return '';
366    }
367
368    /**
369     * @return string
370     */
371    private function getUnactivatedModuleCssClass() {
372        // The following classes are used here:
373        // * growthexperiments-homepage-module-impact-unactivated-desktop
374        // * growthexperiments-homepage-module-impact-unactivated-mobile-details
375        // * growthexperiments-homepage-module-impact-unactivated-mobile-overlay
376        // * growthexperiments-homepage-module-impact-unactivated-mobile-summary
377        return 'growthexperiments-homepage-module-impact-unactivated-' . $this->getMode();
378    }
379
380    /**
381     * @return string
382     */
383    private function getUnactivatedModuleSubheader() {
384        $subheader = Html::element(
385            'h3',
386            [ 'class' => $this->getUnactivatedModuleCssClass() . '-subheader' ],
387            $this->getContext()
388                ->msg( 'growthexperiments-homepage-impact-unactivated-subheader-text' )
389                ->text()
390        );
391        $subheaderSubtext = Html::element(
392            'h4',
393            [ 'class' => $this->getUnactivatedModuleCssClass() . '-subheader-subtext' ],
394            $this->getContext()
395                ->msg( 'growthexperiments-homepage-impact-unactivated-subheader-subtext' )
396                ->params( $this->getContext()->getUser()->getName() )
397                ->text()
398        );
399        return Html::rawElement(
400            'div',
401            [ 'class' => $this->getUnactivatedModuleCssClass() . '-subheader-container' ],
402            $subheader . $subheaderSubtext
403        );
404    }
405
406    /**
407     * @return string
408     */
409    private function getUnactivatedModuleSuggestedEditsButton() {
410        if ( in_array( $this->getMode(), [ self::RENDER_MOBILE_DETAILS, self::RENDER_MOBILE_DETAILS_OVERLAY ] ) ) {
411            if ( $this->isSuggestedEditsActivatedForUser ) {
412                $linkPath = 'Special:Homepage/suggested-edits';
413                $linkModulePath = '#/homepage/suggested-edits';
414            } else {
415                $linkPath = 'Special:Homepage';
416                // HACK: We use this to indicate to the client-side to use launchCta() to open the
417                // start editing onboarding dialog for suggested edits.
418                $linkModulePath = 'launchCta';
419            }
420            $button = new ButtonWidget( [
421                'label' => $this->getContext()
422                    ->msg( 'growthexperiments-homepage-impact-unactivated-suggested-edits-link' )
423                    ->text(),
424                'href' => $this->titleFactory->newFromText( $linkPath )->getLinkURL(),
425                'classes' => [
426                    $this->getUnactivatedModuleCssClass() . '-suggested-edits-button',
427                    'see-suggested-edits-button',
428                ],
429            ] );
430            $button->setAttributes( [
431                'data-link-id' => 'impact-see-suggested-edits',
432                'data-link-module-path' => $linkModulePath
433            ] );
434            return $button;
435        }
436        return '';
437    }
438
439    /**
440     * @return string
441     */
442    private function getUnactivatedModuleBody() {
443        if ( $this->isUnactivatedWithSuggestedEdits() ) {
444            return Html::rawElement(
445                'div',
446                [ 'class' => $this->getUnactivatedModuleCssClass() . '-body' ],
447                Html::element(
448                    'div',
449                    [ 'class' => $this->getUnactivatedModuleCssClass() . '-image' ]
450                ) .
451                $this->getUnactivatedModuleSubheader() .
452                $this->getUnactivatedModuleSuggestedEditsButton()
453            );
454        }
455        return '';
456    }
457
458    /**
459     * @inheritDoc
460     */
461    protected function getSubheader() {
462        if ( $this->isUnactivatedWithSuggestedEdits() ) {
463            return '';
464        }
465        return $this->getSubheaderText() . $this->getSubheaderSubtext();
466    }
467
468    /**
469     * @inheritDoc
470     */
471    protected function getSubheaderTag() {
472        return 'div';
473    }
474
475    /**
476     * @inheritDoc
477     */
478    protected function getFooter() {
479        if ( $this->isUnactivatedWithSuggestedEdits() ) {
480            return $this->getContext()
481                ->msg( 'growthexperiments-homepage-impact-unactivated-suggested-edits-footer' )
482                ->params( $this->getContext()->getUser()->getName() )
483                ->parse();
484        }
485
486        $user = $this->getContext()->getUser();
487        $msgKey = $this->isActivated() ?
488            'growthexperiments-homepage-impact-contributions-link' :
489            'growthexperiments-homepage-impact-contributions-link-no-edit';
490        return Html::rawElement(
491            'a',
492            [
493                'href' => SpecialPage::getTitleFor( 'Contributions', $user->getName() )->getLinkURL(),
494                'data-link-id' => 'impact-contributions',
495            ],
496            $this->getContext()
497                ->msg( $msgKey )
498                ->numParams( $user->getEditCount() )
499                ->params( $user->getName() )
500                ->parse()
501        );
502    }
503
504    /**
505     * @inheritDoc
506     */
507    protected function getCssClasses() {
508        $unactivatedClasses = $this->isUnactivatedWithSuggestedEdits() ?
509            [ $this->getUnactivatedModuleCssClass() ] :
510            [];
511        return array_merge(
512            parent::getCssClasses(),
513            $this->isActivated() ?
514                [ 'growthexperiments-homepage-impact-activated' ] :
515                $unactivatedClasses
516        );
517    }
518
519    /**
520     * @inheritDoc
521     */
522    public function getState() {
523        if ( $this->canRender() ) {
524            return (bool)$this->getArticleContributions() ?
525                self::MODULE_STATE_ACTIVATED :
526                self::MODULE_STATE_UNACTIVATED;
527        }
528        return self::MODULE_STATE_NOTRENDERED;
529    }
530
531    private function isActivated() {
532        return $this->getState() === self::MODULE_STATE_ACTIVATED;
533    }
534
535    /**
536     * Check if impact module is unactivated and suggested edits module is enabled
537     *
538     * @return bool
539     */
540    private function isUnactivatedWithSuggestedEdits() {
541        return $this->getState() === self::MODULE_STATE_UNACTIVATED && $this->isSuggestedEditsEnabledForUser;
542    }
543
544    /**
545     * @return array Top 10 recently edited articles with pageviews
546     */
547    public function getArticleContributions() {
548        if ( $this->contribs === null ) {
549            $this->contribs = $this->queryArticleEdits();
550            if ( count( $this->contribs ) ) {
551                // Add pageviews data
552                $this->addPageViews( $this->contribs );
553
554                // Sort by pageviews DESC
555                usort( $this->contribs, static function ( $a, $b ) {
556                    return ( $b['views'] ?? -1 ) <=> ( $a['views'] ?? -1 );
557                } );
558                // Generate the edits table for later use.
559                $this->generateEditsTable();
560            }
561        }
562        return $this->contribs;
563    }
564
565    private function getTotalPageViews() {
566        if ( !$this->isActivated() ) {
567            return 0;
568        }
569        $views = array_reduce(
570            $this->getArticleContributions(),
571            static function ( $subTotal, $contrib ) {
572                return $subTotal + ( $contrib['views'] ?? 0 );
573            },
574            0
575        );
576        return $views;
577    }
578
579    /**
580     * Query the last 10 edited pages and the timestamp of the first edit for those pages.
581     *
582     * @return array[] like [ 'title' => <Title object>, 'ts' => <DateTime object> ]
583     * @throws Exception
584     */
585    private function queryArticleEdits() {
586        $actorMigration = ActorMigration::newMigration();
587        $dbr = $this->connectionProvider->getReplicaDatabase();
588        $actorQuery = $actorMigration->getWhere( $dbr, 'rev_user', $this->getContext()->getUser() );
589        $subquery = $dbr->newSelectQueryBuilder()
590            ->select( [ 'rev_page', 'page_title', 'page_namespace', 'rev_timestamp' ] )
591            ->from( 'revision' )
592            ->tables( $actorQuery[ 'tables' ] )
593            ->joinConds( $actorQuery[ 'joins' ] )
594            ->join( 'page', null, 'rev_page = page_id' )
595            ->where( [
596                $actorQuery[ 'conds' ],
597                'rev_deleted' => 0,
598                'page_namespace' => 0,
599            ] )
600            ->orderBy( 'rev_timestamp', SelectQueryBuilder::SORT_DESC )
601            ->limit( 1000 )
602            ->caller( __METHOD__ );
603        $result = $dbr->newSelectQueryBuilder()
604            ->select( [
605                'rev_page',
606                'page_title',
607                'page_namespace',
608                'max_ts' => 'MAX(rev_timestamp)',
609                'min_ts' => 'MIN(rev_timestamp)',
610            ] )
611            ->from( $subquery, 'latest_edits' )
612            ->groupBy( 'rev_page' )
613            ->orderBy( 'max_ts', SelectQueryBuilder::SORT_DESC )
614            ->limit( 10 )
615            ->caller( __METHOD__ )
616            ->fetchResultSet();
617        $contribs = [];
618        foreach ( $result as $row ) {
619            $contribs[] = [
620                'title' => Title::newFromRow( $row ),
621                'ts' => new DateTime( $row->min_ts ),
622            ];
623        }
624        return $contribs;
625    }
626
627    /**
628     * Get the total number of article edits made by the current user.
629     *
630     * @return int
631     * @throws Exception
632     */
633    private function getArticleEditCount() {
634        $dbr = $this->connectionProvider->getReplicaDatabase();
635        return $dbr->newSelectQueryBuilder()
636            ->select( 'rev_id' )
637            ->from( 'revision' )
638            ->join( 'page', null, [ 'rev_page = page_id' ] )
639            ->where( [
640                'rev_actor' => MediaWikiServices::getInstance()->getActorNormalization()->findActorId(
641                    $this->getUser(),
642                    $dbr
643                ),
644                'rev_deleted' => 0,
645                'page_namespace' => NS_MAIN,
646            ] )
647            ->caller( __METHOD__ )
648            ->fetchRowCount();
649    }
650
651    private function getArticleOrTotalEditCountText() {
652        $user = $this->getContext()->getUser();
653        if ( $user->getEditCount() < 1000 ) {
654            $msgKey = 'growthexperiments-homepage-impact-mobilebody-articleedits';
655            $count = $this->getArticleEditCount();
656        } else {
657            $msgKey = 'growthexperiments-homepage-impact-mobilebody-totaledits';
658            $count = $user->getEditCount();
659        }
660        return $this->getContext()->msg( $msgKey )
661            ->numParams( $count )
662            ->parse();
663    }
664
665    /**
666     * Add pageviews information to the array of recent contributions.
667     *
668     * @param array[] &$contribs Recent contributions
669     */
670    private function addPageViews( &$contribs ) {
671        $titles = array_column( $contribs, 'title' );
672        $days = min( 60, $this->daysSince( end( $contribs )[ 'ts' ] ) );
673        $data = $this->pageViewService->getPageData( $titles, $days );
674        if ( $data->isGood() ) {
675            foreach ( $contribs as &$contrib ) {
676                $viewsByDay = $data->getValue()[ $contrib[ 'title' ]->getPrefixedDBkey() ] ?? [];
677                if ( $viewsByDay ) {
678                    $editDate = $contrib[ 'ts' ];
679                    // go back to the beginning of the day of the edit
680                    $editDate->setTime( 0, 0 );
681                    $viewsByDaySinceEdit = array_filter(
682                        $viewsByDay,
683                        static function ( $views, $date ) use ( $editDate ) {
684                            return new DateTime( $date ) >= $editDate;
685                        },
686                        ARRAY_FILTER_USE_BOTH
687                    );
688                    if ( $viewsByDaySinceEdit ) {
689                        $contrib['views'] = array_reduce(
690                            $viewsByDaySinceEdit,
691                            static function ( $total, $views ) {
692                                return $total + ( is_numeric( $views ) ? $views : 0 );
693                            },
694                            0
695                        );
696                    } else {
697                        $contrib[ 'views' ] = null;
698                    }
699                } else {
700                    $contrib[ 'views' ] = null;
701                }
702            }
703        }
704    }
705
706    /**
707     * Get image URL for a page
708     * Depends on the PageImages extension.
709     *
710     * @param Title $title
711     * @return bool|string
712     */
713    private function getImage( Title $title ) {
714        if ( !ExtensionRegistry::getInstance()->isLoaded( 'PageImages' ) ) {
715            return false;
716        }
717
718        $imageFile = PageImages::getPageImage( $title );
719        if ( $imageFile ) {
720            $ratio = $imageFile->getWidth() / $imageFile->getHeight();
721            $options = [
722                'width' => $ratio > 1 ?
723                    self::THUMBNAIL_SIZE / $imageFile->getHeight() * $imageFile->getWidth() :
724                    self::THUMBNAIL_SIZE
725            ];
726            $thumb = $imageFile->transform( $options );
727            if ( $thumb ) {
728                return $thumb->getUrl();
729            }
730        }
731
732        return false;
733    }
734
735    /**
736     * @param DateTime $timestamp
737     * @return int Number of days since, and including, the given timestamp
738     * @throws Exception
739     */
740    private function daysSince( DateTime $timestamp ) {
741        $now = MWTimestamp::getInstance();
742        $diff = $now->timestamp->diff( $timestamp );
743        return $diff->days;
744    }
745
746    /**
747     * @param Title $title
748     * @param DateTime $start
749     * @return string Full URL for the PageViews tool for the given title and start date
750     * @throws Exception
751     */
752    private function getPageViewToolsUrl( $title, $start ) {
753        $baseUrl = 'https://pageviews.wmcloud.org/';
754        $format = 'Y-m-d';
755        return wfAppendQuery( $baseUrl, [
756            'project' => $this->getContext()->getConfig()->get( 'ServerName' ),
757            'userlang' => $this->getContext()->getLanguage()->getCode(),
758            'start' => $start->format( $format ),
759            'end' => 'latest',
760            'pages' => $title->getPrefixedDBkey(),
761        ] );
762    }
763
764    /**
765     * @inheritDoc
766     */
767    protected function getHeaderIconName() {
768        return 'chart';
769    }
770
771    /**
772     * @return string|null
773     */
774    protected function getEditsTable() {
775        return $this->editsTable;
776    }
777}