Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
41.34% covered (danger)
41.34%
105 / 254
21.74% covered (danger)
21.74%
5 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
41.34% covered (danger)
41.34%
105 / 254
21.74% covered (danger)
21.74%
5 / 23
1405.43
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 isVectorSkin
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getActiveABTest
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 getVectorSearchResourceLoaderConfig
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 onSkinPageReadyConfig
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 updateActionsMenu
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 updateViewsMenuIcons
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 updateAssociatedPagesMenuIcons
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 appendClassToItem
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 updateUserLinksDropdownItems
93.94% covered (success)
93.94%
31 / 33
0.00% covered (danger)
0.00%
0 / 1
10.02
 fixEcho
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 updateUserLinksItems
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 makeMenuItemCollapsible
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 makeIcon
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 updateItemData
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
7
 updateMenuItemData
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 updateMenuItems
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 createMoreOverflowMenu
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 onSkinTemplateNavigation
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
7.23
 onGetPreferences
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
6
 onLocalUserCreated
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 isSkinVersionLegacy
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onGetBetaFeaturePreferences
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2
3namespace MediaWiki\Skins\Vector;
4
5use MediaWiki\Auth\Hook\LocalUserCreatedHook;
6use MediaWiki\Config\Config;
7use MediaWiki\Context\RequestContext;
8use MediaWiki\MediaWikiServices;
9use MediaWiki\Preferences\Hook\GetPreferencesHook;
10use MediaWiki\ResourceLoader as RL;
11use MediaWiki\Skins\Hook\SkinPageReadyConfigHook;
12use MediaWiki\Skins\Vector\Hooks\HookRunner;
13use MediaWiki\User\Options\UserOptionsManager;
14use MediaWiki\User\User;
15use RuntimeException;
16use SkinTemplate;
17
18/**
19 * Presentation hook handlers for Vector skin.
20 *
21 * Hook handler method names should be in the form of:
22 *    on<HookName>()
23 * @package Vector
24 * @internal
25 */
26class Hooks implements
27    GetPreferencesHook,
28    LocalUserCreatedHook,
29    SkinPageReadyConfigHook
30{
31    private Config $config;
32    private UserOptionsManager $userOptionsManager;
33
34    public function __construct(
35        Config $config,
36        UserOptionsManager $userOptionsManager
37    ) {
38        $this->config = $config;
39        $this->userOptionsManager = $userOptionsManager;
40    }
41
42    /**
43     * Checks if the current skin is a variant of Vector
44     *
45     * @param string $skinName
46     * @return bool
47     */
48    private static function isVectorSkin( string $skinName ): bool {
49        return (
50            $skinName === Constants::SKIN_NAME_LEGACY ||
51            $skinName === Constants::SKIN_NAME_MODERN
52        );
53    }
54
55    /**
56     * @param RL\Context $context
57     * @param Config $config
58     * @return array
59     */
60    public static function getActiveABTest(
61        RL\Context $context,
62        Config $config
63    ) {
64        $ab = $config->get(
65            Constants::CONFIG_WEB_AB_TEST_ENROLLMENT
66        );
67        if ( count( $ab ) === 0 ) {
68            // If array is empty then no experiment and need to validate.
69            return $ab;
70        }
71        if ( !array_key_exists( 'buckets', $ab ) ) {
72            throw new RuntimeException( 'Invalid VectorWebABTestEnrollment value: Must contain buckets key.' );
73        }
74        if ( !array_key_exists( 'unsampled', $ab['buckets'] ) ) {
75            throw new RuntimeException( 'Invalid VectorWebABTestEnrollment value: Must define an `unsampled` bucket.' );
76        } else {
77            // check bucket values.
78            foreach ( $ab['buckets'] as $bucketName => $bucketDefinition ) {
79                if ( !is_array( $bucketDefinition ) ) {
80                    throw new RuntimeException( 'Invalid VectorWebABTestEnrollment value: Buckets should be arrays' );
81                }
82                $samplingRate = $bucketDefinition['samplingRate'];
83                if ( is_string( $samplingRate ) ) {
84                    throw new RuntimeException(
85                        'Invalid VectorWebABTestEnrollment value: Sampling rate should be number between 0 and 1.'
86                    );
87                }
88            }
89        }
90
91        return $ab;
92    }
93
94    /**
95     * Generates config variables for skins.vector.search Resource Loader module (defined in
96     * skin.json).
97     *
98     * @param RL\Context $context
99     * @param Config $config
100     * @return array<string,mixed>
101     */
102    public static function getVectorSearchResourceLoaderConfig(
103        RL\Context $context,
104        Config $config
105    ): array {
106        $vectorSearchConfig = [
107            'highlightQuery' =>
108                VectorServices::getLanguageService()->canWordsBeSplitSafely( $context->getLanguage() )
109        ];
110
111        $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
112        $hookRunner->onVectorSearchResourceLoaderConfig( $vectorSearchConfig );
113
114        return array_merge( $config->get( 'VectorWvuiSearchOptions' ), $vectorSearchConfig );
115    }
116
117    /**
118     * SkinPageReadyConfig hook handler
119     *
120     * Replace searchModule provided by skin.
121     *
122     * @since 1.35
123     * @param RL\Context $context
124     * @param mixed[] &$config Associative array of configurable options
125     * @return void This hook must not abort, it must return no value
126     */
127    public function onSkinPageReadyConfig(
128        RL\Context $context,
129        array &$config
130    ): void {
131        // It's better to exit before any additional check
132        if ( !self::isVectorSkin( $context->getSkin() ) ) {
133            return;
134        }
135
136        // Tell the `mediawiki.page.ready` module not to wire up search.
137        // This allows us to use the new Vue implementation.
138        // Context has no knowledge of legacy / modern Vector
139        // and from its point of view they are the same thing.
140        // Please see the modules `skins.vector.js` and `skins.vector.legacy.js`
141        // for the wire up of search.
142        $config['search'] = false;
143    }
144
145    /**
146     * Moves watch item from actions to views menu.
147     *
148     * @internal used inside Hooks::onSkinTemplateNavigation
149     * @param array &$content_navigation
150     */
151    private static function updateActionsMenu( &$content_navigation ) {
152        $key = null;
153        if ( isset( $content_navigation['actions']['watch'] ) ) {
154            $key = 'watch';
155        }
156        if ( isset( $content_navigation['actions']['unwatch'] ) ) {
157            $key = 'unwatch';
158        }
159
160        // Promote watch link from actions to views and add an icon
161        // The second check to isset is pointless but shuts up phan.
162        if ( $key !== null && isset( $content_navigation['actions'][ $key ] ) ) {
163            $content_navigation['views'][$key] = $content_navigation['actions'][$key];
164            unset( $content_navigation['actions'][$key] );
165        }
166    }
167
168    /**
169     * Adds icons to items in the "views" menu.
170     *
171     * @internal used inside Hooks::onSkinTemplateNavigation
172     * @param array &$content_navigation
173     * @param bool $isLegacy is this the legacy Vector skin?
174     */
175    private static function updateViewsMenuIcons( &$content_navigation, $isLegacy ) {
176        // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
177        foreach ( $content_navigation['views'] as &$item ) {
178            $icon = $item['icon'] ?? null;
179            if ( $icon ) {
180                if ( $isLegacy ) {
181                    self::appendClassToItem(
182                        $item['class'],
183                        [ 'icon' ]
184                    );
185                } else {
186                    // Force the item as a button with hidden text.
187                    $item['button'] = true;
188                    $item['text-hidden'] = true;
189                    $item = self::updateMenuItemData( $item, false );
190                }
191            } elseif ( !$isLegacy ) {
192                // The vector-tab-noicon class is only used in Vector-22.
193                self::appendClassToItem(
194                    $item['class'],
195                    [ 'vector-tab-noicon' ]
196                );
197            }
198        }
199    }
200
201    /**
202     * All associated pages menu items do not have icons so are given the vector-tab-noicon class.
203     *
204     * @internal used inside Hooks::onSkinTemplateNavigation
205     * @param array &$content_navigation
206     */
207    private static function updateAssociatedPagesMenuIcons( &$content_navigation ) {
208        foreach ( $content_navigation['associated-pages'] as &$item ) {
209            self::appendClassToItem(
210                $item['class'],
211                [ 'vector-tab-noicon' ]
212            );
213        }
214    }
215
216    /**
217     * Adds class to a property
218     *
219     * @param array|string &$item to update
220     * @param array|string $classes to add to the item
221     */
222    private static function appendClassToItem( &$item, $classes ) {
223        $existingClasses = $item;
224
225        if ( is_array( $existingClasses ) ) {
226            // Treat as array
227            $newArrayClasses = is_array( $classes ) ? $classes : [ trim( $classes ) ];
228            $item = array_merge( $existingClasses, $newArrayClasses );
229        } elseif ( is_string( $existingClasses ) ) {
230            // Treat as string
231            $newStrClasses = is_string( $classes ) ? trim( $classes ) : implode( ' ', $classes );
232            $item .= ' ' . $newStrClasses;
233        } else {
234            // Treat as whatever $classes is
235            $item = $classes;
236        }
237
238        if ( is_string( $item ) ) {
239            $item = trim( $item );
240        }
241    }
242
243    /**
244     * Updates personal navigation menu (user links) dropdown for modern Vector:
245     *  - Adds icons
246     *  - Makes user page and watchlist collapsible
247     *
248     * @internal used inside ::updateUserLinksItems
249     * @param SkinTemplate $sk
250     * @param array &$content_navigation
251     * @suppress PhanTypeInvalidDimOffset
252     */
253    private static function updateUserLinksDropdownItems( $sk, &$content_navigation ) {
254        // For logged-in users in modern Vector, rearrange some links in the personal toolbar.
255        $user = $sk->getUser();
256        if ( $user->isRegistered() ) {
257            // Remove user page from personal menu dropdown for logged in use
258            $content_navigation['user-menu']['userpage']['collapsible'] = true;
259            // watchlist may be disabled if $wgGroupPermissions['*']['viewmywatchlist'] = false;
260            // See [[phab:T299671]]
261            if ( isset( $content_navigation['user-menu']['watchlist'] ) ) {
262                $content_navigation['user-menu']['watchlist']['collapsible'] = true;
263            }
264
265            // Anon editor links handled manually in new anon editor menu
266            $logoutMenu = [];
267            if ( isset( $content_navigation['user-menu']['logout'] ) ) {
268                $logoutMenu['logout'] = $content_navigation['user-menu']['logout'];
269                $logoutMenu['logout']['id'] = 'pt-logout';
270                unset( $content_navigation['user-menu']['logout'] );
271            }
272            $content_navigation['user-menu-logout'] = $logoutMenu;
273
274            self::updateMenuItems( $content_navigation, 'user-menu' );
275            self::updateMenuItems( $content_navigation, 'user-menu-logout' );
276        } else {
277            // Remove "Not logged in" from personal menu dropdown for anon users.
278            unset( $content_navigation['user-menu']['anonuserpage'] );
279
280            // Make login and create account collapsible
281            if ( isset( $content_navigation['user-menu']['login'] ) ) {
282                $content_navigation['user-menu']['login']['collapsible'] = true;
283            }
284            if ( isset( $content_navigation['user-menu']['login-private'] ) ) {
285                $content_navigation['user-menu']['login-private']['collapsible'] = true;
286            }
287            if ( isset( $content_navigation['user-menu']['createaccount'] ) ) {
288                $content_navigation['user-menu']['createaccount']['collapsible'] = true;
289            }
290            if ( isset( $content_navigation['user-menu']['sitesupport'] ) ) {
291                $content_navigation['user-menu']['sitesupport']['collapsible'] = true;
292            }
293
294            // Anon editor links handled manually in new anon editor menu
295            $anonEditorMenu = [];
296            if ( isset( $content_navigation['user-menu']['anoncontribs'] ) ) {
297                $anonEditorMenu['anoncontribs'] = $content_navigation['user-menu']['anoncontribs'];
298                $anonEditorMenu['anoncontribs']['id'] = 'pt-anoncontribs';
299                unset( $content_navigation['user-menu']['anoncontribs'] );
300            }
301            if ( isset( $content_navigation['user-menu']['anontalk'] ) ) {
302                $anonEditorMenu['anontalk'] = $content_navigation['user-menu']['anontalk'];
303                $anonEditorMenu['anontalk']['id'] = 'pt-anontalk';
304                unset( $content_navigation['user-menu']['anontalk'] );
305            }
306            $content_navigation['user-menu-anon-editor'] = $anonEditorMenu;
307
308            // Only show icons for anon menu items (login and create account).
309            self::updateMenuItems( $content_navigation, 'user-menu' );
310        }
311    }
312
313    /**
314     * Echo has styles that control icons rendering in places we don't want them.
315     * This code works around T343838.
316     *
317     * @param SkinTemplate $sk
318     * @param array &$content_navigation
319     */
320    private static function fixEcho( $sk, &$content_navigation ) {
321        if ( isset( $content_navigation['notifications'] ) ) {
322            foreach ( $content_navigation['notifications'] as &$item ) {
323                $icon = $item['icon'] ?? null;
324                if ( $icon ) {
325                    $linkClass = $item['link-class'] ?? [];
326                    $newLinkClass = [
327                        // Allows Echo to react to clicks
328                        'mw-echo-notification-badge-nojs'
329                    ];
330                    if ( in_array( 'mw-echo-unseen-notifications', $linkClass ) ) {
331                        $newLinkClass[] = 'mw-echo-unseen-notifications';
332                    }
333                    $item['link-class'] = $newLinkClass;
334                }
335            }
336        }
337    }
338
339    /**
340     * Updates personal navigation menu (user links) for modern Vector wherein user page, create account and login links
341     * are removed from the dropdown to be handled separately. In legacy Vector, the custom "user-page" bucket is
342     * removed to preserve existing behavior.
343     *
344     * @internal used inside Hooks::onSkinTemplateNavigation
345     * @param SkinTemplate $sk
346     * @param array &$content_navigation
347     */
348    private static function updateUserLinksItems( $sk, &$content_navigation ) {
349        $skinName = $sk->getSkinName();
350        if ( self::isSkinVersionLegacy( $skinName ) ) {
351            // Remove user page from personal toolbar since it will be inside the personal menu for logged-in
352            // users in legacy Vector.
353            unset( $content_navigation['user-page'] );
354        } else {
355            self::fixEcho( $sk, $content_navigation );
356            self::updateUserLinksDropdownItems( $sk, $content_navigation );
357        }
358    }
359
360    /**
361     * Modifies list item to make it collapsible.
362     *
363     * @internal used in ::updateItemData and ::createMoreOverflowMenu
364     * @param array &$item
365     * @param string $prefix defaults to user-links-
366     */
367    private static function makeMenuItemCollapsible( array &$item, string $prefix = 'user-links-' ) {
368        $collapseMenuItemClass = $prefix . 'collapsible-item';
369        self::appendClassToItem( $item[ 'class' ], $collapseMenuItemClass );
370    }
371
372    /**
373     * Make an icon
374     *
375     * @internal for use inside Vector skin.
376     * @param string $name
377     * @return string of HTML
378     */
379    private static function makeIcon( $name ) {
380        // Html::makeLink will pass this through rawElement
381        return '<span class="vector-icon mw-ui-icon-' . $name . ' mw-ui-icon-wikimedia-' . $name . '"></span>';
382    }
383
384    /**
385     * Update template data to include classes and html that handle buttons, icons, and collapsible items.
386     *
387     * @internal used in ::updateMenuItemData
388     * @param array $item data to update
389     * @param string $buttonClassProp property to append button classes
390     * @param string $iconHtmlProp property to set icon HTML
391     * @param bool $unsetIcon should the icon field be unset?
392     * @return array $item Updated data
393     */
394    private static function updateItemData(
395        $item, $buttonClassProp, $iconHtmlProp, $unsetIcon = true
396    ) {
397        $hasButton = $item['button'] ?? false;
398        $hideText = $item['text-hidden'] ?? false;
399        $isCollapsible = $item['collapsible'] ?? false;
400        $icon = $item['icon'] ?? '';
401        if ( $unsetIcon ) {
402            unset( $item['icon'] );
403        }
404        unset( $item['button'] );
405        unset( $item['text-hidden'] );
406        unset( $item['collapsible'] );
407
408        if ( $isCollapsible ) {
409            self::makeMenuItemCollapsible( $item );
410        }
411        if ( $hasButton ) {
412            // Hardcoded button classes, this should be fixed by replacing Hooks.php with VectorComponentButton.php
413            self::appendClassToItem( $item[ $buttonClassProp ], [
414                'cdx-button',
415                'cdx-button--fake-button',
416                'cdx-button--fake-button--enabled',
417                'cdx-button--weight-quiet'
418            ] );
419        }
420        if ( $icon ) {
421            if ( $hideText && $hasButton ) {
422                self::appendClassToItem( $item[ $buttonClassProp ], [ 'cdx-button--icon-only' ] );
423            }
424
425            $item[ $iconHtmlProp ] = self::makeIcon( $icon );
426        }
427        return $item;
428    }
429
430    /**
431     * Updates template data for Vector menu items.
432     *
433     * @internal used inside Hooks::updateMenuItems ::updateViewsMenuIcons and ::updateUserLinksDropdownItems
434     * @param array $item menu item data to update
435     * @param bool $unsetIcon should the icon field be unset?
436     * @return array $item Updated menu item data
437     */
438    private static function updateMenuItemData( $item, $unsetIcon = true ) {
439        $buttonClassProp = 'link-class';
440        $iconHtmlProp = 'link-html';
441        return self::updateItemData( $item, $buttonClassProp, $iconHtmlProp, $unsetIcon );
442    }
443
444    /**
445     * Updates user interface preferences for modern Vector to upgrade icon/button menu items.
446     *
447     * @param array &$content_navigation
448     * @param string $menu identifier
449     */
450    private static function updateMenuItems( &$content_navigation, $menu ) {
451        foreach ( $content_navigation[$menu] as &$item ) {
452            $item = self::updateMenuItemData( $item );
453        }
454    }
455
456    /**
457     * Vector 2022 only:
458     * Creates an additional menu that will be injected inside the more (cactions)
459     * dropdown menu. This menu is a clone of `views` and this menu will only be
460     * shown at low resolutions (when the `views` menu is hidden).
461     *
462     * An additional menu is used instead of adding to the existing cactions menu
463     * so that the emptyPortlet logic for that menu is preserved and the cactions menu
464     * is not shown at large resolutions when empty (e.g. all items including collapsed
465     * items are hidden).
466     *
467     * @param array &$content_navigation
468     */
469    private static function createMoreOverflowMenu( &$content_navigation ) {
470        $clonedViews = [];
471        foreach ( $content_navigation['views'] ?? [] as $key => $item ) {
472            $newItem = $item;
473            self::makeMenuItemCollapsible(
474                $newItem,
475                'vector-more-'
476            );
477            $clonedViews['more-' . $key] = $newItem;
478        }
479        // Inject collapsible menu items ahead of existing actions.
480        $content_navigation['views-overflow'] = $clonedViews;
481    }
482
483    /**
484     * Upgrades Vector's watch action to a watchstar.
485     * This is invoked inside SkinVector, not via skin registration, as skin hooks
486     * are not guaranteed to run last.
487     * This can possibly be revised based on the outcome of T287622.
488     *
489     * @see https://www.mediawiki.org/wiki/Manual:Hooks/SkinTemplateNavigation
490     * @param SkinTemplate $sk
491     * @param array &$content_navigation
492     */
493    public static function onSkinTemplateNavigation( $sk, &$content_navigation ) {
494        $skinName = $sk->getSkinName();
495        // These changes should only happen in Vector.
496        if ( !$skinName || !self::isVectorSkin( $skinName ) ) {
497            return;
498        }
499
500        $title = $sk->getRelevantTitle();
501        if (
502            $sk->getConfig()->get( 'VectorUseIconWatch' ) &&
503            $title && $title->canExist()
504        ) {
505            self::updateActionsMenu( $content_navigation );
506        }
507
508        self::updateUserLinksItems( $sk, $content_navigation );
509        if ( $skinName === Constants::SKIN_NAME_MODERN ) {
510            self::createMoreOverflowMenu( $content_navigation );
511        }
512
513        // The updating of the views menu happens /after/ the overflow menu has been created
514        // this avoids icons showing in the more overflow menu.
515        self::updateViewsMenuIcons( $content_navigation, self::isSkinVersionLegacy( $skinName ) );
516        self::updateAssociatedPagesMenuIcons( $content_navigation );
517    }
518
519    /**
520     * Adds Vector specific user preferences that can only be accessed via API.
521     *
522     * @param User $user User whose preferences are being modified.
523     * @param array[] &$prefs Preferences description array, to be fed to a HTMLForm object.
524     */
525    public function onGetPreferences( $user, &$prefs ): void {
526        $services = MediaWikiServices::getInstance();
527        $featureManagerFactory = $services->getService( 'Vector.FeatureManagerFactory' );
528        $featureManager = $featureManagerFactory->createFeatureManager( RequestContext::getMain() );
529        $isNightModeEnabled = $featureManager->isFeatureEnabled( Constants::FEATURE_NIGHT_MODE );
530
531        $vectorPrefs = [
532            Constants::PREF_KEY_LIMITED_WIDTH => [
533                'type' => 'toggle',
534                'label-message' => 'vector-prefs-limited-width',
535                'section' => 'rendering/skin/skin-prefs',
536                'help-message' => 'vector-prefs-limited-width-help',
537                'hide-if' => [ '!==', 'skin', Constants::SKIN_NAME_MODERN ],
538            ],
539            Constants::PREF_KEY_FONT_SIZE => [
540                'type' => 'select',
541                'label-message' => 'vector-feature-custom-font-size-name',
542                'section' => 'rendering/skin/skin-prefs',
543                'options-messages' => [
544                    'vector-feature-custom-font-size-0-label' => '0',
545                    'vector-feature-custom-font-size-1-label' => '1',
546                    'vector-feature-custom-font-size-2-label' => '2',
547                ],
548                'hide-if' => [ '!==', 'skin', Constants::SKIN_NAME_MODERN ],
549            ],
550            Constants::PREF_KEY_PAGE_TOOLS_PINNED => [
551                'type' => 'api'
552            ],
553            Constants::PREF_KEY_MAIN_MENU_PINNED => [
554                'type' => 'api'
555            ],
556            Constants::PREF_KEY_TOC_PINNED => [
557                'type' => 'api'
558            ],
559            Constants::PREF_KEY_APPEARANCE_PINNED => [
560                'type' => 'api'
561            ],
562            Constants::PREF_KEY_NIGHT_MODE => [
563                'type' => $isNightModeEnabled ? 'select' : 'api',
564                'label-message' => 'skin-theme-name',
565                'help-message' => 'skin-theme-description',
566                'section' => 'rendering/skin/skin-prefs',
567                'options-messages' => [
568                    'skin-theme-day-label' => 'day',
569                    'skin-theme-night-label' => 'night',
570                    'skin-theme-os-label' => 'os',
571                ],
572                'hide-if' => [ '!==', 'skin', Constants::SKIN_NAME_MODERN ],
573            ],
574        ];
575        $prefs += $vectorPrefs;
576    }
577
578    /**
579     * Called one time when initializing a users preferences for a newly created account.
580     *
581     * @param User $user Newly created user object.
582     * @param bool $isAutoCreated
583     */
584    public function onLocalUserCreated( $user, $isAutoCreated ) {
585        $default = $this->config->get( Constants::CONFIG_KEY_DEFAULT_SKIN_VERSION_FOR_NEW_ACCOUNTS );
586        if ( $default ) {
587            $this->userOptionsManager->setOption(
588                $user,
589                Constants::PREF_KEY_SKIN,
590                $default === Constants::SKIN_VERSION_LEGACY ?
591                    Constants::SKIN_NAME_LEGACY : Constants::SKIN_NAME_MODERN
592            );
593        }
594    }
595
596    /**
597     * Gets whether the current skin version is the legacy version.
598     *
599     * @param string $skinName hint that can be used to detect modern vector.
600     * @return bool
601     */
602    private static function isSkinVersionLegacy( $skinName ): bool {
603        return $skinName === Constants::SKIN_NAME_LEGACY;
604    }
605
606    /**
607     * Register Vector 2022 beta feature to the beta features list
608     *
609     * @param User $user User the preferences are for
610     * @param array &$betaFeatures
611     */
612    public function onGetBetaFeaturePreferences( User $user, array &$betaFeatures ) {
613        $skinName = RequestContext::getMain()->getSkinName();
614        // Only Vector 2022 is supported for beta features
615        if ( $skinName !== Constants::SKIN_NAME_MODERN ) {
616            return;
617        }
618        // Only add Vector 2022 beta feature if there is at least one beta feature present in config
619        $configHasBeta = false;
620        foreach ( Constants::VECTOR_BETA_FEATURES as $featureName ) {
621            if ( $this->config->has( $featureName ) && $this->config->get( $featureName )[ 'beta' ] === true ) {
622                $configHasBeta = true;
623                break;
624            }
625        }
626        if ( !$configHasBeta ) {
627            return;
628        }
629        $skinsAssetsPath = $this->config->get( 'StylePath' );
630        $imagesDir = "$skinsAssetsPath/Vector/resources/images";
631        $betaFeatures[ Constants::VECTOR_2022_BETA_KEY ] = [
632            'label-message' => 'vector-2022-beta-preview-label',
633            'desc-message' => 'vector-2022-beta-preview-description',
634            'screenshot' => [
635                // follow up work to add images is required in T349321
636                'ltr' => "$imagesDir/vector-2022-beta-preview-ltr.svg",
637                'rtl' => "$imagesDir/vector-2022-beta-preview-rtl.svg",
638            ],
639            'info-link' => 'https://www.mediawiki.org/wiki/Special:MyLanguage/Reading/Web/Accessibility_for_reading',
640            'discussion-link' => 'https://www.mediawiki.org/wiki/Talk:Reading/Web/Accessibility_for_reading',
641        ];
642    }
643}