Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
14.46% covered (danger)
14.46%
73 / 505
10.26% covered (danger)
10.26%
4 / 39
CRAP
0.00% covered (danger)
0.00%
0 / 1
SkinMinerva
14.46% covered (danger)
14.46%
73 / 505
10.26% covered (danger)
10.26%
4 / 39
14807.13
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 hasPageActions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 hasSecondaryActions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isFallbackEditor
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getPageActions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getNotificationFallbackButton
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getCombinedNotificationButton
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 getNotificationCircleButton
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getNotificationButton
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 runOnSkinTemplateNavigationHooks
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
90
 getTemplateData
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 1
72
 getNotificationButtons
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
7
 isHistoryPage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasPageTabs
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 getTabsData
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getMainMenu
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getPersonalToolsMenu
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 getSubjectPage
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
56
 doEditSectionLinksHTML
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getPageClasses
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 resolveNightModeQueryValue
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
56
 getHtmlElementAttributes
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
6.04
 hasCategoryLinks
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getRelativeHistoryLink
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getGenericHistoryLink
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 shouldUseSpecialHistory
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getHistoryUrl
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getHistoryLink
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
72
 getRevisionEditorData
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getTaglineHtml
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 getUserPageHeadingHtml
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 prepareBanners
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getLanguageButton
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getTalkButton
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 getSecondaryActions
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
156
 getJsConfigVars
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultModules
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 getPageSpecificStyles
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
210
 getFeatureSpecificStyles
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\Minerva\Skins;
22
23use ExtensionRegistry;
24use Language;
25use MediaWiki\Cache\GenderCache;
26use MediaWiki\Extension\Notifications\Controller\NotificationController;
27use MediaWiki\Html\Html;
28use MediaWiki\Linker\LinkRenderer;
29use MediaWiki\Linker\LinkTarget;
30use MediaWiki\MediaWikiServices;
31use MediaWiki\Minerva\LanguagesHelper;
32use MediaWiki\Minerva\Menu\Definitions;
33use MediaWiki\Minerva\Menu\Main\AdvancedMainMenuBuilder;
34use MediaWiki\Minerva\Menu\Main\DefaultMainMenuBuilder;
35use MediaWiki\Minerva\Menu\Main\MainMenuDirector;
36use MediaWiki\Minerva\Menu\PageActions\PageActionsDirector;
37use MediaWiki\Minerva\Menu\User\AdvancedUserMenuBuilder;
38use MediaWiki\Minerva\Menu\User\DefaultUserMenuBuilder;
39use MediaWiki\Minerva\Menu\User\UserMenuDirector;
40use MediaWiki\Minerva\Permissions\IMinervaPagePermissions;
41use MediaWiki\Minerva\Permissions\MinervaPagePermissions;
42use MediaWiki\Minerva\SkinOptions;
43use MediaWiki\Revision\RevisionLookup;
44use MediaWiki\SpecialPage\SpecialPage;
45use MediaWiki\Title\NamespaceInfo;
46use MediaWiki\Title\Title;
47use MediaWiki\User\Options\UserOptionsManager;
48use MediaWiki\User\UserIdentityUtils;
49use MediaWiki\Utils\MWTimestamp;
50use RuntimeException;
51use SkinMustache;
52use SkinTemplate;
53use SpecialMobileHistory;
54
55/**
56 * Minerva: Born from the godhead of Jupiter with weapons!
57 * A skin that works on both desktop and mobile
58 * @ingroup Skins
59 */
60class SkinMinerva extends SkinMustache {
61    /** @const LEAD_SECTION_NUMBER integer which corresponds to the lead section
62     * in editing mode
63     */
64    public const LEAD_SECTION_NUMBER = 0;
65
66    /** @var string Name of this skin */
67    public $skinname = 'minerva';
68    /** @var string Name of this used template */
69    public $template = 'MinervaTemplate';
70
71    /** @var array|null */
72    private $contentNavigationUrls;
73    private GenderCache $genderCache;
74    private LinkRenderer $linkRenderer;
75    private LanguagesHelper $languagesHelper;
76    private Definitions $definitions;
77    private IMinervaPagePermissions $permissions;
78    private SkinOptions $skinOptions;
79    private SkinUserPageHelper $skinUserPageHelper;
80    private NamespaceInfo $namespaceInfo;
81    private RevisionLookup $revisionLookup;
82    private UserIdentityUtils $userIdentityUtils;
83    private UserOptionsManager $userOptionsManager;
84
85    /**
86     * @param GenderCache $genderCache
87     * @param LinkRenderer $linkRenderer
88     * @param LanguagesHelper $languagesHelper
89     * @param Definitions $definitions
90     * @param MinervaPagePermissions $permissions
91     * @param SkinOptions $skinOptions
92     * @param SkinUserPageHelper $skinUserPageHelper
93     * @param NamespaceInfo $namespaceInfo
94     * @param RevisionLookup $revisionLookup
95     * @param UserIdentityUtils $userIdentityUtils
96     * @param UserOptionsManager $userOptionsManager
97     * @param array $options
98     */
99    public function __construct(
100        GenderCache $genderCache,
101        LinkRenderer $linkRenderer,
102        LanguagesHelper $languagesHelper,
103        Definitions $definitions,
104        MinervaPagePermissions $permissions,
105        SkinOptions $skinOptions,
106        SkinUserPageHelper $skinUserPageHelper,
107        NamespaceInfo $namespaceInfo,
108        RevisionLookup $revisionLookup,
109        UserIdentityUtils $userIdentityUtils,
110        UserOptionsManager $userOptionsManager,
111        $options = []
112    ) {
113        parent::__construct( $options );
114        $this->genderCache = $genderCache;
115        $this->linkRenderer = $linkRenderer;
116        $this->languagesHelper = $languagesHelper;
117        $this->definitions = $definitions
118            ->setContext( $this->getContext() );
119        $this->permissions = $permissions
120            ->setContext( $this->getContext() );
121        $this->skinOptions = $skinOptions;
122        $this->skinUserPageHelper = $skinUserPageHelper
123            ->setContext( $this->getContext() )
124            ->setTitle( $this->getTitle() );
125        $this->namespaceInfo = $namespaceInfo;
126        $this->revisionLookup = $revisionLookup;
127        $this->userIdentityUtils = $userIdentityUtils;
128        $this->userOptionsManager = $userOptionsManager;
129    }
130
131    /**
132     * @return bool
133     */
134    private function hasPageActions(): bool {
135        $title = $this->getTitle();
136        return !$title->isSpecialPage() && !$title->isMainPage() &&
137            $this->getContext()->getActionName() === 'view';
138    }
139
140    /**
141     * @return bool
142     */
143    private function hasSecondaryActions(): bool {
144        return !$this->skinUserPageHelper->isUserPage();
145    }
146
147    /**
148     * @return bool
149     */
150    private function isFallbackEditor(): bool {
151        $action = $this->getContext()->getActionName();
152        return $action === 'edit';
153    }
154
155    /**
156     * Returns available page actions if the page has any.
157     *
158     * @param array $nav result of SkinTemplate::buildContentNavigationUrls
159     * @return array|null
160     */
161    private function getPageActions( array $nav ): ?array {
162        if ( $this->isFallbackEditor() || !$this->hasPageActions() ) {
163            return null;
164        }
165        $services = MediaWikiServices::getInstance();
166        /** @var PageActionsDirector $pageActionsDirector */
167        $pageActionsDirector = $services->getService( 'Minerva.Menu.PageActionsDirector' );
168        $sidebar = $this->buildSidebar();
169        $actions = $nav['actions'] ?? [];
170        $views = $nav['views'] ?? [];
171        return $pageActionsDirector->buildMenu( $sidebar['TOOLBOX'], $actions, $views );
172    }
173
174    /**
175     * A notification icon that links to Special:Mytalk when Echo is not installed.
176     * Consider upstreaming this to core or removing at a future date.
177     *
178     * @return array
179     */
180    private function getNotificationFallbackButton(): array {
181        return [
182            'icon' => 'bellOutline-base20',
183            'href' => SpecialPage::getTitleFor( 'Mytalk' )->getLocalURL(
184                [ 'returnto' => $this->getTitle()->getPrefixedText() ]
185            ),
186        ];
187    }
188
189    /**
190     * @param array $alert
191     * @param array $notice
192     * @return array
193     */
194    private function getCombinedNotificationButton( array $alert, array $notice ): array {
195        // Sum the notifications from the two original buttons
196        $notifCount = ( $alert['data']['counter-num'] ?? 0 ) + ( $notice['data']['counter-num'] ?? 0 );
197        $alert['data']['counter-num'] = $notifCount;
198        // @phan-suppress-next-line PhanUndeclaredClassReference
199        if ( class_exists( NotificationController::class ) ) {
200            // @phan-suppress-next-line PhanUndeclaredClassMethod
201            $alert['data']['counter-text'] = NotificationController::formatNotificationCount( $notifCount );
202        } else {
203            $alert['data']['counter-text'] = $notifCount;
204        }
205
206        $linkClassAlert = $alert['link-class'] ?? [];
207        $hasUnseenAlerts = is_array( $linkClassAlert ) && in_array( 'mw-echo-unseen-notifications', $linkClassAlert );
208        // The circle should only appear if there are unseen notifications.
209        // Once the notifications are seen (by opening the notification drawer)
210        // then the icon reverts to a gray circle, but on page refresh
211        // it should revert back to a bell icon.
212        // If you try and change this behaviour, at time of writing
213        // (December 2022) JavaScript will correct it.
214        if ( $notifCount > 0 && $hasUnseenAlerts ) {
215            $linkClass = $notice['link-class'] ?? [];
216            $hasUnseenNotices = is_array( $linkClass ) && in_array( 'mw-echo-unseen-notifications', $linkClass );
217            return $this->getNotificationCircleButton( $alert, $hasUnseenNotices );
218        } else {
219            return $this->getNotificationButton( $alert );
220        }
221    }
222
223    /**
224     * Minerva differs from other skins in that for users with unread notifications
225     * instead of a bell with a small square indicating the number of notifications
226     * it shows a red circle with a number inside. Ideally Vector and Minerva would
227     * be treated the same but we'd need to talk to a designer about consolidating these
228     * before making such a decision.
229     *
230     * @param array $alert
231     * @param bool $hasUnseenNotices does the user have unseen notices?
232     * @return array
233     */
234    private function getNotificationCircleButton( array $alert, bool $hasUnseenNotices ): array {
235        $alertCount = $alert['data']['counter-num'] ?? 0;
236        $linkClass = $alert['link-class'] ?? [];
237        $hasSeenAlerts = is_array( $linkClass ) && in_array( 'mw-echo-unseen-notifications', $linkClass );
238        $alertText = $alert['data']['counter-text'] ?? $alertCount;
239        $alert['icon'] = 'circle';
240        $alert['class'] = 'notification-count';
241        if ( $hasSeenAlerts || $hasUnseenNotices ) {
242            $alert['class'] .= ' notification-unseen mw-echo-unseen-notifications';
243        }
244        return $alert;
245    }
246
247    /**
248     * Removes the OOUI icon class and adds Minerva notification classes.
249     *
250     * @param array $alert
251     * @return array
252     */
253    private function getNotificationButton( array $alert ): array {
254        $linkClass = $alert['link-class'];
255        $alert['link-class'] = array_filter(
256            $linkClass,
257            static function ( $class ) {
258                return $class !== 'oo-ui-icon-bellOutline';
259            }
260        );
261        $alert['icon'] = 'bellOutline-base20';
262        return $alert;
263    }
264
265    /**
266     * Caches content navigation urls locally for use inside getTemplateData
267     *
268     * @inheritDoc
269     */
270    protected function runOnSkinTemplateNavigationHooks( SkinTemplate $skin, &$contentNavigationUrls ) {
271        parent::runOnSkinTemplateNavigationHooks( $skin, $contentNavigationUrls );
272        // There are some SkinTemplate modifications that occur after the execution of this hook
273        // to add rel attributes and ID attributes.
274        // The only one Minerva needs is this one so we manually add it.
275        foreach ( array_keys( $contentNavigationUrls['associated-pages'] ) as $id ) {
276            if ( in_array( $id, [ 'user_talk', 'talk' ] ) ) {
277                $contentNavigationUrls['associated-pages'][ $id ]['rel'] = 'discussion';
278            }
279        }
280        $this->contentNavigationUrls = $contentNavigationUrls;
281
282        //
283        // Echo Technical debt!!
284        // * Convert the Echo button into a single button
285        // * Switch out the icon.
286        //
287        if ( $this->getUser()->isRegistered() ) {
288            if ( count( $contentNavigationUrls['notifications'] ) === 0 ) {
289                // Shown to logged in users when Echo is not installed:
290                $contentNavigationUrls['notifications']['mytalks'] = $this->getNotificationFallbackButton();
291            } elseif ( $this->skinOptions->get( SkinOptions::SINGLE_ECHO_BUTTON ) ) {
292                // Combine notification icons. Minerva only shows one entry point to notifications.
293                // This can be reconsidered with a solution to https://phabricator.wikimedia.org/T142981
294                $alert = $contentNavigationUrls['notifications']['notifications-alert'] ?? null;
295                $notice = $contentNavigationUrls['notifications']['notifications-notice'] ?? null;
296                if ( $alert && $notice ) {
297                    unset( $contentNavigationUrls['notifications']['notifications-notice'] );
298                    $contentNavigationUrls['notifications']['notifications-alert'] =
299                        $this->getCombinedNotificationButton( $alert, $notice );
300                }
301            } else {
302                // Show desktop alert icon.
303                $alert = $contentNavigationUrls['notifications']['notifications-alert'] ?? null;
304                if ( $alert ) {
305                    // Correct the icon to be the bell filled rather than the outline to match
306                    // Echo's badge.
307                    $linkClass = $alert['link-class'] ?? [];
308                    $alert['link-class'] = array_filter( $linkClass, static function ( $class ) {
309                        return $class !== 'oo-ui-icon-bellOutline';
310                    } );
311                    $contentNavigationUrls['notifications']['notifications-alert'] = $alert;
312                }
313            }
314        }
315    }
316
317    /**
318     * @inheritDoc
319     */
320    public function getTemplateData(): array {
321        $data = parent::getTemplateData();
322        // FIXME: Can we use $data instead of calling buildContentNavigationUrls ?
323        $nav = $this->contentNavigationUrls;
324        if ( $nav === null ) {
325            throw new RuntimeException( 'contentNavigationUrls was not set as expected.' );
326        }
327        if ( !$this->hasCategoryLinks() ) {
328            unset( $data['html-categories'] );
329        }
330
331        // Special handling for certain pages.
332        // This is technical debt that should be upstreamed to core.
333        $isUserPage = $this->skinUserPageHelper->isUserPage();
334        $isUserPageAccessible = $this->skinUserPageHelper->isUserPageAccessibleToCurrentUser();
335        if ( $isUserPage && $isUserPageAccessible ) {
336            $data['html-title-heading'] = $this->getUserPageHeadingHtml( $data['html-title-heading' ] );
337        }
338
339        $usermessage = $data['html-user-message'] ?? '';
340        if ( $usermessage ) {
341            $data['html-user-message'] = Html::warningBox(
342                '<span class="minerva-icon minerva-icon--userTalk-warning"></span>&nbsp;'
343                    . $usermessage,
344                'minerva-anon-talk-message'
345            );
346        }
347        $allLanguages = $data['data-portlets']['data-languages']['array-items'] ?? [];
348        $allVariants = $data['data-portlets']['data-variants']['array-items'] ?? [];
349        $notifications = $data['data-portlets']['data-notifications']['array-items'] ?? [];
350        $associatedPages = $data['data-portlets']['data-associated-pages'] ?? [];
351
352        return $data + [
353            'has-minerva-languages' => $allLanguages || $allVariants,
354            'array-minerva-banners' => $this->prepareBanners( $data['html-site-notice'] ),
355            'data-minerva-search-box' => $data['data-search-box'] + [
356                'data-btn' => [
357                    'data-icon' => [
358                        'icon' => 'search-base20',
359                    ],
360                    'label' => $this->msg( 'searchbutton' )->escaped(),
361                    'classes' => 'skin-minerva-search-trigger',
362                    'array-attributes' => [
363                        [
364                            'key' => 'id',
365                            'value' => 'searchIcon',
366                        ]
367                    ]
368                ],
369            ],
370            'data-minerva-main-menu-btn' => [
371                'data-icon' => [
372                    'icon' => 'menu-base20',
373                ],
374                'tag-name' => 'label',
375                'classes' => 'toggle-list__toggle',
376                'array-attributes' => [
377                    [
378                        'key' => 'for',
379                        'value' => 'main-menu-input',
380                    ],
381                    [
382                        'key' => 'id',
383                        'value' => 'mw-mf-main-menu-button',
384                    ],
385                    [
386                        'key' => 'aria-hidden',
387                        'value' => 'true',
388                    ],
389                    [
390                        'key' => 'data-event-name',
391                        'value' => 'ui.mainmenu',
392                    ],
393                ],
394                'text' => $this->msg( 'mobile-frontend-main-menu-button-tooltip' )->escaped(),
395            ],
396            'data-minerva-main-menu' => $this->getMainMenu()->getMenuData(
397                $nav,
398                $this->buildSidebar()
399            )['items'],
400            'html-minerva-tagline' => $this->getTaglineHtml(),
401            'html-minerva-user-menu' => $this->getPersonalToolsMenu( $nav['user-menu'] ),
402            'is-minerva-beta' => $this->skinOptions->get( SkinOptions::BETA_MODE ),
403            'data-minerva-notifications' => $notifications ? [
404                'array-buttons' => $this->getNotificationButtons( $notifications ),
405            ] : null,
406            'data-minerva-tabs' => $this->getTabsData( $nav, $associatedPages ),
407            'data-minerva-page-actions' => $this->getPageActions( $nav ),
408            'data-minerva-secondary-actions' => $this->getSecondaryActions( $nav ),
409            'html-minerva-subject-link' => $this->getSubjectPage(),
410            'data-minerva-history-link' => $this->getHistoryLink( $this->getTitle() ),
411        ];
412    }
413
414    /**
415     * Prepares the notification badges for the Button template.
416     *
417     * @internal
418     * @param array $notifications
419     * @return array
420     */
421    public static function getNotificationButtons( array $notifications ): array {
422        $btns = [];
423
424        foreach ( $notifications as $notification ) {
425            $linkData = $notification['array-links'][ 0 ] ?? [];
426            $icon = $linkData['icon'] ?? null;
427            if ( !$icon ) {
428                continue;
429            }
430            $id = $notification['id'] ?? null;
431            $classes = '';
432            $attributes = [];
433
434            // We don't want to output multiple attributes.
435            // Iterate through the attributes and pull out ID and class which
436            // will be defined separately.
437            foreach ( $linkData[ 'array-attributes' ] as $keyValuePair ) {
438                if ( $keyValuePair['key'] === 'class' ) {
439                    $classes = $keyValuePair['value'];
440                } elseif ( $keyValuePair['key'] === 'id' ) {
441                    // ignore. We want to use the LI `id` instead.
442                } else {
443                    $attributes[] = $keyValuePair;
444                }
445            }
446            // add LI ID to end for use on the button.
447            if ( $id ) {
448                $attributes[] = [
449                    'key' => 'id',
450                    'value' => $id,
451                ];
452            }
453            $btns[] = [
454                'tag-name' => 'a',
455                // FIXME: Move preg_replace when Echo no longer provides this class.
456                'classes' => preg_replace( '/oo-ui-icon-(bellOutline|tray)/', '', $classes ),
457                'array-attributes' => $attributes,
458                'data-icon' => [
459                    'icon' => $icon,
460                ],
461                'label' => $linkData['text'] ?? '',
462            ];
463        }
464        return $btns;
465    }
466
467    /**
468     * @return bool
469     */
470    private function isHistoryPage(): bool {
471        return $this->getRequest()->getRawVal( 'action' ) === 'history';
472    }
473
474    /**
475     * Tabs are available if a page has page actions but is not the talk page of
476     * the main page.
477     *
478     * Special pages have tabs if SkinOptions::TABS_ON_SPECIALS is enabled.
479     * This is used by Extension:GrowthExperiments
480     *
481     * @return bool
482     */
483    private function hasPageTabs(): bool {
484        $title = $this->getTitle();
485        $isSpecialPageOrHistory = $title->isSpecialPage() ||
486            $this->isHistoryPage();
487        $subjectPage = $this->namespaceInfo->getSubjectPage( $title );
488        $isMainPageTalk = Title::newFromLinkTarget( $subjectPage )->isMainPage();
489        return (
490                $this->hasPageActions() && !$isMainPageTalk &&
491                $this->skinOptions->get( SkinOptions::TALK_AT_TOP )
492            ) || (
493                $isSpecialPageOrHistory &&
494                $this->skinOptions->get( SkinOptions::TABS_ON_SPECIALS )
495            );
496    }
497
498    /**
499     * @param array $contentNavigationUrls
500     * @param array $associatedPages - data-associated-pages from template data, currently only used for ID
501     * @return array
502     */
503    private function getTabsData( array $contentNavigationUrls, array $associatedPages ): array {
504        $hasPageTabs = $this->hasPageTabs();
505        if ( !$hasPageTabs ) {
506            return [];
507        }
508        return $contentNavigationUrls ? [
509            'items' => array_values( $contentNavigationUrls['associated-pages'] ),
510            'id' => $associatedPages['id'] ?? null,
511        ] : [];
512    }
513
514    /**
515     * Build the Main Menu Director by passing the skin options
516     *
517     * @return MainMenuDirector
518     */
519    protected function getMainMenu(): MainMenuDirector {
520        $showMobileOptions = $this->skinOptions->get( SkinOptions::MOBILE_OPTIONS );
521        // Add a donate link (see https://phabricator.wikimedia.org/T219793)
522        $showDonateLink = $this->skinOptions->get( SkinOptions::SHOW_DONATE );
523        $builder = $this->skinOptions->get( SkinOptions::MAIN_MENU_EXPANDED ) ?
524            new AdvancedMainMenuBuilder(
525                $showMobileOptions,
526                $showDonateLink,
527                $this->definitions
528            ) :
529            new DefaultMainMenuBuilder(
530                $showMobileOptions,
531                $showDonateLink,
532                $this->getUser(),
533                $this->definitions,
534                $this->userIdentityUtils
535            );
536        return new MainMenuDirector( $builder );
537    }
538
539    /**
540     * Prepare all Minerva menus
541     *
542     * @param array $personalUrls result of SkinTemplate::buildPersonalUrls
543     * @return string|null
544     */
545    private function getPersonalToolsMenu( array $personalUrls ): ?string {
546        $builder = $this->skinOptions->get( SkinOptions::PERSONAL_MENU ) ?
547            new AdvancedUserMenuBuilder(
548                $this->getContext(),
549                $this->getUser(),
550                $this->definitions
551            ) :
552            new DefaultUserMenuBuilder();
553
554        $userMenuDirector = new UserMenuDirector(
555            $builder,
556            $this->getContext()->getSkin()
557        );
558        return $userMenuDirector->renderMenuData( $personalUrls );
559    }
560
561    /**
562     * @return string
563     */
564    protected function getSubjectPage(): string {
565        $title = $this->getTitle();
566
567        // If it's a talk page, add a link to the main namespace page
568        // In AMC we do not need to do this as there is an easy way back to the article page
569        // via the talk/article tabs.
570        if ( $title->isTalkPage() && !$this->skinOptions->get( SkinOptions::TALK_AT_TOP ) ) {
571            // if it's a talk page for which we have a special message, use it
572            switch ( $title->getNamespace() ) {
573                case NS_USER_TALK:
574                    $msg = 'mobile-frontend-talk-back-to-userpage';
575                    break;
576                case NS_PROJECT_TALK:
577                    $msg = 'mobile-frontend-talk-back-to-projectpage';
578                    break;
579                case NS_FILE_TALK:
580                    $msg = 'mobile-frontend-talk-back-to-filepage';
581                    break;
582                default:
583                    // generic (all other NS)
584                    $msg = 'mobile-frontend-talk-back-to-page';
585            }
586            $subjectPage = $this->namespaceInfo->getSubjectPage( $title );
587
588            return $this->linkRenderer->makeLink(
589                $subjectPage,
590                $this->msg( $msg, $title->getText() )->text(),
591                [
592                    'data-event-name' => 'talk.returnto',
593                    'class' => 'return-link'
594                ]
595            );
596        } else {
597            return '';
598        }
599    }
600
601    /**
602     * Modifies the template data before parsing in SkinMustache.
603     *
604     * @inheritDoc
605     */
606    final protected function doEditSectionLinksHTML( array $links, Language $lang ): string {
607        $transformedLinks = [];
608        foreach ( $links as $key => $link ) {
609            $transformedLinks[] = $link + [
610                'data-icon' => [
611                    'icon' => $link['icon'],
612                ],
613            ];
614        }
615        return parent::doEditSectionLinksHTML( $transformedLinks, $lang );
616    }
617
618    /**
619     * Takes a title and returns classes to apply to the body tag
620     * @param Title $title
621     * @return string
622     */
623    public function getPageClasses( $title ): string {
624        $className = parent::getPageClasses( $title );
625        $className .= ' ' . ( $this->skinOptions->get( SkinOptions::BETA_MODE )
626                ? 'beta' : 'stable' );
627
628        if ( $this->getUser()->isRegistered() ) {
629            $className .= ' is-authenticated';
630        }
631
632        // The new treatment should only apply to the main namespace
633        if (
634            $title->getNamespace() === NS_MAIN &&
635            $this->skinOptions->get( SkinOptions::PAGE_ISSUES )
636        ) {
637            $className .= ' issues-group-B';
638        }
639
640        return $className;
641    }
642
643    /**
644     * Converts "1", "2", and "0" to equivalent values.
645     *
646     * @return string
647     */
648    private static function resolveNightModeQueryValue( string $value ): string {
649        switch ( $value ) {
650            case 'day':
651            case 'night':
652            case 'os':
653                return $value;
654            case '1':
655                return 'night';
656            case '2':
657                return 'os';
658            default:
659                return 'day';
660        }
661    }
662
663    /**
664     * Provides skin-specific modifications to the HTML element attributes
665     *
666     * Currently only used for adding the night mode class
667     *
668     * @return array
669     */
670    public function getHtmlElementAttributes(): array {
671        $attributes = parent::getHtmlElementAttributes();
672
673        // check to see if night mode is enabled via query params or by config
674        $webRequest = $this->getContext()->getRequest();
675        $forceNightMode = $webRequest->getRawVal( 'minervanightmode' );
676
677        // get skin config of night mode to check what is execluded
678        $nightModeConfig = $this->getConfig()->get( 'MinervaNightModeOptions' );
679        $featuresHelper = new FeaturesHelper();
680        $shouldDisableNightMode = $featuresHelper->shouldDisableNightMode( $nightModeConfig,
681            $webRequest,
682            $this->getContext()->getTitle()
683        );
684
685        if (
686            $this->skinOptions->get( SkinOptions::NIGHT_MODE ) || $forceNightMode !== null
687        ) {
688            $user = $this->getUser();
689            $value = $this->userOptionsManager->getOption( $user, 'minerva-theme' );
690
691            // if forcing a (valid) setting via query params, take priority over the user option
692            if ( $forceNightMode !== null && in_array( $forceNightMode, [ '1', '0', '2', 'day', 'night', 'os' ] ) ) {
693                $value = self::resolveNightModeQueryValue( $forceNightMode );
694            }
695
696            // For T356653 add a class to the page to allow the client to detect we've
697            // intentionally disabled night mode.
698            if ( $shouldDisableNightMode ) {
699                $attributes[ 'class' ] .= ' skin-night-mode-page-disabled';
700                return $attributes;
701            }
702
703            $attributes[ 'class' ] .= " skin-theme-clientpref-$value";
704        }
705
706        return $attributes;
707    }
708
709    /**
710     * Whether the output page contains category links and the category feature is enabled.
711     * @return bool
712     */
713    private function hasCategoryLinks(): bool {
714        if ( !$this->skinOptions->get( SkinOptions::CATEGORIES ) ) {
715            return false;
716        }
717        $categoryLinks = $this->getOutput()->getCategoryLinks();
718
719        if ( !count( $categoryLinks ) ) {
720            return false;
721        }
722        return !empty( $categoryLinks['normal'] ) || !empty( $categoryLinks['hidden'] );
723    }
724
725    /**
726     * Get a history link which describes author and relative time of last edit
727     * @param Title $title The Title object of the page being viewed
728     * @param string $timestamp
729     * @return array
730     */
731    protected function getRelativeHistoryLink( Title $title, string $timestamp ): array {
732        $user = $this->getUser();
733        $userDate = $this->getLanguage()->userDate( $timestamp, $user );
734        $text = $this->msg(
735            'minerva-last-modified-date', $userDate,
736            $this->getLanguage()->userTime( $timestamp, $user )
737        )->parse();
738        return [
739            // Use $edit['timestamp'] (Unix format) instead of $timestamp (MW format)
740            'data-timestamp' => wfTimestamp( TS_UNIX, $timestamp ),
741            'href' => $this->getHistoryUrl( $title ),
742            'text' => $text,
743        ] + $this->getRevisionEditorData( $title );
744    }
745
746    /**
747     * Get a history link which makes no reference to user or last edited time
748     * @param Title $title The Title object of the page being viewed
749     * @return array
750     */
751    protected function getGenericHistoryLink( Title $title ): array {
752        $text = $this->msg( 'mobile-frontend-history' )->plain();
753        return [
754            'href' => $this->getHistoryUrl( $title ),
755            'text' => $text,
756        ];
757    }
758
759    /**
760     * Checks if the Special:History page is being used.
761     * @param Title $title The Title object of the page being viewed
762     * @return bool
763     */
764    private function shouldUseSpecialHistory( Title $title ): bool {
765        return ExtensionRegistry::getInstance()->isLoaded( 'MobileFrontend' ) &&
766            SpecialMobileHistory::shouldUseSpecialHistory( $title, $this->getUser() );
767    }
768
769    /**
770     * Get the URL for the history page for the given title using Special:History
771     * when available.
772     * @param Title $title The Title object of the page being viewed
773     * @return string
774     */
775    protected function getHistoryUrl( Title $title ): string {
776        return $this->shouldUseSpecialHistory( $title ) ?
777            SpecialPage::getTitleFor( 'History', $title )->getLocalURL() :
778            $title->getLocalURL( [ 'action' => 'history' ] );
779    }
780
781    /**
782     * Prepare the content for the 'last edited' message, e.g. 'Last edited on 30 August
783     * 2013, at 23:31'. This message is different for the main page since main page
784     * content is typically transcluded rather than edited directly.
785     *
786     * The relative time is only rendered on the latest revision.
787     * For older revisions the last modified information will not render with a relative time
788     * nor will it show the name of the editor.
789     * @param Title $title The Title object of the page being viewed
790     * @return array|null
791     */
792    protected function getHistoryLink( Title $title ): ?array {
793        if ( !$title->exists() ||
794            $this->getContext()->getActionName() !== 'view'
795        ) {
796            return null;
797        }
798        // Do not show the last modified bar on diff pages [T350515]
799        $request = $this->getRequest();
800        if ( $request->getCheck( 'diff' ) ) {
801            return null;
802        }
803
804        $out = $this->getOutput();
805
806        if ( !$out->getRevisionId() || !$out->isRevisionCurrent() || $title->isMainPage() ) {
807            $historyLink = $this->getGenericHistoryLink( $title );
808        } else {
809            // Get rev_timestamp of current revision (preloaded by MediaWiki core)
810            $timestamp = $out->getRevisionTimestamp();
811            if ( !$timestamp ) {
812                # No cached timestamp, load it from the database
813                $timestamp = $this->revisionLookup->getTimestampFromId( $out->getRevisionId() );
814            }
815            $historyLink = $this->getRelativeHistoryLink( $title, $timestamp );
816        }
817
818        return $historyLink + [
819            'historyIcon' => [
820                'icon' => 'modified-history',
821                'size' => 'medium'
822            ],
823            'arrowIcon' => [
824                'icon' => 'expand',
825                'size' => 'small'
826            ]
827        ];
828    }
829
830    /**
831     * Returns data attributes representing the editor for the current revision.
832     * @param LinkTarget $title The Title object of the page being viewed
833     * @return array representing user with name and gender fields. Empty if the editor no longer
834     *   exists in the database or is hidden from public view.
835     */
836    private function getRevisionEditorData( LinkTarget $title ): array {
837        $rev = $this->revisionLookup->getRevisionByTitle( $title );
838        $result = [];
839        if ( $rev ) {
840            $revUser = $rev->getUser();
841            // Note the user will only be returned if that information is public
842            if ( $revUser ) {
843                $editorName = $revUser->getName();
844                $editorGender = $this->genderCache->getGenderOf( $revUser, __METHOD__ );
845                $result += [
846                    'data-user-name' => $editorName,
847                    'data-user-gender' => $editorGender,
848                ];
849            }
850        }
851        return $result;
852    }
853
854    /**
855     * Returns the HTML representing the tagline
856     * @return string HTML for tagline
857     */
858    protected function getTaglineHtml(): string {
859        $tagline = '';
860
861        $pageUser = $this->skinUserPageHelper->getPageUser();
862        if ( $pageUser ) {
863            $fromDate = $pageUser->getRegistration();
864
865            if ( $this->skinUserPageHelper->isUserPageAccessibleToCurrentUser() && is_string( $fromDate ) ) {
866                $fromDateTs = wfTimestamp( TS_UNIX, $fromDate );
867
868                // This is shown when js is disabled. js enhancement made due to caching
869                $tagline = $this->msg( 'mobile-frontend-user-page-member-since',
870                        $this->getLanguage()->userDate( new MWTimestamp( $fromDateTs ), $this->getUser() ),
871                        $pageUser )->text();
872
873                // Define html attributes for usage with js enhancement (unix timestamp, gender)
874                $attrs = [ 'id' => 'tagline-userpage',
875                    'data-userpage-registration-date' => $fromDateTs,
876                    'data-userpage-gender' => $this->genderCache->getGenderOf( $pageUser, __METHOD__ ) ];
877            }
878        } else {
879            if ( $this->getTitle() ) {
880                $out = $this->getOutput();
881                $tagline = $out->getProperty( 'wgMFDescription' );
882            }
883        }
884
885        $attrs[ 'class' ] = 'tagline';
886        return Html::element( 'div', $attrs, $tagline );
887    }
888
889    /**
890     * Returns the HTML representing the heading.
891     *
892     * @param string $heading The heading suggested by core.
893     * @return string HTML for header
894     */
895    private function getUserPageHeadingHtml( string $heading ): string {
896        // The heading is just the username without namespace
897        return Html::element( 'h1',
898            // These IDs and classes should match Skin::getTemplateData
899            [
900                'id' => 'firstHeading',
901                'class' => 'firstHeading mw-first-heading mw-minerva-user-heading',
902            ],
903            $this->skinUserPageHelper->getPageUser()->getName()
904        );
905    }
906
907    /**
908     * Load internal banner content to show in pre content in template
909     * Beware of HTML caching when using this function.
910     * Content set as "internalbanner"
911     * @param string $siteNotice HTML fragment
912     * @return array
913     */
914    protected function prepareBanners( string $siteNotice ): array {
915        $banners = [];
916        if ( $siteNotice && $this->getConfig()->get( 'MinervaEnableSiteNotice' ) ) {
917            $banners[] = $siteNotice;
918        } else {
919            $banners[] = '<div id="siteNotice"></div>';
920        }
921        return $banners;
922    }
923
924    /**
925     * Returns an array with details for a language button.
926     * @return array
927     */
928    protected function getLanguageButton(): array {
929        return [
930            'array-attributes' => [
931                [
932                    'key' => 'href',
933                    'value' => '#p-lang'
934                ]
935            ],
936            'tag-name' => 'a',
937            'classes' => 'language-selector button',
938            'label' => $this->msg( 'mobile-frontend-language-article-heading' )->text()
939        ];
940    }
941
942    /**
943     * Returns an array with details for a talk button.
944     * @param Title $talkTitle Title object of the talk page
945     * @param string $label Button label
946     * @return array
947     */
948    protected function getTalkButton( Title $talkTitle, string $label ): array {
949        return [
950            'array-attributes' => [
951                [
952                    'key' => 'href',
953                    'value' => $talkTitle->getLinkURL(),
954                ],
955                [
956                    'key' => 'data-title',
957                    'value' => $talkTitle->getFullText(),
958                ]
959            ],
960            'tag-name' => 'a',
961            'classes' => 'talk button',
962            'label' => $label,
963        ];
964    }
965
966    /**
967     * Returns an array of links for page secondary actions
968     * @param array $contentNavigationUrls
969     * @return array|null
970     */
971    protected function getSecondaryActions( array $contentNavigationUrls ): ?array {
972        if ( $this->isFallbackEditor() || !$this->hasSecondaryActions() ) {
973            return null;
974        }
975
976        $buttons = [];
977        // always add a button to link to the talk page
978        // it will link to the wikitext talk page
979        $title = $this->getTitle();
980        $subjectPage = Title::newFromLinkTarget( $this->namespaceInfo->getSubjectPage( $title ) );
981        $talkAtBottom = !$this->skinOptions->get( SkinOptions::TALK_AT_TOP ) ||
982            $subjectPage->isMainPage();
983        if ( !$this->skinUserPageHelper->isUserPage() &&
984            $this->permissions->isTalkAllowed() && $talkAtBottom &&
985            // When showing talk at the bottom we restrict this so it is not shown to anons
986            // https://phabricator.wikimedia.org/T54165
987            // This whole code block can be removed when SkinOptions::TALK_AT_TOP is always true
988            $this->getUser()->isRegistered()
989        ) {
990            $namespaces = $contentNavigationUrls['associated-pages'];
991            // FIXME [core]: This seems unnecessary..
992            $subjectId = $title->getNamespaceKey( '' );
993            $talkId = $subjectId === 'main' ? 'talk' : "{$subjectId}_talk";
994
995            if ( isset( $namespaces[$talkId] ) ) {
996                $talkButton = $namespaces[$talkId];
997                $talkTitle = Title::newFromLinkTarget( $this->namespaceInfo->getTalkPage( $title ) );
998
999                $buttons['talk'] = $this->getTalkButton( $talkTitle, $talkButton['text'] );
1000            }
1001        }
1002
1003        if (
1004            $this->languagesHelper->doesTitleHasLanguagesOrVariants( $this->getOutput(), $title ) &&
1005            $title->isMainPage()
1006        ) {
1007            $buttons['language'] = $this->getLanguageButton();
1008        }
1009
1010        return $buttons;
1011    }
1012
1013    /**
1014     * @inheritDoc
1015     * @return array
1016     */
1017    protected function getJsConfigVars(): array {
1018        return array_merge( parent::getJsConfigVars(), [
1019            'wgMinervaPermissions' => [
1020                'watchable' => $this->permissions->isAllowed( IMinervaPagePermissions::WATCHABLE ),
1021                'watch' => $this->permissions->isAllowed( IMinervaPagePermissions::WATCH ),
1022            ],
1023            'wgMinervaFeatures' => $this->skinOptions->getAll(),
1024            'wgMinervaDownloadNamespaces' => $this->getConfig()->get( 'MinervaDownloadNamespaces' ),
1025        ] );
1026    }
1027
1028    /**
1029     * Returns the javascript entry modules to load. Only modules that need to
1030     * be overriden or added conditionally should be placed here.
1031     * @return array
1032     */
1033    public function getDefaultModules(): array {
1034        $modules = parent::getDefaultModules();
1035
1036        // FIXME: T223204: Dequeue default content modules except for the history
1037        // action. Allow default content modules on history action in order to
1038        // enable toggling of the filters.
1039        // Long term this won't be necessary when T111565 is resolved and a
1040        // more general solution can be used.
1041        if ( $this->getContext()->getActionName() !== 'history' ) {
1042            // dequeue default content modules (toc, collapsible, etc.)
1043            $modules['content'] = array_diff( $modules['content'], [
1044                // T111565
1045                'jquery.makeCollapsible',
1046                // Minerva provides its own implementation. Loading this will break display.
1047                'mediawiki.toc'
1048            ] );
1049            // dequeue styles associated with `content` key.
1050            $modules['styles']['content'] = array_diff( $modules['styles']['content'], [
1051                // T111565
1052                'jquery.makeCollapsible.styles',
1053            ] );
1054        }
1055
1056        $modules['styles']['skin.page'] = $this->getPageSpecificStyles();
1057        $modules['styles']['skin.features'] = $this->getFeatureSpecificStyles();
1058
1059        return $modules;
1060    }
1061
1062    /**
1063     * Provide styles required to present the server rendered page in this skin. Additional styles
1064     * may be loaded dynamically by the client.
1065     *
1066     * Any styles returned by this method are loaded on the critical rendering path as linked
1067     * stylesheets. I.e., they are required to load on the client before first paint.
1068     *
1069     * @return array
1070     */
1071    protected function getPageSpecificStyles(): array {
1072        $styles = [];
1073        $title = $this->getTitle();
1074        $request = $this->getRequest();
1075        $requestAction = $this->getContext()->getActionName();
1076        $viewAction = $requestAction === 'view';
1077
1078        // Warning box styles are needed when reviewing old revisions
1079        // and inside the fallback editor styles to action=edit page.
1080        if (
1081            $title->getNamespace() !== NS_MAIN ||
1082            $request->getCheck( 'oldid' ) ||
1083            !$viewAction
1084        ) {
1085            $styles[] = 'skins.minerva.messageBox.styles';
1086        }
1087
1088        if ( $title->isMainPage() ) {
1089            $styles[] = 'skins.minerva.mainPage.styles';
1090        } elseif ( $this->skinUserPageHelper->isUserPage() ) {
1091            $styles[] = 'skins.minerva.userpage.styles';
1092        }
1093
1094        if ( $this->getUser()->isRegistered() ) {
1095            $styles[] = 'skins.minerva.loggedin.styles';
1096        }
1097
1098        // When any of these features are enabled in production
1099        // remove the if condition
1100        // and move the associated LESS file inside `skins.minerva.amc.styles`
1101        // into a more appropriate module.
1102        if (
1103            // T356117 - enable on all special pages - some special pages e.g. Special:Contribute have tabs.
1104            $title->isSpecialPage() ||
1105            ( $this->isHistoryPage() && !$this->shouldUseSpecialHistory( $title ) ) ||
1106            $this->skinOptions->get( SkinOptions::PERSONAL_MENU ) ||
1107            $this->skinOptions->get( SkinOptions::TALK_AT_TOP ) ||
1108            $this->skinOptions->get( SkinOptions::HISTORY_IN_PAGE_ACTIONS ) ||
1109            $this->skinOptions->get( SkinOptions::TOOLBAR_SUBMENU )
1110        ) {
1111            // SkinOptions::PERSONAL_MENU + SkinOptions::TOOLBAR_SUBMENU uses ToggleList
1112            // SkinOptions::TALK_AT_TOP uses tabs.less
1113            // SkinOptions::HISTORY_IN_PAGE_ACTIONS + SkinOptions::TOOLBAR_SUBMENU uses pageactions.less
1114            $styles[] = 'skins.minerva.amc.styles';
1115        }
1116
1117        return $styles;
1118    }
1119
1120    /**
1121     * Provide styles required to present the server rendered page with related features in this skin.
1122     * Additional styles may be loaded dynamically by the client.
1123     *
1124     *  Any styles returned by this method are loaded on the critical rendering path as linked
1125     *  stylesheets. I.e., they are required to load on the client before first paint.
1126     *
1127     * @return array
1128     */
1129    protected function getFeatureSpecificStyles(): array {
1130        $styles = [];
1131
1132        if ( $this->hasCategoryLinks() ) {
1133            $styles[] = 'skins.minerva.categories.styles';
1134        }
1135
1136        if ( $this->skinOptions->get( SkinOptions::PERSONAL_MENU ) ) {
1137            // If ever enabled as the default, please remove the duplicate icons
1138            // inside skins.minerva.mainMenu.icons. See comment for MAIN_MENU_EXPANDED
1139            $styles[] = 'skins.minerva.personalMenu.icons';
1140        }
1141
1142        if (
1143            $this->skinOptions->get( SkinOptions::MAIN_MENU_EXPANDED )
1144        ) {
1145            // If ever enabled as the default, please review skins.minerva.mainMenu.icons
1146            // and remove any unneeded icons
1147            $styles[] = 'skins.minerva.mainMenu.advanced.icons';
1148        }
1149
1150        if (
1151            $this->skinOptions->get( SkinOptions::PERSONAL_MENU ) ||
1152            $this->skinOptions->get( SkinOptions::TOOLBAR_SUBMENU )
1153        ) {
1154            // SkinOptions::PERSONAL_MENU requires the `userTalk` icon.
1155            // SkinOptions::TOOLBAR_SUBMENU requires the rest of the icons including `overflow`.
1156            // Note `skins.minerva.overflow.icons` is pulled down by skins.minerva.scripts but the menu can
1157            // work without JS.
1158            $styles[] = 'skins.minerva.overflow.icons';
1159        }
1160
1161        return $styles;
1162    }
1163}