Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
6.05% covered (danger)
6.05%
15 / 248
3.70% covered (danger)
3.70%
1 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Mentorship
6.05% covered (danger)
6.05%
15 / 248
3.70% covered (danger)
3.70%
1 / 27
1504.89
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getMentorLastActive
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 getHeaderText
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 buildSection
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getEllipsisWidget
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getCssClasses
0.00% covered (danger)
0.00%
0 / 3
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
 getBody
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getMobileSummaryBody
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getFooter
0.00% covered (danger)
0.00%
0 / 12
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
 getModules
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getJsConfigVars
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 getActionData
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 canRender
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getMentorUsernameElement
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
6
 getMentorInfo
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getEditCount
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 getLastActive
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getIntroText
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 getQuestionButton
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 getMentor
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getRecentQuestionsSection
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getRecentQuestions
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getAboutMentorshipElement
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
2
 getUserGender
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMentorGender
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 DateInterval;
6use GrowthExperiments\ExperimentUserManager;
7use GrowthExperiments\HelpPanel;
8use GrowthExperiments\HelpPanel\QuestionRecord;
9use GrowthExperiments\HelpPanel\QuestionStoreFactory;
10use GrowthExperiments\MentorDashboard\MentorTools\MentorStatusManager;
11use GrowthExperiments\Mentorship\MentorManager;
12use GrowthExperiments\Mentorship\Provider\MentorProvider;
13use LogicException;
14use MediaWiki\Cache\GenderCache;
15use MediaWiki\Config\Config;
16use MediaWiki\Config\ConfigException;
17use MediaWiki\Context\IContextSource;
18use MediaWiki\Html\Html;
19use MediaWiki\User\User;
20use MediaWiki\User\UserEditTracker;
21use MediaWiki\User\UserIdentity;
22use MediaWiki\Utils\MWTimestamp;
23use MessageLocalizer;
24use OOUI\ButtonWidget;
25use OOUI\IconWidget;
26
27/**
28 * This is the "Mentorship" module. It shows your mentor and
29 * provides ways to interact with them.
30 */
31class Mentorship extends BaseModule {
32
33    public const MENTORSHIP_MODULE_QUESTION_TAG = 'mentorship module question';
34    public const MENTORSHIP_HELPPANEL_QUESTION_TAG = 'mentorship panel question';
35    public const QUESTION_PREF = 'growthexperiments-mentor-questions';
36
37    private ?UserIdentity $mentor = null;
38
39    /** @var QuestionRecord[] */
40    private array $recentQuestions = [];
41    private MentorManager $mentorManager;
42
43    private MentorStatusManager $mentorStatusManager;
44    private GenderCache $genderCache;
45    private UserEditTracker $userEditTracker;
46
47    public function __construct(
48        IContextSource $context,
49        Config $wikiConfig,
50        ExperimentUserManager $experimentUserManager,
51        MentorManager $mentorManager,
52        MentorStatusManager $mentorStatusManager,
53        GenderCache $genderCache,
54        UserEditTracker $userEditTracker
55    ) {
56        parent::__construct( 'mentorship', $context, $wikiConfig, $experimentUserManager );
57        $this->mentorManager = $mentorManager;
58        $this->mentorStatusManager = $mentorStatusManager;
59        $this->genderCache = $genderCache;
60        $this->userEditTracker = $userEditTracker;
61    }
62
63    /**
64     * Get the time a mentor was last active, as a human-readable relative time.
65     * @param UserIdentity $mentor The mentoring user.
66     * @param User $mentee The mentored user (for time formatting).
67     * @param MessageLocalizer $messageLocalizer
68     * @param UserEditTracker $userEditTracker
69     * @return string
70     */
71    public static function getMentorLastActive(
72        UserIdentity $mentor, User $mentee,
73        MessageLocalizer $messageLocalizer, UserEditTracker $userEditTracker
74    ) {
75        $editTimestamp = new MWTimestamp( $userEditTracker->getLatestEditTimestamp( $mentor ) );
76        $editTimestamp->offsetForUser( $mentee );
77        $editDate = $editTimestamp->format( 'Ymd' );
78
79        $now = new MWTimestamp();
80        $now->offsetForUser( $mentee );
81        $timeDiff = $now->diff( $editTimestamp );
82
83        $today = $now->format( 'Ymd' );
84        $yesterday = $now->timestamp->sub( new DateInterval( 'P1D' ) )->format( 'Ymd' );
85
86        if ( $editDate === $today ) {
87            $text = $messageLocalizer
88                ->msg( 'growthexperiments-homepage-mentorship-mentor-active-today' )
89                ->params( $mentor->getName() )
90                ->text();
91        } elseif ( $editDate === $yesterday ) {
92            $text = $messageLocalizer
93                ->msg( 'growthexperiments-homepage-mentorship-mentor-active-yesterday' )
94                ->params( $mentor->getName() )
95                ->text();
96        } else {
97            $text = $messageLocalizer
98                ->msg( 'growthexperiments-homepage-mentorship-mentor-active-days-ago' )
99                ->params( $mentor->getName() )
100                ->numParams( $timeDiff->days )
101                ->text();
102        }
103        return $text;
104    }
105
106    /**
107     * @inheritDoc
108     */
109    protected function getHeaderText() {
110        return $this->getContext()
111            ->msg( 'growthexperiments-homepage-mentorship-header' )
112            ->params( $this->getContext()->getUser()->getName() )
113            ->params( $this->getMentor()->getName() )
114            ->text();
115    }
116
117    /**
118     * @inheritDoc
119     */
120    protected function buildSection( $name, $content, $tag = 'div' ) {
121        if ( $name === 'header' && $this->getMode() === self::RENDER_DESKTOP ) {
122            return Html::rawElement(
123                'div',
124                [ 'class' => 'growthexperiments-homepage-mentorship-header-wrapper' ],
125                implode( "\n", [
126                    parent::buildSection( $name, $content, $tag ),
127                    $this->getEllipsisWidget(),
128                ] )
129            );
130        }
131        return parent::buildSection( $name, $content, $tag );
132    }
133
134    /**
135     * @return string
136     */
137    private function getEllipsisWidget() {
138        // NOTE: This will be replaced with ButtonMenuSelectWidget in EllipsisMenu.js on the
139        // client side.
140        return Html::rawElement(
141            'div',
142            [ 'class' => 'growthexperiments-homepage-mentorship-ellipsis' ],
143            new ButtonWidget( [
144                'id' => 'mw-ge-homepage-mentorship-ellipsis',
145                'icon' => 'ellipsis',
146                'framed' => false,
147                'invisibleLabel' => true,
148                'infusable' => true,
149            ] )
150        );
151    }
152
153    /**
154     * @inheritDoc
155     */
156    protected function getCssClasses(): array {
157        return array_merge( parent::getCssClasses(),
158            // Enable "Poor man's dark mode" per-module. Temporary workaround for T357699.
159            // FIXME: This should be removed when there is capacity for updating the extension
160            // to use Codex design tokens.
161            [ 'notheme skin-invert ' ],
162        );
163    }
164
165    /**
166     * @inheritDoc
167     */
168    protected function getHeaderIconName() {
169        return 'userTalk';
170    }
171
172    /**
173     * @inheritDoc
174     */
175    protected function getBody() {
176        return implode( "\n", [
177            $this->getAboutMentorshipElement(),
178            $this->getMentorUsernameElement( true ),
179            $this->getMentorInfo(),
180            $this->getIntroText(),
181            $this->getQuestionButton(),
182            $this->getRecentQuestionsSection(),
183        ] );
184    }
185
186    /**
187     * @inheritDoc
188     */
189    protected function getMobileSummaryBody() {
190        return implode( "\n", [
191            Html::element( 'p', [], $this->msg(
192                'growthexperiments-homepage-mentorship-preintro',
193                $this->getMentor()->getName()
194            )->text() ),
195            $this->getMentorUsernameElement( false ),
196            $this->getLastActive(),
197        ] );
198    }
199
200    /**
201     * @inheritDoc
202     */
203    protected function getFooter() {
204        return Html::element(
205            'a',
206            [
207                'href' => User::newFromIdentity( $this->getMentor() )->getTalkPage()->getLinkURL(),
208                'data-link-id' => 'mentor-usertalk',
209            ],
210            $this->getContext()
211                ->msg( 'growthexperiments-homepage-mentorship-mentor-conversations' )
212                ->params( $this->getMentor()->getName() )
213                ->params( $this->getContext()->getUser()->getName() )
214                ->text()
215        );
216    }
217
218    /**
219     * @inheritDoc
220     */
221    protected function getModuleStyles() {
222        return array_merge(
223            parent::getModuleStyles(),
224            [ 'oojs-ui.styles.icons-user' ]
225        );
226    }
227
228    /**
229     * @inheritDoc
230     */
231    protected function getModules() {
232        return $this->getMode() !== self::RENDER_MOBILE_SUMMARY ?
233            [ 'ext.growthExperiments.Homepage.Mentorship' ] : [];
234    }
235
236    /**
237     * @inheritDoc
238     */
239    protected function getJsConfigVars() {
240        $mentor = $this->getMentor();
241        $effectiveMentor = $this->mentorManager->getEffectiveMentorForUserSafe(
242            $this->getUser()
243        )->getUserIdentity();
244        $mentorBackTimestamp = $this->mentorStatusManager->getMentorBackTimestamp( $mentor );
245        return [
246            'GEHomepageMentorshipMentorName' => $mentor->getName(),
247            'GEHomepageMentorshipMentorGender' => $this->getMentorGender(),
248            'GEHomepageMentorshipEffectiveMentorName' => $effectiveMentor->getName(),
249            'GEHomepageMentorshipEffectiveMentorGender' => $this->getUserGender( $effectiveMentor ),
250            'GEHomepageMentorshipBackAt' => $mentorBackTimestamp ? $this->getContext()->getLanguage()->date(
251                $mentorBackTimestamp
252            ) : null,
253        ] + HelpPanel::getUserEmailConfigVars( $this->getContext()->getUser() );
254    }
255
256    /**
257     * @inheritDoc
258     */
259    protected function getActionData() {
260        $archivedQuestions = 0;
261        $unarchivedQuestions = 0;
262        foreach ( $this->getRecentQuestions() as $questionRecord ) {
263            if ( $questionRecord->isArchived() ) {
264                $archivedQuestions++;
265            } else {
266                $unarchivedQuestions++;
267            }
268        }
269
270        return array_merge(
271            parent::getActionData(),
272            [
273                'mentorEditCount' => $this->userEditTracker->getUserEditCount( $this->getMentor() ),
274                'mentorLastActive' => $this->userEditTracker->getLatestEditTimestamp( $this->getMentor() ),
275                'archivedQuestions' => $archivedQuestions,
276                'unarchivedQuestions' => $unarchivedQuestions,
277            ]
278        );
279    }
280
281    /**
282     * @inheritDoc
283     */
284    protected function canRender() {
285        return $this->mentorManager->getMentorshipStateForUser(
286            $this->getUser()
287        ) === MentorManager::MENTORSHIP_ENABLED &&
288            $this->mentorManager->getEffectiveMentorForUserSafe( $this->getUser() ) !== null;
289    }
290
291    private function getMentorUsernameElement( $link ) {
292        $iconElement = new IconWidget( [ 'icon' => 'mentor' ] );
293        $dir = $this->getContext()->getLanguage()->getDir();
294        $usernameElement = Html::rawElement(
295            'span',
296            [ 'class' => 'growthexperiments-homepage-mentorship-username' ],
297            Html::element( 'bdi', [ 'dir' => $dir ], $this->getMentor()->getName() )
298        );
299        if ( $link ) {
300            $content = Html::rawElement(
301                'a',
302                [
303                    'href' => User::newFromIdentity( $this->getMentor() )->getUserPage()->getLinkURL(),
304                    'data-link-id' => 'mentor-userpage',
305                    'class' => 'growthexperiments-homepage-mentorship-userlink-link',
306                ],
307                $iconElement . $usernameElement
308            );
309        } else {
310            $content = Html::rawElement(
311                'span',
312                [],
313                $iconElement . $usernameElement
314            );
315        }
316        return Html::rawElement( 'div', [
317            'class' => 'growthexperiments-homepage-mentorship-userlink',
318        ], $content );
319    }
320
321    private function getMentorInfo() {
322        return Html::rawElement(
323            'div',
324            [
325                'class' => 'growthexperiments-homepage-mentorship-mentorinfo',
326            ],
327            $this->getEditCount() . ' &bull; ' . $this->getLastActive()
328        );
329    }
330
331    private function getEditCount() {
332        $mentorEditCount = $this->userEditTracker->getUserEditCount( $this->getMentor() );
333        if ( !is_int( $mentorEditCount ) ) {
334            throw new LogicException(
335                'UserEditTracker returned non-integer for user ' . $this->getMentor()->getName()
336            );
337        }
338
339        $text = $this->getContext()
340            ->msg( 'growthexperiments-homepage-mentorship-mentor-edits' )
341            ->numParams( $mentorEditCount )
342            ->text();
343        return Html::element( 'span', [
344            'class' => 'growthexperiments-homepage-mentorship-editcount',
345        ], $text );
346    }
347
348    private function getLastActive() {
349        $text = self::getMentorLastActive( $this->getMentor(), $this->getContext()->getUser(),
350            $this->getContext(), $this->userEditTracker );
351        return Html::element( 'span', [
352            'class' => 'growthexperiments-homepage-mentorship-lastactive',
353        ], $text );
354    }
355
356    private function getIntroText() {
357        $mentor = $this->mentorManager->getMentorForUser( $this->getContext()->getUser() );
358
359        $introText = $this->getContext()->getLanguage()->truncateForVisual(
360            $mentor->getIntroText(),
361            MentorProvider::INTRO_TEXT_LENGTH
362        );
363        if ( $mentor->hasCustomIntroText() ) {
364            $introText = $this->msg( 'quotation-marks' )
365                ->inContentLanguage()
366                ->params( $introText )
367                ->text();
368        }
369
370        return Html::element(
371            'div',
372            [ 'class' => 'growthexperiments-homepage-mentorship-intro' ],
373            $introText
374        );
375    }
376
377    private function getQuestionButton() {
378        return new ButtonWidget( [
379            'id' => 'mw-ge-homepage-mentorship-cta',
380            'classes' => [ 'growthexperiments-homepage-mentorship-cta' ],
381            'active' => false,
382            'label' => $this->getContext()
383                ->msg( 'growthexperiments-homepage-mentorship-question-button' )
384                ->params( $this->getMentor()->getName() )
385                ->params( $this->getContext()->getUser()->getName() )
386                ->text(),
387            // nojs action
388            'href' => User::newFromIdentity( $this->getMentor() )->getTalkPage()->getLinkURL( [
389                'action' => 'edit',
390                'section' => 'new',
391            ] ),
392            'infusable' => true,
393        ] );
394    }
395
396    /**
397     * @return UserIdentity|false The current user's mentor or false if not set.
398     * @throws ConfigException
399     */
400    private function getMentor() {
401        if ( !$this->mentor ) {
402            $mentor = $this->mentorManager->getMentorForUserSafe( $this->getContext()->getUser() );
403            if ( $mentor ) {
404                $this->mentor = $mentor->getUserIdentity();
405            } else {
406                return false;
407            }
408        }
409        return $this->mentor;
410    }
411
412    private function getRecentQuestionsSection() {
413        $recentQuestionFormatter = new RecentQuestionsFormatter(
414            $this->getContext(),
415            $this->getRecentQuestions(),
416            self::QUESTION_PREF
417        );
418        return $recentQuestionFormatter->format();
419    }
420
421    private function getRecentQuestions() {
422        if ( count( $this->recentQuestions ) ) {
423            return $this->recentQuestions;
424        }
425        $this->recentQuestions = QuestionStoreFactory::newFromContextAndStorage(
426            $this->getContext(),
427            self::QUESTION_PREF
428        )->loadQuestions();
429        return $this->recentQuestions;
430    }
431
432    private function getAboutMentorshipElement() {
433        return Html::rawElement(
434            'p',
435            [ 'class' => 'growthexperiments-homepage-mentorship-about' ],
436            implode( "\n", [
437                Html::element(
438                    'span',
439                    [],
440                    $this->msg(
441                        'growthexperiments-homepage-mentorship-preintro',
442                        $this->getMentor()->getName()
443                    )->text()
444                ),
445                Html::element(
446                    'a',
447                    [
448                        'id' => 'growthexperiments-homepage-mentorship-learn-more',
449                        'href' => '#',
450                    ],
451                    $this->msg( 'growthexperiments-homepage-mentorship-learn-more' )->text()
452                ),
453            ] )
454        );
455    }
456
457    /**
458     * Get the gender of the specified user
459     *
460     * @param UserIdentity $user
461     * @return string
462     */
463    private function getUserGender( UserIdentity $user ): string {
464        return $this->genderCache->getGenderOf( $user, __METHOD__ );
465    }
466
467    /**
468     * Get the gender of the mentor
469     *
470     * @return string
471     */
472    private function getMentorGender(): string {
473        return $this->getUserGender( $this->getMentor() );
474    }
475
476}