Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
37.08% covered (danger)
37.08%
462 / 1246
6.45% covered (danger)
6.45%
2 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
DefaultPreferencesFactory
37.08% covered (danger)
37.08%
462 / 1246
6.45% covered (danger)
6.45%
2 / 31
16071.59
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
2
 getSaveBlacklist
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getFormDescriptor
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 simplifyFormDescriptor
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
182
 loadPreferenceValues
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
90
 getPreferenceForField
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getOptionFromUser
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
342
 profilePreferences
78.22% covered (warning)
78.22%
298 / 381
0.00% covered (danger)
0.00%
0 / 1
82.04
 skinPreferences
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
110
 filesPreferences
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 datetimePreferences
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
12
 renderingPreferences
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
3
 editingPreferences
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
20
 rcPreferences
0.00% covered (danger)
0.00%
0 / 83
0.00% covered (danger)
0.00%
0 / 1
30
 watchlistPreferences
0.00% covered (danger)
0.00%
0 / 135
0.00% covered (danger)
0.00%
0 / 1
90
 searchPreferences
65.45% covered (warning)
65.45%
36 / 55
0.00% covered (danger)
0.00%
0 / 1
6.03
 sortSkinNames
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
10.04
 getValidSkinNames
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 generateSkinOptions
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
72
 getDateOptions
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 getImageSizes
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getThumbSizes
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 validateSignature
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
182
 cleanSignature
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getForm
88.46% covered (warning)
88.46%
23 / 26
0.00% covered (danger)
0.00%
0 / 1
5.04
 saveFormData
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
12.04
 applyFilters
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 submitForm
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getResetKinds
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
756
 listResetKinds
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getOptionNamesForReset
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
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\Preferences;
22
23use IDBAccessObject;
24use ILanguageConverter;
25use Language;
26use LanguageCode;
27use LanguageConverter;
28use MediaWiki\Auth\AuthManager;
29use MediaWiki\Auth\PasswordAuthenticationRequest;
30use MediaWiki\Config\ServiceOptions;
31use MediaWiki\Context\IContextSource;
32use MediaWiki\HookContainer\HookContainer;
33use MediaWiki\HookContainer\HookRunner;
34use MediaWiki\Html\Html;
35use MediaWiki\HTMLForm\Field\HTMLCheckMatrix;
36use MediaWiki\HTMLForm\Field\HTMLInfoField;
37use MediaWiki\HTMLForm\Field\HTMLMultiSelectField;
38use MediaWiki\HTMLForm\HTMLForm;
39use MediaWiki\HTMLForm\HTMLFormField;
40use MediaWiki\HTMLForm\HTMLNestedFilterable;
41use MediaWiki\Languages\LanguageConverterFactory;
42use MediaWiki\Languages\LanguageNameUtils;
43use MediaWiki\Linker\LinkRenderer;
44use MediaWiki\MainConfigNames;
45use MediaWiki\MediaWikiServices;
46use MediaWiki\Message\Message;
47use MediaWiki\Output\OutputPage;
48use MediaWiki\Parser\Parser;
49use MediaWiki\Permissions\PermissionManager;
50use MediaWiki\SpecialPage\SpecialPage;
51use MediaWiki\Specials\SpecialWatchlist;
52use MediaWiki\Status\Status;
53use MediaWiki\Title\NamespaceInfo;
54use MediaWiki\Title\Title;
55use MediaWiki\User\Options\UserOptionsLookup;
56use MediaWiki\User\Options\UserOptionsManager;
57use MediaWiki\User\User;
58use MediaWiki\User\UserGroupManager;
59use MediaWiki\User\UserGroupMembership;
60use MediaWiki\User\UserTimeCorrection;
61use MediaWiki\Xml\Xml;
62use MessageLocalizer;
63use OOUI\ButtonWidget;
64use OOUI\FieldLayout;
65use OOUI\HtmlSnippet;
66use OOUI\LabelWidget;
67use ParserFactory;
68use ParserOptions;
69use PreferencesFormOOUI;
70use Psr\Log\LoggerAwareTrait;
71use Psr\Log\NullLogger;
72use SkinFactory;
73use UnexpectedValueException;
74
75/**
76 * This is the default implementation of PreferencesFactory.
77 */
78class DefaultPreferencesFactory implements PreferencesFactory {
79    use LoggerAwareTrait;
80
81    /** @var ServiceOptions */
82    protected $options;
83
84    /** @var Language The wiki's content language. */
85    protected $contLang;
86
87    /** @var LanguageNameUtils */
88    protected $languageNameUtils;
89
90    /** @var AuthManager */
91    protected $authManager;
92
93    /** @var LinkRenderer */
94    protected $linkRenderer;
95
96    /** @var NamespaceInfo */
97    protected $nsInfo;
98
99    /** @var PermissionManager */
100    protected $permissionManager;
101
102    /** @var ILanguageConverter */
103    private $languageConverter;
104
105    /** @var HookRunner */
106    private $hookRunner;
107
108    /** @var UserOptionsManager */
109    protected $userOptionsManager;
110
111    /** @var LanguageConverterFactory */
112    private $languageConverterFactory;
113
114    /** @var ParserFactory */
115    private $parserFactory;
116
117    /** @var SkinFactory */
118    private $skinFactory;
119
120    /** @var UserGroupManager */
121    private $userGroupManager;
122
123    /** @var SignatureValidatorFactory */
124    private $signatureValidatorFactory;
125
126    /**
127     * @internal For use by ServiceWiring
128     */
129    public const CONSTRUCTOR_OPTIONS = [
130        MainConfigNames::AllowRequiringEmailForResets,
131        MainConfigNames::AllowUserCss,
132        MainConfigNames::AllowUserCssPrefs,
133        MainConfigNames::AllowUserJs,
134        MainConfigNames::DefaultSkin,
135        MainConfigNames::EmailAuthentication,
136        MainConfigNames::EmailConfirmToEdit,
137        MainConfigNames::EnableEditRecovery,
138        MainConfigNames::EnableEmail,
139        MainConfigNames::EnableUserEmail,
140        MainConfigNames::EnableUserEmailMuteList,
141        MainConfigNames::EnotifMinorEdits,
142        MainConfigNames::EnotifRevealEditorAddress,
143        MainConfigNames::EnotifUserTalk,
144        MainConfigNames::EnotifWatchlist,
145        MainConfigNames::ForceHTTPS,
146        MainConfigNames::HiddenPrefs,
147        MainConfigNames::ImageLimits,
148        MainConfigNames::LanguageCode,
149        MainConfigNames::LocalTZoffset,
150        MainConfigNames::MaxSigChars,
151        MainConfigNames::RCMaxAge,
152        MainConfigNames::RCShowWatchingUsers,
153        MainConfigNames::RCWatchCategoryMembership,
154        MainConfigNames::SearchMatchRedirectPreference,
155        MainConfigNames::SecureLogin,
156        MainConfigNames::ScriptPath,
157        MainConfigNames::SignatureValidation,
158        MainConfigNames::SkinsPreferred,
159        MainConfigNames::ThumbLimits,
160        MainConfigNames::ThumbnailNamespaces,
161    ];
162
163    /**
164     * @param ServiceOptions $options
165     * @param Language $contLang
166     * @param AuthManager $authManager
167     * @param LinkRenderer $linkRenderer
168     * @param NamespaceInfo $nsInfo
169     * @param PermissionManager $permissionManager
170     * @param ILanguageConverter $languageConverter
171     * @param LanguageNameUtils $languageNameUtils
172     * @param HookContainer $hookContainer
173     * @param UserOptionsLookup $userOptionsLookup Should be an instance of UserOptionsManager
174     * @param LanguageConverterFactory|null $languageConverterFactory
175     * @param ParserFactory|null $parserFactory
176     * @param SkinFactory|null $skinFactory
177     * @param UserGroupManager|null $userGroupManager
178     * @param SignatureValidatorFactory|null $signatureValidatorFactory
179     */
180    public function __construct(
181        ServiceOptions $options,
182        Language $contLang,
183        AuthManager $authManager,
184        LinkRenderer $linkRenderer,
185        NamespaceInfo $nsInfo,
186        PermissionManager $permissionManager,
187        ILanguageConverter $languageConverter,
188        LanguageNameUtils $languageNameUtils,
189        HookContainer $hookContainer,
190        UserOptionsLookup $userOptionsLookup,
191        LanguageConverterFactory $languageConverterFactory = null,
192        ParserFactory $parserFactory = null,
193        SkinFactory $skinFactory = null,
194        UserGroupManager $userGroupManager = null,
195        SignatureValidatorFactory $signatureValidatorFactory = null
196    ) {
197        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
198
199        $this->options = $options;
200        $this->contLang = $contLang;
201        $this->authManager = $authManager;
202        $this->linkRenderer = $linkRenderer;
203        $this->nsInfo = $nsInfo;
204
205        // We don't use the PermissionManager anymore, but we need to be careful
206        // removing the parameter since this class is extended by GlobalPreferencesFactory
207        // in the GlobalPreferences extension, and that class uses it
208        $this->permissionManager = $permissionManager;
209
210        $this->logger = new NullLogger();
211        $this->languageConverter = $languageConverter;
212        $this->languageNameUtils = $languageNameUtils;
213        $this->hookRunner = new HookRunner( $hookContainer );
214
215        // Don't break GlobalPreferences, fall back to global state if missing services
216        // or if passed a UserOptionsLookup that isn't UserOptionsManager
217        $services = static function () {
218            // BC hack. Use a closure so this can be unit-tested.
219            return MediaWikiServices::getInstance();
220        };
221        $this->userOptionsManager = ( $userOptionsLookup instanceof UserOptionsManager )
222            ? $userOptionsLookup
223            : $services()->getUserOptionsManager();
224        $this->languageConverterFactory = $languageConverterFactory ?? $services()->getLanguageConverterFactory();
225
226        $this->parserFactory = $parserFactory ?? $services()->getParserFactory();
227        $this->skinFactory = $skinFactory ?? $services()->getSkinFactory();
228        $this->userGroupManager = $userGroupManager ?? $services()->getUserGroupManager();
229        $this->signatureValidatorFactory = $signatureValidatorFactory
230            ?? $services()->getSignatureValidatorFactory();
231    }
232
233    /**
234     * @inheritDoc
235     */
236    public function getSaveBlacklist() {
237        return [
238            'realname',
239            'emailaddress',
240        ];
241    }
242
243    /**
244     * @param User $user
245     * @param IContextSource $context
246     * @return array
247     */
248    public function getFormDescriptor( User $user, IContextSource $context ) {
249        $preferences = [];
250
251        OutputPage::setupOOUI(
252            strtolower( $context->getSkin()->getSkinName() ),
253            $context->getLanguage()->getDir()
254        );
255
256        $this->profilePreferences( $user, $context, $preferences );
257        $this->skinPreferences( $user, $context, $preferences );
258        $this->datetimePreferences( $user, $context, $preferences );
259        $this->filesPreferences( $context, $preferences );
260        $this->renderingPreferences( $user, $context, $preferences );
261        $this->editingPreferences( $user, $context, $preferences );
262        $this->rcPreferences( $user, $context, $preferences );
263        $this->watchlistPreferences( $user, $context, $preferences );
264        $this->searchPreferences( $context, $preferences );
265
266        $this->hookRunner->onGetPreferences( $user, $preferences );
267
268        $this->loadPreferenceValues( $user, $context, $preferences );
269        $this->logger->debug( "Created form descriptor for user '{$user->getName()}'" );
270        return $preferences;
271    }
272
273    /**
274     * Simplify form descriptor for validation or something similar.
275     *
276     * @param array $descriptor HTML form descriptor.
277     * @return array
278     */
279    public static function simplifyFormDescriptor( array $descriptor ) {
280        foreach ( $descriptor as $name => &$params ) {
281            // Info fields are useless and can use complicated closure to provide
282            // text, skip all of them.
283            if ( ( isset( $params['type'] ) && $params['type'] === 'info' ) ||
284                // Checking old alias for compatibility with unchanged extensions
285                ( isset( $params['class'] ) && $params['class'] === \HTMLInfoField::class ) ||
286                ( isset( $params['class'] ) && $params['class'] === HTMLInfoField::class )
287            ) {
288                unset( $descriptor[$name] );
289                continue;
290            }
291            // Message parsing is the heaviest load when constructing the field,
292            // but we just want to validate data.
293            foreach ( $params as $key => $value ) {
294                switch ( $key ) {
295                    // Special case, should be kept.
296                    case 'options-message':
297                        break;
298                    // Special case, should be transferred.
299                    case 'options-messages':
300                        unset( $params[$key] );
301                        $params['options'] = $value;
302                        break;
303                    default:
304                        if ( preg_match( '/-messages?$/', $key ) ) {
305                            // Unwanted.
306                            unset( $params[$key] );
307                        }
308                }
309            }
310        }
311        return $descriptor;
312    }
313
314    /**
315     * Loads existing values for a given array of preferences
316     * @param User $user
317     * @param IContextSource $context
318     * @param array &$defaultPreferences Array to load values for
319     * @return array|null
320     */
321    private function loadPreferenceValues( User $user, IContextSource $context, &$defaultPreferences ) {
322        // Remove preferences that wikis don't want to use
323        foreach ( $this->options->get( MainConfigNames::HiddenPrefs ) as $pref ) {
324            unset( $defaultPreferences[$pref] );
325        }
326
327        // For validation.
328        $simplified = self::simplifyFormDescriptor( $defaultPreferences );
329        $form = new HTMLForm( $simplified, $context );
330
331        $disable = !$user->isAllowed( 'editmyoptions' );
332
333        $defaultOptions = $this->userOptionsManager->getDefaultOptions( $user );
334        $userOptions = $this->userOptionsManager->getOptions( $user );
335        $this->applyFilters( $userOptions, $defaultPreferences, 'filterForForm' );
336        // Add in defaults from the user
337        foreach ( $simplified as $name => $_ ) {
338            $info = &$defaultPreferences[$name];
339            if ( $disable && !in_array( $name, $this->getSaveBlacklist() ) ) {
340                $info['disabled'] = 'disabled';
341            }
342            if ( isset( $info['default'] ) ) {
343                // Already set, no problem
344                continue;
345            }
346            $field = $form->getField( $name );
347            $globalDefault = $defaultOptions[$name] ?? null;
348            $prefFromUser = static::getPreferenceForField( $name, $field, $userOptions );
349
350            // If it validates, set it as the default
351            // FIXME: That's not how the validate() function works! Values of nested fields
352            // (e.g. CheckMatix) would be missing.
353            if ( $prefFromUser !== null && // Make sure we're not just pulling nothing
354                    $field->validate( $prefFromUser, $this->userOptionsManager->getOptions( $user ) ) === true ) {
355                $info['default'] = $prefFromUser;
356            } elseif ( $field->validate( $globalDefault, $this->userOptionsManager->getOptions( $user ) ) === true ) {
357                $info['default'] = $globalDefault;
358            } else {
359                $globalDefault = json_encode( $globalDefault );
360                throw new UnexpectedValueException(
361                    "Default '$globalDefault' is invalid for preference $name of user " . $user->getName()
362                );
363            }
364        }
365
366        return $defaultPreferences;
367    }
368
369    /**
370     * Get preference values for the 'default' param of html form descriptor, compatible
371     * with nested fields.
372     *
373     * @since 1.41
374     * @param string $name
375     * @param HTMLFormField $field
376     * @param array $userOptions
377     * @return array|string
378     */
379    public static function getPreferenceForField( $name, HTMLFormField $field, array $userOptions ) {
380        $val = $userOptions[$name] ?? null;
381
382        if ( $field instanceof HTMLNestedFilterable ) {
383            $val = [];
384            $prefix = $field->mParams['prefix'] ?? $name;
385            // Fetch all possible preference keys of the given field on this wiki.
386            $keys = array_keys( $field->filterDataForSubmit( [] ) );
387            foreach ( $keys as $key ) {
388                if ( $userOptions[$prefix . $key] ?? false ) {
389                    $val[] = $key;
390                }
391            }
392        }
393
394        return $val;
395    }
396
397    /**
398     * Pull option from a user account. Handles stuff like array-type preferences.
399     *
400     * @deprecated since 1.41; Use getPreferenceForField() instead.
401     * @param string $name
402     * @param array $info
403     * @param array $userOptions
404     * @return array|string
405     */
406    protected function getOptionFromUser( $name, $info, array $userOptions ) {
407        $val = $userOptions[$name] ?? null;
408
409        // Handling for multiselect preferences
410        if ( ( isset( $info['type'] ) && $info['type'] == 'multiselect' ) ||
411            // Checking old alias for compatibility with unchanged extensions
412            ( isset( $info['class'] ) && $info['class'] === \HTMLMultiSelectField::class ) ||
413            ( isset( $info['class'] ) && $info['class'] === HTMLMultiSelectField::class )
414        ) {
415            $options = HTMLFormField::flattenOptions( $info['options-messages'] ?? $info['options'] );
416            $prefix = $info['prefix'] ?? $name;
417            $val = [];
418
419            foreach ( $options as $value ) {
420                if ( $userOptions["$prefix$value"] ?? false ) {
421                    $val[] = $value;
422                }
423            }
424        }
425
426        // Handling for checkmatrix preferences
427        if ( ( isset( $info['type'] ) && $info['type'] == 'checkmatrix' ) ||
428            // Checking old alias for compatibility with unchanged extensions
429            ( isset( $info['class'] ) && $info['class'] === \HTMLCheckMatrix::class ) ||
430            ( isset( $info['class'] ) && $info['class'] === HTMLCheckMatrix::class )
431        ) {
432            $columns = HTMLFormField::flattenOptions( $info['columns'] );
433            $rows = HTMLFormField::flattenOptions( $info['rows'] );
434            $prefix = $info['prefix'] ?? $name;
435            $val = [];
436
437            foreach ( $columns as $column ) {
438                foreach ( $rows as $row ) {
439                    if ( $userOptions["$prefix$column-$row"] ?? false ) {
440                        $val[] = "$column-$row";
441                    }
442                }
443            }
444        }
445
446        return $val;
447    }
448
449    /**
450     * @todo Inject user Language instead of using context.
451     * @param User $user
452     * @param IContextSource $context
453     * @param array &$defaultPreferences
454     * @return void
455     */
456    protected function profilePreferences(
457        User $user, IContextSource $context, &$defaultPreferences
458    ) {
459        // retrieving user name for GENDER and misc.
460        $userName = $user->getName();
461
462        // Information panel
463        $defaultPreferences['username'] = [
464            'type' => 'info',
465            'label-message' => [ 'username', $userName ],
466            'default' => $userName,
467            'section' => 'personal/info',
468        ];
469
470        $lang = $context->getLanguage();
471
472        // Get groups to which the user belongs, Skip the default * group, seems useless here
473        $userEffectiveGroups = array_diff(
474            $this->userGroupManager->getUserEffectiveGroups( $user ),
475            [ '*' ]
476        );
477        $defaultPreferences['usergroups'] = [
478            'type' => 'info',
479            'label-message' => [ 'prefs-memberingroups',
480                Message::numParam( count( $userEffectiveGroups ) ), $userName ],
481            'default' => function () use ( $user, $userEffectiveGroups, $context, $lang, $userName ) {
482                $userGroupMemberships = $this->userGroupManager->getUserGroupMemberships( $user );
483                $userGroups = $userMembers = $userTempGroups = $userTempMembers = [];
484                foreach ( $userEffectiveGroups as $ueg ) {
485                    $groupStringOrObject = $userGroupMemberships[$ueg] ?? $ueg;
486
487                    $userG = UserGroupMembership::getLinkHTML( $groupStringOrObject, $context );
488                    $userM = UserGroupMembership::getLinkHTML( $groupStringOrObject, $context, $userName );
489
490                    // Store expiring groups separately, so we can place them before non-expiring
491                    // groups in the list. This is to avoid the ambiguity of something like
492                    // "administrator, bureaucrat (until X date)" -- users might wonder whether the
493                    // expiry date applies to both groups, or just the last one
494                    if ( $groupStringOrObject instanceof UserGroupMembership &&
495                        $groupStringOrObject->getExpiry()
496                    ) {
497                        $userTempGroups[] = $userG;
498                        $userTempMembers[] = $userM;
499                    } else {
500                        $userGroups[] = $userG;
501                        $userMembers[] = $userM;
502                    }
503                }
504                sort( $userGroups );
505                sort( $userMembers );
506                sort( $userTempGroups );
507                sort( $userTempMembers );
508                $userGroups = array_merge( $userTempGroups, $userGroups );
509                $userMembers = array_merge( $userTempMembers, $userMembers );
510                return $context->msg( 'prefs-memberingroups-type' )
511                    ->rawParams( $lang->commaList( $userGroups ), $lang->commaList( $userMembers ) )
512                    ->escaped();
513            },
514            'raw' => true,
515            'section' => 'personal/info',
516        ];
517
518        $contribTitle = SpecialPage::getTitleFor( "Contributions", $userName );
519        $formattedEditCount = $lang->formatNum( $user->getEditCount() );
520        $editCount = $this->linkRenderer->makeLink( $contribTitle, $formattedEditCount );
521
522        $defaultPreferences['editcount'] = [
523            'type' => 'info',
524            'raw' => true,
525            'label-message' => 'prefs-edits',
526            'default' => $editCount,
527            'section' => 'personal/info',
528        ];
529
530        if ( $user->getRegistration() ) {
531            $displayUser = $context->getUser();
532            $userRegistration = $user->getRegistration();
533            $defaultPreferences['registrationdate'] = [
534                'type' => 'info',
535                'label-message' => 'prefs-registration',
536                'default' => $context->msg(
537                    'prefs-registration-date-time',
538                    $lang->userTimeAndDate( $userRegistration, $displayUser ),
539                    $lang->userDate( $userRegistration, $displayUser ),
540                    $lang->userTime( $userRegistration, $displayUser )
541                )->text(),
542                'section' => 'personal/info',
543            ];
544        }
545
546        $canViewPrivateInfo = $user->isAllowed( 'viewmyprivateinfo' );
547        $canEditPrivateInfo = $user->isAllowed( 'editmyprivateinfo' );
548
549        // Actually changeable stuff
550        $defaultPreferences['realname'] = [
551            // (not really "private", but still shouldn't be edited without permission)
552            'type' => $canEditPrivateInfo && $this->authManager->allowsPropertyChange( 'realname' )
553                ? 'text' : 'info',
554            'default' => $user->getRealName(),
555            'section' => 'personal/info',
556            'label-message' => 'yourrealname',
557            'help-message' => 'prefs-help-realname',
558        ];
559
560        if ( $canEditPrivateInfo && $this->authManager->allowsAuthenticationDataChange(
561            new PasswordAuthenticationRequest(), false )->isGood()
562        ) {
563            $defaultPreferences['password'] = [
564                'type' => 'info',
565                'raw' => true,
566                'default' => (string)new ButtonWidget( [
567                    'href' => SpecialPage::getTitleFor( 'ChangePassword' )->getLinkURL( [
568                        'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText()
569                    ] ),
570                    'label' => $context->msg( 'prefs-resetpass' )->text(),
571                ] ),
572                'label-message' => 'yourpassword',
573                // email password reset feature only works for users that have an email set up
574                'help' => $this->options->get( MainConfigNames::AllowRequiringEmailForResets ) &&
575                        $user->getEmail()
576                    ? $context->msg( 'prefs-help-yourpassword',
577                        '[[#mw-prefsection-personal-email|{{int:prefs-email}}]]' )->parse()
578                    : '',
579                'section' => 'personal/info',
580            ];
581        }
582        // Only show prefershttps if secure login is turned on
583        if ( !$this->options->get( MainConfigNames::ForceHTTPS )
584            && $this->options->get( MainConfigNames::SecureLogin )
585        ) {
586            $defaultPreferences['prefershttps'] = [
587                'type' => 'toggle',
588                'label-message' => 'tog-prefershttps',
589                'help-message' => 'prefs-help-prefershttps',
590                'section' => 'personal/info'
591            ];
592        }
593
594        $defaultPreferences['downloaduserdata'] = [
595            'type' => 'info',
596            'raw' => true,
597            'label-message' => 'prefs-user-downloaddata-label',
598            'default' => Html::element(
599                'a',
600                [
601                    'href' => $this->options->get( MainConfigNames::ScriptPath ) .
602                        '/api.php?action=query&meta=userinfo&uiprop=*&formatversion=2',
603                ],
604                $context->msg( 'prefs-user-downloaddata-info' )->text()
605            ),
606            'help-message' => [ 'prefs-user-downloaddata-help-message', urlencode( $user->getTitleKey() ) ],
607            'section' => 'personal/info',
608        ];
609
610        $defaultPreferences['restoreprefs'] = [
611            'type' => 'info',
612            'raw' => true,
613            'label-message' => 'prefs-user-restoreprefs-label',
614            'default' => Html::element(
615                'a',
616                [
617                    'href' => SpecialPage::getTitleFor( 'Preferences' )
618                        ->getSubpage( 'reset' )->getLocalURL()
619                ],
620                $context->msg( 'prefs-user-restoreprefs-info' )->text()
621            ),
622            'section' => 'personal/info',
623        ];
624
625        $languages = $this->languageNameUtils->getLanguageNames(
626            LanguageNameUtils::AUTONYMS,
627            LanguageNameUtils::SUPPORTED
628        );
629        $languageCode = $this->options->get( MainConfigNames::LanguageCode );
630        if ( !array_key_exists( $languageCode, $languages ) ) {
631            $languages[$languageCode] = $languageCode;
632            // Sort the array again
633            ksort( $languages );
634        }
635
636        $options = [];
637        foreach ( $languages as $code => $name ) {
638            $display = LanguageCode::bcp47( $code ) . ' - ' . $name;
639            $options[$display] = $code;
640        }
641        $defaultPreferences['language'] = [
642            'type' => 'select',
643            'section' => 'personal/i18n',
644            'options' => $options,
645            'label-message' => 'yourlanguage',
646        ];
647
648        $neutralGenderMessage = $context->msg( 'gender-notknown' )->escaped() . (
649            !$context->msg( 'gender-unknown' )->isDisabled()
650                ? "<br>" . $context->msg( 'parentheses' )
651                    ->params( $context->msg( 'gender-unknown' )->plain() )
652                    ->escaped()
653                : ''
654        );
655
656        $defaultPreferences['gender'] = [
657            'type' => 'radio',
658            'section' => 'personal/i18n',
659            'options' => [
660                $neutralGenderMessage => 'unknown',
661                $context->msg( 'gender-female' )->escaped() => 'female',
662                $context->msg( 'gender-male' )->escaped() => 'male',
663            ],
664            'label-message' => 'yourgender',
665            'help-message' => 'prefs-help-gender',
666        ];
667
668        // see if there are multiple language variants to choose from
669        if ( !$this->languageConverterFactory->isConversionDisabled() ) {
670
671            foreach ( LanguageConverter::$languagesWithVariants as $langCode ) {
672                if ( $langCode == $this->contLang->getCode() ) {
673                    if ( !$this->languageConverter->hasVariants() ) {
674                        continue;
675                    }
676
677                    $variants = $this->languageConverter->getVariants();
678                    $variantArray = [];
679                    foreach ( $variants as $v ) {
680                        $v = str_replace( '_', '-', strtolower( $v ) );
681                        $variantArray[$v] = $lang->getVariantname( $v, false );
682                    }
683
684                    $options = [];
685                    foreach ( $variantArray as $code => $name ) {
686                        $display = LanguageCode::bcp47( $code ) . ' - ' . $name;
687                        $options[$display] = $code;
688                    }
689
690                    $defaultPreferences['variant'] = [
691                        'label-message' => 'yourvariant',
692                        'type' => 'select',
693                        'options' => $options,
694                        'section' => 'personal/i18n',
695                        'help-message' => 'prefs-help-variant',
696                    ];
697                } else {
698                    $defaultPreferences["variant-$langCode"] = [
699                        'type' => 'api',
700                    ];
701                }
702            }
703        }
704
705        // show a preview of the old signature first
706        $oldsigWikiText = $this->parserFactory->getInstance()->preSaveTransform(
707            '~~~',
708            $context->getTitle(),
709            $user,
710            ParserOptions::newFromContext( $context )
711        );
712        $oldsigHTML = Parser::stripOuterParagraph(
713            $context->getOutput()->parseAsContent( $oldsigWikiText )
714        );
715        $signatureFieldConfig = [];
716        // Validate existing signature and show a message about it
717        $signature = $this->userOptionsManager->getOption( $user, 'nickname' );
718        $useFancySig = $this->userOptionsManager->getBoolOption( $user, 'fancysig' );
719        if ( $useFancySig && $signature !== '' ) {
720            $parserOpts = ParserOptions::newFromContext( $context );
721            $validator = $this->signatureValidatorFactory
722                ->newSignatureValidator( $user, $context, $parserOpts );
723            $signatureErrors = $validator->validateSignature( $signature );
724            if ( $signatureErrors ) {
725                $sigValidation = $this->options->get( MainConfigNames::SignatureValidation );
726                $oldsigHTML .= '<p><strong>' .
727                    // Messages used here:
728                    // * prefs-signature-invalid-warning
729                    // * prefs-signature-invalid-new
730                    // * prefs-signature-invalid-disallow
731                    $context->msg( "prefs-signature-invalid-$sigValidation" )->parse() .
732                    '</strong></p>';
733
734                // On initial page load, show the warnings as well
735                // (when posting, you get normal validation errors instead)
736                foreach ( $signatureErrors as &$sigError ) {
737                    $sigError = new HtmlSnippet( $sigError );
738                }
739                if ( !$context->getRequest()->wasPosted() ) {
740                    $signatureFieldConfig = [
741                        'warnings' => $sigValidation !== 'disallow' ? $signatureErrors : null,
742                        'errors' => $sigValidation === 'disallow' ? $signatureErrors : null,
743                    ];
744                }
745            }
746        }
747        $defaultPreferences['oldsig'] = [
748            'type' => 'info',
749            // Normally HTMLFormFields do not display warnings, so we need to use 'rawrow'
750            // and provide the entire OOUI\FieldLayout here
751            'rawrow' => true,
752            'default' => new FieldLayout(
753                new LabelWidget( [
754                    'label' => new HtmlSnippet( $oldsigHTML ),
755                ] ),
756                [
757                    'align' => 'top',
758                    'label' => new HtmlSnippet( $context->msg( 'tog-oldsig' )->parse() )
759                ] + $signatureFieldConfig
760            ),
761            'section' => 'personal/signature',
762        ];
763        $defaultPreferences['nickname'] = [
764            'type' => $this->authManager->allowsPropertyChange( 'nickname' ) ? 'text' : 'info',
765            'maxlength' => $this->options->get( MainConfigNames::MaxSigChars ),
766            'label-message' => 'yournick',
767            'validation-callback' => function ( $signature, $alldata, HTMLForm $form ) {
768                return $this->validateSignature( $signature, $alldata, $form );
769            },
770            'section' => 'personal/signature',
771            'filter-callback' => function ( $signature, array $alldata, HTMLForm $form ) {
772                return $this->cleanSignature( $signature, $alldata, $form );
773            },
774        ];
775        $defaultPreferences['fancysig'] = [
776            'type' => 'toggle',
777            'label-message' => 'tog-fancysig',
778            // show general help about signature at the bottom of the section
779            'help-message' => 'prefs-help-signature',
780            'section' => 'personal/signature'
781        ];
782
783        // Email preferences
784        if ( $this->options->get( MainConfigNames::EnableEmail ) ) {
785            if ( $canViewPrivateInfo ) {
786                $helpMessages = [];
787                $helpMessages[] = $this->options->get( MainConfigNames::EmailConfirmToEdit )
788                        ? 'prefs-help-email-required'
789                        : 'prefs-help-email';
790
791                if ( $this->options->get( MainConfigNames::EnableUserEmail ) ) {
792                    // additional messages when users can send email to each other
793                    $helpMessages[] = 'prefs-help-email-others';
794                }
795
796                $emailAddress = $user->getEmail() ? htmlspecialchars( $user->getEmail() ) : '';
797                if ( $canEditPrivateInfo && $this->authManager->allowsPropertyChange( 'emailaddress' ) ) {
798                    $button = new ButtonWidget( [
799                        'href' => SpecialPage::getTitleFor( 'ChangeEmail' )->getLinkURL( [
800                            'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText()
801                        ] ),
802                        'label' =>
803                            $context->msg( $user->getEmail() ? 'prefs-changeemail' : 'prefs-setemail' )->text(),
804                    ] );
805
806                    $emailAddress .= $emailAddress == '' ? $button : ( '<br />' . $button );
807                }
808
809                $defaultPreferences['emailaddress'] = [
810                    'type' => 'info',
811                    'raw' => true,
812                    'default' => $emailAddress,
813                    'label-message' => 'youremail',
814                    'section' => 'personal/email',
815                    'help-messages' => $helpMessages,
816                    // 'cssclass' chosen below
817                ];
818            }
819
820            $disableEmailPrefs = false;
821
822            if ( $this->options->get( MainConfigNames::AllowRequiringEmailForResets ) ) {
823                $defaultPreferences['requireemail'] = [
824                    'type' => 'toggle',
825                    'label-message' => 'tog-requireemail',
826                    'help-message' => 'prefs-help-requireemail',
827                    'section' => 'personal/email',
828                    'disabled' => !$user->getEmail(),
829                ];
830            }
831
832            if ( $this->options->get( MainConfigNames::EmailAuthentication ) ) {
833                if ( $user->getEmail() ) {
834                    if ( $user->getEmailAuthenticationTimestamp() ) {
835                        // date and time are separate parameters to facilitate localisation.
836                        // $time is kept for backward compat reasons.
837                        // 'emailauthenticated' is also used in SpecialConfirmemail.php
838                        $displayUser = $context->getUser();
839                        $emailTimestamp = $user->getEmailAuthenticationTimestamp();
840                        $time = $lang->userTimeAndDate( $emailTimestamp, $displayUser );
841                        $d = $lang->userDate( $emailTimestamp, $displayUser );
842                        $t = $lang->userTime( $emailTimestamp, $displayUser );
843                        $emailauthenticated = $context->msg( 'emailauthenticated',
844                            $time, $d, $t )->parse() . '<br />';
845                        $emailauthenticationclass = 'mw-email-authenticated';
846                    } else {
847                        $disableEmailPrefs = true;
848                        $emailauthenticated = $context->msg( 'emailnotauthenticated' )->parse() . '<br />' .
849                            new ButtonWidget( [
850                                'href' => SpecialPage::getTitleFor( 'Confirmemail' )->getLinkURL(),
851                                'label' => $context->msg( 'emailconfirmlink' )->text(),
852                            ] );
853                        $emailauthenticationclass = "mw-email-not-authenticated";
854                    }
855                } else {
856                    $disableEmailPrefs = true;
857                    $emailauthenticated = $context->msg( 'noemailprefs' )->escaped();
858                    $emailauthenticationclass = 'mw-email-none';
859                }
860
861                if ( $canViewPrivateInfo ) {
862                    $defaultPreferences['emailauthentication'] = [
863                        'type' => 'info',
864                        'raw' => true,
865                        'section' => 'personal/email',
866                        'label-message' => 'prefs-emailconfirm-label',
867                        'default' => $emailauthenticated,
868                        // Apply the same CSS class used on the input to the message:
869                        'cssclass' => $emailauthenticationclass,
870                    ];
871                }
872            }
873
874            if ( $this->options->get( MainConfigNames::EnableUserEmail ) &&
875                $user->isAllowed( 'sendemail' )
876            ) {
877                $defaultPreferences['disablemail'] = [
878                    'id' => 'wpAllowEmail',
879                    'type' => 'toggle',
880                    'invert' => true,
881                    'section' => 'personal/email',
882                    'label-message' => 'allowemail',
883                    'disabled' => $disableEmailPrefs,
884                ];
885
886                $defaultPreferences['email-allow-new-users'] = [
887                    'id' => 'wpAllowEmailFromNewUsers',
888                    'type' => 'toggle',
889                    'section' => 'personal/email',
890                    'label-message' => 'email-allow-new-users-label',
891                    'disabled' => $disableEmailPrefs,
892                    'disable-if' => [ '!==', 'disablemail', '1' ],
893                ];
894
895                $defaultPreferences['ccmeonemails'] = [
896                    'type' => 'toggle',
897                    'section' => 'personal/email',
898                    'label-message' => 'tog-ccmeonemails',
899                    'disabled' => $disableEmailPrefs,
900                ];
901
902                if ( $this->options->get( MainConfigNames::EnableUserEmailMuteList ) ) {
903                    $defaultPreferences['email-blacklist'] = [
904                        'type' => 'usersmultiselect',
905                        'label-message' => 'email-mutelist-label',
906                        'section' => 'personal/email',
907                        'disabled' => $disableEmailPrefs,
908                        'filter' => MultiUsernameFilter::class,
909                    ];
910                }
911            }
912
913            if ( $this->options->get( MainConfigNames::EnotifWatchlist ) ) {
914                $defaultPreferences['enotifwatchlistpages'] = [
915                    'type' => 'toggle',
916                    'section' => 'personal/email',
917                    'label-message' => 'tog-enotifwatchlistpages',
918                    'disabled' => $disableEmailPrefs,
919                ];
920            }
921            if ( $this->options->get( MainConfigNames::EnotifUserTalk ) ) {
922                $defaultPreferences['enotifusertalkpages'] = [
923                    'type' => 'toggle',
924                    'section' => 'personal/email',
925                    'label-message' => 'tog-enotifusertalkpages',
926                    'disabled' => $disableEmailPrefs,
927                ];
928            }
929            if ( $this->options->get( MainConfigNames::EnotifUserTalk ) ||
930            $this->options->get( MainConfigNames::EnotifWatchlist ) ) {
931                if ( $this->options->get( MainConfigNames::EnotifMinorEdits ) ) {
932                    $defaultPreferences['enotifminoredits'] = [
933                        'type' => 'toggle',
934                        'section' => 'personal/email',
935                        'label-message' => 'tog-enotifminoredits',
936                        'disabled' => $disableEmailPrefs,
937                    ];
938                }
939
940                if ( $this->options->get( MainConfigNames::EnotifRevealEditorAddress ) ) {
941                    $defaultPreferences['enotifrevealaddr'] = [
942                        'type' => 'toggle',
943                        'section' => 'personal/email',
944                        'label-message' => 'tog-enotifrevealaddr',
945                        'disabled' => $disableEmailPrefs,
946                    ];
947                }
948            }
949        }
950    }
951
952    /**
953     * @param User $user
954     * @param IContextSource $context
955     * @param array &$defaultPreferences
956     * @return void
957     */
958    protected function skinPreferences( User $user, IContextSource $context, &$defaultPreferences ) {
959        // Skin selector, if there is at least one valid skin
960        $validSkinNames = $this->getValidSkinNames( $user, $context );
961        if ( $validSkinNames ) {
962            $defaultPreferences['skin'] = [
963                // @phan-suppress-next-line SecurityCheck-XSS False +ve, label is escaped in generateSkinOptions()
964                'type' => 'radio',
965                'options' => $this->generateSkinOptions( $user, $context, $validSkinNames ),
966                'section' => 'rendering/skin',
967            ];
968            $hideCond = [ 'AND' ];
969            foreach ( $validSkinNames as $skinName => $_ ) {
970                $options = $this->skinFactory->getSkinOptions( $skinName );
971                if ( $options['responsive'] ?? false ) {
972                    $hideCond[] = [ '!==', 'skin', $skinName ];
973                }
974            }
975            if ( $hideCond === [ 'AND' ] ) {
976                $hideCond = [];
977            }
978            $defaultPreferences['skin-responsive'] = [
979                'type' => 'check',
980                'label-message' => 'prefs-skin-responsive',
981                'section' => 'rendering/skin/skin-prefs',
982                'help-message' => 'prefs-help-skin-responsive',
983                'hide-if' => $hideCond,
984            ];
985        }
986
987        $allowUserCss = $this->options->get( MainConfigNames::AllowUserCss );
988        $allowUserJs = $this->options->get( MainConfigNames::AllowUserJs );
989        $safeMode = $this->userOptionsManager->getOption( $user, 'forcesafemode' );
990        // Create links to user CSS/JS pages for all skins.
991        // This code is basically copied from generateSkinOptions().
992        // @todo Refactor this and the similar code in generateSkinOptions().
993        if ( $allowUserCss || $allowUserJs ) {
994            if ( $safeMode ) {
995                $defaultPreferences['customcssjs-safemode'] = [
996                    'type' => 'info',
997                    'raw' => true,
998                    'default' => Html::warningBox( $context->msg( 'prefs-custom-cssjs-safemode' )->parse() ),
999                    'section' => 'rendering/skin',
1000                ];
1001            } else {
1002                $linkTools = [];
1003                $userName = $user->getName();
1004
1005                if ( $allowUserCss ) {
1006                    $cssPage = Title::makeTitleSafe( NS_USER, $userName . '/common.css' );
1007                    $cssLinkText = $context->msg( 'prefs-custom-css' )->text();
1008                    $linkTools[] = $this->linkRenderer->makeLink( $cssPage, $cssLinkText );
1009                }
1010
1011                if ( $allowUserJs ) {
1012                    $jsPage = Title::makeTitleSafe( NS_USER, $userName . '/common.js' );
1013                    $jsLinkText = $context->msg( 'prefs-custom-js' )->text();
1014                    $linkTools[] = $this->linkRenderer->makeLink( $jsPage, $jsLinkText );
1015                }
1016
1017                $defaultPreferences['commoncssjs'] = [
1018                    'type' => 'info',
1019                    'raw' => true,
1020                    'default' => $context->getLanguage()->pipeList( $linkTools ),
1021                    'label-message' => 'prefs-common-config',
1022                    'section' => 'rendering/skin',
1023                ];
1024            }
1025        }
1026    }
1027
1028    /**
1029     * @param IContextSource $context
1030     * @param array &$defaultPreferences
1031     */
1032    protected function filesPreferences( IContextSource $context, &$defaultPreferences ) {
1033        $defaultPreferences['imagesize'] = [
1034            'type' => 'select',
1035            'options' => $this->getImageSizes( $context ),
1036            'label-message' => 'imagemaxsize',
1037            'section' => 'rendering/files',
1038        ];
1039        $defaultPreferences['thumbsize'] = [
1040            'type' => 'select',
1041            'options' => $this->getThumbSizes( $context ),
1042            'label-message' => 'thumbsize',
1043            'section' => 'rendering/files',
1044        ];
1045    }
1046
1047    /**
1048     * @param User $user
1049     * @param IContextSource $context
1050     * @param array &$defaultPreferences
1051     * @return void
1052     */
1053    protected function datetimePreferences(
1054        User $user, IContextSource $context, &$defaultPreferences
1055    ) {
1056        $dateOptions = $this->getDateOptions( $context );
1057        if ( $dateOptions ) {
1058            $defaultPreferences['date'] = [
1059                'type' => 'radio',
1060                'options' => $dateOptions,
1061                'section' => 'rendering/dateformat',
1062            ];
1063        }
1064
1065        // Info
1066        $now = wfTimestampNow();
1067        $lang = $context->getLanguage();
1068        $nowlocal = Xml::element( 'span', [ 'id' => 'wpLocalTime' ],
1069            $lang->userTime( $now, $user ) );
1070        $nowserver = $lang->userTime( $now, $user,
1071                [ 'format' => false, 'timecorrection' => false ] ) .
1072            Html::hidden( 'wpServerTime', (int)substr( $now, 8, 2 ) * 60 + (int)substr( $now, 10, 2 ) );
1073
1074        $defaultPreferences['nowserver'] = [
1075            'type' => 'info',
1076            'raw' => 1,
1077            'label-message' => 'servertime',
1078            'default' => $nowserver,
1079            'section' => 'rendering/timeoffset',
1080        ];
1081
1082        $defaultPreferences['nowlocal'] = [
1083            'type' => 'info',
1084            'raw' => 1,
1085            'label-message' => 'localtime',
1086            'default' => $nowlocal,
1087            'section' => 'rendering/timeoffset',
1088        ];
1089
1090        $userTimeCorrection = (string)$this->userOptionsManager->getOption( $user, 'timecorrection' );
1091        // This value should already be normalized by UserTimeCorrection, so it should always be valid and not
1092        // in the legacy format. However, let's be sure about that and normalize it again.
1093        // Also, recompute the offset because it can change with DST.
1094        $userTimeCorrectionObj = new UserTimeCorrection(
1095            $userTimeCorrection,
1096            null,
1097            $this->options->get( MainConfigNames::LocalTZoffset )
1098        );
1099
1100        if ( $userTimeCorrectionObj->getCorrectionType() === UserTimeCorrection::OFFSET ) {
1101            $tzDefault = UserTimeCorrection::formatTimezoneOffset( $userTimeCorrectionObj->getTimeOffset() );
1102        } else {
1103            $tzDefault = $userTimeCorrectionObj->toString();
1104        }
1105
1106        $defaultPreferences['timecorrection'] = [
1107            'type' => 'timezone',
1108            'label-message' => 'timezonelegend',
1109            'default' => $tzDefault,
1110            'size' => 20,
1111            'section' => 'rendering/timeoffset',
1112            'id' => 'wpTimeCorrection',
1113            'filter' => TimezoneFilter::class,
1114        ];
1115    }
1116
1117    /**
1118     * @param User $user
1119     * @param MessageLocalizer $l10n
1120     * @param array &$defaultPreferences
1121     */
1122    protected function renderingPreferences(
1123        User $user,
1124        MessageLocalizer $l10n,
1125        &$defaultPreferences
1126    ) {
1127        // Diffs
1128        $defaultPreferences['diffonly'] = [
1129            'type' => 'toggle',
1130            'section' => 'rendering/diffs',
1131            'label-message' => 'tog-diffonly',
1132        ];
1133        $defaultPreferences['norollbackdiff'] = [
1134            'type' => 'toggle',
1135            'section' => 'rendering/diffs',
1136            'label-message' => 'tog-norollbackdiff',
1137        ];
1138        $defaultPreferences['diff-type'] = [
1139            'type' => 'api',
1140        ];
1141
1142        // Page Rendering
1143        if ( $this->options->get( MainConfigNames::AllowUserCssPrefs ) ) {
1144            $defaultPreferences['underline'] = [
1145                'type' => 'select',
1146                'options' => [
1147                    $l10n->msg( 'underline-never' )->text() => 0,
1148                    $l10n->msg( 'underline-always' )->text() => 1,
1149                    $l10n->msg( 'underline-default' )->text() => 2,
1150                ],
1151                'label-message' => 'tog-underline',
1152                'section' => 'rendering/advancedrendering',
1153            ];
1154        }
1155
1156        $defaultPreferences['showhiddencats'] = [
1157            'type' => 'toggle',
1158            'section' => 'rendering/advancedrendering',
1159            'label-message' => 'tog-showhiddencats'
1160        ];
1161
1162        if ( $user->isAllowed( 'rollback' ) ) {
1163            $defaultPreferences['showrollbackconfirmation'] = [
1164                'type' => 'toggle',
1165                'section' => 'rendering/advancedrendering',
1166                'label-message' => 'tog-showrollbackconfirmation',
1167            ];
1168        }
1169
1170        $defaultPreferences['forcesafemode'] = [
1171            'type' => 'toggle',
1172            'section' => 'rendering/advancedrendering',
1173            'label-message' => 'tog-forcesafemode',
1174            'help-message' => 'prefs-help-forcesafemode'
1175        ];
1176    }
1177
1178    /**
1179     * @param User $user
1180     * @param MessageLocalizer $l10n
1181     * @param array &$defaultPreferences
1182     */
1183    protected function editingPreferences( User $user, MessageLocalizer $l10n, &$defaultPreferences ) {
1184        $defaultPreferences['editsectiononrightclick'] = [
1185            'type' => 'toggle',
1186            'section' => 'editing/advancedediting',
1187            'label-message' => 'tog-editsectiononrightclick',
1188        ];
1189        $defaultPreferences['editondblclick'] = [
1190            'type' => 'toggle',
1191            'section' => 'editing/advancedediting',
1192            'label-message' => 'tog-editondblclick',
1193        ];
1194
1195        if ( $this->options->get( MainConfigNames::AllowUserCssPrefs ) ) {
1196            $defaultPreferences['editfont'] = [
1197                'type' => 'select',
1198                'section' => 'editing/editor',
1199                'label-message' => 'editfont-style',
1200                'options' => [
1201                    $l10n->msg( 'editfont-monospace' )->text() => 'monospace',
1202                    $l10n->msg( 'editfont-sansserif' )->text() => 'sans-serif',
1203                    $l10n->msg( 'editfont-serif' )->text() => 'serif',
1204                ]
1205            ];
1206        }
1207
1208        if ( $user->isAllowed( 'minoredit' ) ) {
1209            $defaultPreferences['minordefault'] = [
1210                'type' => 'toggle',
1211                'section' => 'editing/editor',
1212                'label-message' => 'tog-minordefault',
1213            ];
1214        }
1215
1216        $defaultPreferences['forceeditsummary'] = [
1217            'type' => 'toggle',
1218            'section' => 'editing/editor',
1219            'label-message' => 'tog-forceeditsummary',
1220        ];
1221
1222        // T350653
1223        if ( $this->options->get( MainConfigNames::EnableEditRecovery ) ) {
1224            $defaultPreferences['editrecovery'] = [
1225                'type' => 'toggle',
1226                'section' => 'editing/editor',
1227                'label-message' => 'tog-editrecovery',
1228                'help-message' => [
1229                    'tog-editrecovery-help',
1230                    'https://meta.wikimedia.org/wiki/Talk:Community_Wishlist_Survey_2023/Edit-recovery_feature',
1231                ],
1232            ];
1233        }
1234
1235        $defaultPreferences['useeditwarning'] = [
1236            'type' => 'toggle',
1237            'section' => 'editing/editor',
1238            'label-message' => 'tog-useeditwarning',
1239        ];
1240
1241        $defaultPreferences['previewonfirst'] = [
1242            'type' => 'toggle',
1243            'section' => 'editing/preview',
1244            'label-message' => 'tog-previewonfirst',
1245        ];
1246        $defaultPreferences['previewontop'] = [
1247            'type' => 'toggle',
1248            'section' => 'editing/preview',
1249            'label-message' => 'tog-previewontop',
1250        ];
1251        $defaultPreferences['uselivepreview'] = [
1252            'type' => 'toggle',
1253            'section' => 'editing/preview',
1254            'label-message' => 'tog-uselivepreview',
1255        ];
1256    }
1257
1258    /**
1259     * @param User $user
1260     * @param MessageLocalizer $l10n
1261     * @param array &$defaultPreferences
1262     */
1263    protected function rcPreferences( User $user, MessageLocalizer $l10n, &$defaultPreferences ) {
1264        $rcMaxAge = $this->options->get( MainConfigNames::RCMaxAge );
1265        $rcMax = ceil( $rcMaxAge / ( 3600 * 24 ) );
1266        $defaultPreferences['rcdays'] = [
1267            'type' => 'float',
1268            'label-message' => 'recentchangesdays',
1269            'section' => 'rc/displayrc',
1270            'min' => 1 / 24,
1271            'max' => $rcMax,
1272            'help-message' => [ 'recentchangesdays-max', Message::numParam( $rcMax ) ],
1273        ];
1274        $defaultPreferences['rclimit'] = [
1275            'type' => 'int',
1276            'min' => 1,
1277            'max' => 1000,
1278            'label-message' => 'recentchangescount',
1279            'help-message' => 'prefs-help-recentchangescount',
1280            'section' => 'rc/displayrc',
1281            'filter' => IntvalFilter::class,
1282        ];
1283        $defaultPreferences['usenewrc'] = [
1284            'type' => 'toggle',
1285            'label-message' => 'tog-usenewrc',
1286            'section' => 'rc/advancedrc',
1287        ];
1288        $defaultPreferences['hideminor'] = [
1289            'type' => 'toggle',
1290            'label-message' => 'tog-hideminor',
1291            'section' => 'rc/changesrc',
1292        ];
1293        $defaultPreferences['pst-cssjs'] = [
1294            'type' => 'api',
1295        ];
1296        $defaultPreferences['rcfilters-rc-collapsed'] = [
1297            'type' => 'api',
1298        ];
1299        $defaultPreferences['rcfilters-wl-collapsed'] = [
1300            'type' => 'api',
1301        ];
1302        $defaultPreferences['rcfilters-saved-queries'] = [
1303            'type' => 'api',
1304        ];
1305        $defaultPreferences['rcfilters-wl-saved-queries'] = [
1306            'type' => 'api',
1307        ];
1308        // Override RCFilters preferences for RecentChanges 'limit'
1309        $defaultPreferences['rcfilters-limit'] = [
1310            'type' => 'api',
1311        ];
1312        $defaultPreferences['rcfilters-saved-queries-versionbackup'] = [
1313            'type' => 'api',
1314        ];
1315        $defaultPreferences['rcfilters-wl-saved-queries-versionbackup'] = [
1316            'type' => 'api',
1317        ];
1318
1319        if ( $this->options->get( MainConfigNames::RCWatchCategoryMembership ) ) {
1320            $defaultPreferences['hidecategorization'] = [
1321                'type' => 'toggle',
1322                'label-message' => 'tog-hidecategorization',
1323                'section' => 'rc/changesrc',
1324            ];
1325        }
1326
1327        if ( $user->useRCPatrol() ) {
1328            $defaultPreferences['hidepatrolled'] = [
1329                'type' => 'toggle',
1330                'section' => 'rc/changesrc',
1331                'label-message' => 'tog-hidepatrolled',
1332            ];
1333        }
1334
1335        if ( $user->useNPPatrol() ) {
1336            $defaultPreferences['newpageshidepatrolled'] = [
1337                'type' => 'toggle',
1338                'section' => 'rc/changesrc',
1339                'label-message' => 'tog-newpageshidepatrolled',
1340            ];
1341        }
1342
1343        if ( $this->options->get( MainConfigNames::RCShowWatchingUsers ) ) {
1344            $defaultPreferences['shownumberswatching'] = [
1345                'type' => 'toggle',
1346                'section' => 'rc/advancedrc',
1347                'label-message' => 'tog-shownumberswatching',
1348            ];
1349        }
1350
1351        $defaultPreferences['rcenhancedfilters-disable'] = [
1352            'type' => 'toggle',
1353            'section' => 'rc/advancedrc',
1354            'label-message' => 'rcfilters-preference-label',
1355            'help-message' => 'rcfilters-preference-help',
1356        ];
1357    }
1358
1359    /**
1360     * @param User $user
1361     * @param IContextSource $context
1362     * @param array &$defaultPreferences
1363     */
1364    protected function watchlistPreferences(
1365        User $user, IContextSource $context, &$defaultPreferences
1366    ) {
1367        $watchlistdaysMax = ceil( $this->options->get( MainConfigNames::RCMaxAge ) / ( 3600 * 24 ) );
1368
1369        if ( $user->isAllowed( 'editmywatchlist' ) ) {
1370            $editWatchlistLinks = '';
1371            $editWatchlistModes = [
1372                'edit' => [ 'subpage' => false, 'flags' => [] ],
1373                'raw' => [ 'subpage' => 'raw', 'flags' => [] ],
1374                'clear' => [ 'subpage' => 'clear', 'flags' => [ 'destructive' ] ],
1375            ];
1376            foreach ( $editWatchlistModes as $mode => $options ) {
1377                // Messages: prefs-editwatchlist-edit, prefs-editwatchlist-raw, prefs-editwatchlist-clear
1378                $editWatchlistLinks .=
1379                    new ButtonWidget( [
1380                        'href' => SpecialPage::getTitleFor( 'EditWatchlist', $options['subpage'] )->getLinkURL(),
1381                        'flags' => $options[ 'flags' ],
1382                        'label' => new HtmlSnippet(
1383                            $context->msg( "prefs-editwatchlist-{$mode}" )->parse()
1384                        ),
1385                    ] );
1386            }
1387
1388            $defaultPreferences['editwatchlist'] = [
1389                'type' => 'info',
1390                'raw' => true,
1391                'default' => $editWatchlistLinks,
1392                'label-message' => 'prefs-editwatchlist-label',
1393                'section' => 'watchlist/editwatchlist',
1394            ];
1395        }
1396
1397        $defaultPreferences['watchlistdays'] = [
1398            'type' => 'float',
1399            'min' => 1 / 24,
1400            'max' => $watchlistdaysMax,
1401            'section' => 'watchlist/displaywatchlist',
1402            'help-message' => [ 'prefs-watchlist-days-max', Message::numParam( $watchlistdaysMax ) ],
1403            'label-message' => 'prefs-watchlist-days',
1404        ];
1405        $defaultPreferences['wllimit'] = [
1406            'type' => 'int',
1407            'min' => 1,
1408            'max' => 1000,
1409            'label-message' => 'prefs-watchlist-edits',
1410            'help-message' => 'prefs-watchlist-edits-max',
1411            'section' => 'watchlist/displaywatchlist',
1412            'filter' => IntvalFilter::class,
1413        ];
1414        $defaultPreferences['extendwatchlist'] = [
1415            'type' => 'toggle',
1416            'section' => 'watchlist/advancedwatchlist',
1417            'label-message' => 'tog-extendwatchlist',
1418        ];
1419        $defaultPreferences['watchlisthideminor'] = [
1420            'type' => 'toggle',
1421            'section' => 'watchlist/changeswatchlist',
1422            'label-message' => 'tog-watchlisthideminor',
1423        ];
1424        $defaultPreferences['watchlisthidebots'] = [
1425            'type' => 'toggle',
1426            'section' => 'watchlist/changeswatchlist',
1427            'label-message' => 'tog-watchlisthidebots',
1428        ];
1429        $defaultPreferences['watchlisthideown'] = [
1430            'type' => 'toggle',
1431            'section' => 'watchlist/changeswatchlist',
1432            'label-message' => 'tog-watchlisthideown',
1433        ];
1434        $defaultPreferences['watchlisthideanons'] = [
1435            'type' => 'toggle',
1436            'section' => 'watchlist/changeswatchlist',
1437            'label-message' => 'tog-watchlisthideanons',
1438        ];
1439        $defaultPreferences['watchlisthideliu'] = [
1440            'type' => 'toggle',
1441            'section' => 'watchlist/changeswatchlist',
1442            'label-message' => 'tog-watchlisthideliu',
1443        ];
1444
1445        if ( !SpecialWatchlist::checkStructuredFilterUiEnabled( $user ) ) {
1446            $defaultPreferences['watchlistreloadautomatically'] = [
1447                'type' => 'toggle',
1448                'section' => 'watchlist/advancedwatchlist',
1449                'label-message' => 'tog-watchlistreloadautomatically',
1450            ];
1451        }
1452
1453        $defaultPreferences['watchlistunwatchlinks'] = [
1454            'type' => 'toggle',
1455            'section' => 'watchlist/advancedwatchlist',
1456            'label-message' => 'tog-watchlistunwatchlinks',
1457        ];
1458
1459        if ( $this->options->get( MainConfigNames::RCWatchCategoryMembership ) ) {
1460            $defaultPreferences['watchlisthidecategorization'] = [
1461                'type' => 'toggle',
1462                'section' => 'watchlist/changeswatchlist',
1463                'label-message' => 'tog-watchlisthidecategorization',
1464            ];
1465        }
1466
1467        if ( $user->useRCPatrol() ) {
1468            $defaultPreferences['watchlisthidepatrolled'] = [
1469                'type' => 'toggle',
1470                'section' => 'watchlist/changeswatchlist',
1471                'label-message' => 'tog-watchlisthidepatrolled',
1472            ];
1473        }
1474
1475        $watchTypes = [
1476            'edit' => 'watchdefault',
1477            'move' => 'watchmoves',
1478        ];
1479
1480        // Kinda hacky
1481        if ( $user->isAllowedAny( 'createpage', 'createtalk' ) ) {
1482            $watchTypes['read'] = 'watchcreations';
1483        }
1484
1485        // Move uncommon actions to end of list
1486        $watchTypes += [
1487            'rollback' => 'watchrollback',
1488            'upload' => 'watchuploads',
1489            'delete' => 'watchdeletion',
1490        ];
1491
1492        foreach ( $watchTypes as $action => $pref ) {
1493            if ( $user->isAllowed( $action ) ) {
1494                // Messages:
1495                // tog-watchdefault, tog-watchmoves, tog-watchdeletion, tog-watchcreations, tog-watchuploads
1496                // tog-watchrollback
1497                $defaultPreferences[$pref] = [
1498                    'type' => 'toggle',
1499                    'section' => 'watchlist/pageswatchlist',
1500                    'label-message' => "tog-$pref",
1501                ];
1502            }
1503        }
1504
1505        $defaultPreferences['watchlisttoken'] = [
1506            'type' => 'api',
1507        ];
1508
1509        $tokenButton = new ButtonWidget( [
1510            'href' => SpecialPage::getTitleFor( 'ResetTokens' )->getLinkURL( [
1511                'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText()
1512            ] ),
1513            'label' => $context->msg( 'prefs-watchlist-managetokens' )->text(),
1514        ] );
1515        $defaultPreferences['watchlisttoken-info'] = [
1516            'type' => 'info',
1517            'section' => 'watchlist/tokenwatchlist',
1518            'label-message' => 'prefs-watchlist-token',
1519            'help-message' => 'prefs-help-tokenmanagement',
1520            'raw' => true,
1521            'default' => (string)$tokenButton,
1522        ];
1523
1524        $defaultPreferences['wlenhancedfilters-disable'] = [
1525            'type' => 'toggle',
1526            'section' => 'watchlist/advancedwatchlist',
1527            'label-message' => 'rcfilters-watchlist-preference-label',
1528            'help-message' => 'rcfilters-watchlist-preference-help',
1529        ];
1530    }
1531
1532    /**
1533     * @param IContextSource $context
1534     * @param array &$defaultPreferences
1535     */
1536    protected function searchPreferences( $context, &$defaultPreferences ) {
1537        $defaultPreferences['search-special-page'] = [
1538            'type' => 'api',
1539        ];
1540
1541        foreach ( $this->nsInfo->getValidNamespaces() as $n ) {
1542            $defaultPreferences['searchNs' . $n] = [
1543                'type' => 'api',
1544            ];
1545        }
1546
1547        if ( $this->options->get( MainConfigNames::SearchMatchRedirectPreference ) ) {
1548            $defaultPreferences['search-match-redirect'] = [
1549                'type' => 'toggle',
1550                'section' => 'searchoptions/searchmisc',
1551                'label-message' => 'search-match-redirect-label',
1552                'help-message' => 'search-match-redirect-help',
1553            ];
1554        } else {
1555            $defaultPreferences['search-match-redirect'] = [
1556                'type' => 'api',
1557            ];
1558        }
1559
1560        $defaultPreferences['searchlimit'] = [
1561            'type' => 'int',
1562            'min' => 1,
1563            'max' => 500,
1564            'section' => 'searchoptions/searchmisc',
1565            'label-message' => 'searchlimit-label',
1566            'help-message' => $context->msg( 'searchlimit-help', 500 ),
1567            'filter' => IntvalFilter::class,
1568        ];
1569
1570        // show a preference for thumbnails from namespaces other than NS_FILE,
1571        // only when there they're actually configured to be served
1572        $thumbNamespaces = $this->options->get( MainConfigNames::ThumbnailNamespaces );
1573        $thumbNamespacesFormatted = array_combine(
1574            $thumbNamespaces,
1575            array_map(
1576                static function ( $namespaceId ) use ( $context ) {
1577                    return $namespaceId === NS_MAIN
1578                        ? $context->msg( 'blanknamespace' )->escaped()
1579                        : $context->getLanguage()->getFormattedNsText( $namespaceId );
1580                },
1581                $thumbNamespaces
1582            )
1583        );
1584        $defaultThumbNamespacesFormatted =
1585            array_intersect_key( $thumbNamespacesFormatted, [ NS_FILE => 1 ] ) ?? [];
1586        $extraThumbNamespacesFormatted =
1587            array_diff_key( $thumbNamespacesFormatted, [ NS_FILE => 1 ] );
1588        if ( $extraThumbNamespacesFormatted ) {
1589            $defaultPreferences['search-thumbnail-extra-namespaces'] = [
1590                'type' => 'toggle',
1591                'section' => 'searchoptions/searchmisc',
1592                'label-message' => 'search-thumbnail-extra-namespaces-label',
1593                'help-message' => $context->msg(
1594                    'search-thumbnail-extra-namespaces-message',
1595                    $context->getLanguage()->listToText( $extraThumbNamespacesFormatted ),
1596                    count( $extraThumbNamespacesFormatted ),
1597                    $context->getLanguage()->listToText( $defaultThumbNamespacesFormatted ),
1598                    count( $defaultThumbNamespacesFormatted )
1599                ),
1600            ];
1601        }
1602    }
1603
1604    /*
1605     * Custom skin string comparison function that takes into account current and preferred skins.
1606     *
1607     * @param string $a
1608     * @param string $b
1609     * @param string $currentSkin
1610     * @param array $preferredSkins
1611     * @return int
1612     */
1613    private static function sortSkinNames( $a, $b, $currentSkin, $preferredSkins ) {
1614        // Display the current skin first in the list
1615        if ( strcasecmp( $a, $currentSkin ) === 0 ) {
1616            return -1;
1617        }
1618        if ( strcasecmp( $b, $currentSkin ) === 0 ) {
1619            return 1;
1620        }
1621        // Display preferred skins over other skins
1622        if ( count( $preferredSkins ) ) {
1623            $aPreferred = array_search( $a, $preferredSkins );
1624            $bPreferred = array_search( $b, $preferredSkins );
1625            // Cannot use ! operator because array_search returns the
1626            // index of the array item if found (i.e. 0) and false otherwise
1627            if ( $aPreferred !== false && $bPreferred === false ) {
1628                return -1;
1629            }
1630            if ( $aPreferred === false && $bPreferred !== false ) {
1631                return 1;
1632            }
1633            // When both skins are preferred, default to the ordering
1634            // specified by the preferred skins config array
1635            if ( $aPreferred !== false && $bPreferred !== false ) {
1636                return strcasecmp( $aPreferred, $bPreferred );
1637            }
1638        }
1639        // Use normal string comparison if both strings are not preferred
1640        return strcasecmp( $a, $b );
1641    }
1642
1643    /**
1644     * Gat valid skin names for the given user, which the 'useskin' query string and user
1645     * options should be taken into account.
1646     *
1647     * @param User $user
1648     * @param IContextSource $context
1649     * @return array Associative array in the format of [ 'skin name' => 'display name' ].
1650     */
1651    private function getValidSkinNames( User $user, IContextSource $context ) {
1652        // Only show skins that aren't disabled
1653        $validSkinNames = $this->skinFactory->getAllowedSkins();
1654        $allInstalledSkins = $this->skinFactory->getInstalledSkins();
1655
1656        // Display the installed skin the user has specifically requested via useskin=….
1657        $useSkin = $context->getRequest()->getRawVal( 'useskin' );
1658        if ( isset( $allInstalledSkins[$useSkin] )
1659            && $context->msg( "skinname-$useSkin" )->exists()
1660        ) {
1661            $validSkinNames[$useSkin] = $useSkin;
1662        }
1663
1664        // Display the skin if the user has set it as a preference already before it was hidden.
1665        $currentUserSkin = $this->userOptionsManager->getOption( $user, 'skin' );
1666        if ( isset( $allInstalledSkins[$currentUserSkin] )
1667            && $context->msg( "skinname-$currentUserSkin" )->exists()
1668        ) {
1669            $validSkinNames[$currentUserSkin] = $currentUserSkin;
1670        }
1671
1672        foreach ( $validSkinNames as $skinkey => &$skinname ) {
1673            $msg = $context->msg( "skinname-{$skinkey}" );
1674            if ( $msg->exists() ) {
1675                $skinname = htmlspecialchars( $msg->text() );
1676            }
1677        }
1678
1679        $preferredSkins = $this->options->get( MainConfigNames::SkinsPreferred );
1680        // Sort by the internal name, so that the ordering is the same for each display language,
1681        // especially if some skin names are translated to use a different alphabet and some are not.
1682        uksort( $validSkinNames, function ( $a, $b ) use ( $currentUserSkin, $preferredSkins ) {
1683            return $this->sortSkinNames( $a, $b, $currentUserSkin, $preferredSkins );
1684        } );
1685
1686        return $validSkinNames;
1687    }
1688
1689    /**
1690     * @param User $user
1691     * @param IContextSource $context
1692     * @param array $validSkinNames
1693     * @return array Text/links to display as key; $skinkey as value
1694     */
1695    protected function generateSkinOptions( User $user, IContextSource $context, array $validSkinNames ) {
1696        $ret = [];
1697
1698        $mptitle = Title::newMainPage();
1699        $previewtext = $context->msg( 'skin-preview' )->escaped();
1700        $defaultSkin = $this->options->get( MainConfigNames::DefaultSkin );
1701        $allowUserCss = $this->options->get( MainConfigNames::AllowUserCss );
1702        $allowUserJs = $this->options->get( MainConfigNames::AllowUserJs );
1703        $safeMode = $this->userOptionsManager->getOption( $user, 'forcesafemode' );
1704        $foundDefault = false;
1705        foreach ( $validSkinNames as $skinkey => $sn ) {
1706            $linkTools = [];
1707
1708            // Mark the default skin
1709            if ( strcasecmp( $skinkey, $defaultSkin ) === 0 ) {
1710                $linkTools[] = $context->msg( 'default' )->escaped();
1711                $foundDefault = true;
1712            }
1713
1714            // Create talk page link if relevant message exists.
1715            $talkPageMsg = $context->msg( "$skinkey-prefs-talkpage" );
1716            if ( $talkPageMsg->exists() ) {
1717                $linkTools[] = $talkPageMsg->parse();
1718            }
1719
1720            // Create preview link
1721            $mplink = htmlspecialchars( $mptitle->getLocalURL( [ 'useskin' => $skinkey ] ) );
1722            $linkTools[] = "<a target='_blank' href=\"$mplink\">$previewtext</a>";
1723
1724            if ( !$safeMode ) {
1725                // Create links to user CSS/JS pages
1726                // @todo Refactor this and the similar code in skinPreferences().
1727                if ( $allowUserCss ) {
1728                    $cssPage = Title::makeTitleSafe( NS_USER, $user->getName() . '/' . $skinkey . '.css' );
1729                    $cssLinkText = $context->msg( 'prefs-custom-css' )->text();
1730                    $linkTools[] = $this->linkRenderer->makeLink( $cssPage, $cssLinkText );
1731                }
1732
1733                if ( $allowUserJs ) {
1734                    $jsPage = Title::makeTitleSafe( NS_USER, $user->getName() . '/' . $skinkey . '.js' );
1735                    $jsLinkText = $context->msg( 'prefs-custom-js' )->text();
1736                    $linkTools[] = $this->linkRenderer->makeLink( $jsPage, $jsLinkText );
1737                }
1738            }
1739
1740            $display = $sn . ' ' . $context->msg( 'parentheses' )
1741                ->rawParams( $context->getLanguage()->pipeList( $linkTools ) )
1742                ->escaped();
1743            $ret[$display] = $skinkey;
1744        }
1745
1746        if