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