Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.63% covered (warning)
78.63%
1045 / 1329
27.27% covered (danger)
27.27%
9 / 33
CRAP
0.00% covered (danger)
0.00%
0 / 1
DefaultPreferencesFactory
78.63% covered (warning)
78.63%
1045 / 1329
27.27% covered (danger)
27.27%
9 / 33
907.56
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
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getFormDescriptor
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 simplifyFormDescriptor
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
15.64
 loadPreferenceValues
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
9.51
 getPreferenceForField
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
8.74
 getOptionFromUser
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
342
 profilePreferences
82.28% covered (warning)
82.28%
311 / 378
0.00% covered (danger)
0.00%
0 / 1
60.83
 skinPreferences
43.64% covered (danger)
43.64%
24 / 55
0.00% covered (danger)
0.00%
0 / 1
27.91
 filesPreferences
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
4
 datetimePreferences
97.83% covered (success)
97.83%
45 / 46
0.00% covered (danger)
0.00%
0 / 1
3
 renderingPreferences
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
3
 editingPreferences
85.48% covered (warning)
85.48%
53 / 62
0.00% covered (danger)
0.00%
0 / 1
4.05
 rcPreferences
75.90% covered (warning)
75.90%
63 / 83
0.00% covered (danger)
0.00%
0 / 1
5.35
 watchlistPreferences
75.15% covered (warning)
75.15%
127 / 169
0.00% covered (danger)
0.00%
0 / 1
15.59
 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
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
8.01
 renderUserGroupList
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
4.01
 generateSkinOptions
79.41% covered (warning)
79.41%
27 / 34
0.00% covered (danger)
0.00%
0 / 1
8.56
 getDateOptions
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
6.09
 getImageSizes
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getNormalizedThumbSizes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getThumbSizes
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 validateSignature
22.73% covered (danger)
22.73%
5 / 22
0.00% covered (danger)
0.00%
0 / 1
90.98
 cleanSignature
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 getForm
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
5.01
 saveFormData
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
12.04
 applyFilters
57.14% covered (warning)
57.14%
8 / 14
0.00% covered (danger)
0.00%
0 / 1
10.86
 submitForm
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 getResetKinds
65.12% covered (warning)
65.12%
28 / 43
0.00% covered (danger)
0.00%
0 / 1
54.70
 listResetKinds
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getOptionNamesForReset
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Preferences;
8
9use MediaWiki\Actions\WatchAction;
10use MediaWiki\Auth\AuthManager;
11use MediaWiki\Auth\PasswordAuthenticationRequest;
12use MediaWiki\Config\ServiceOptions;
13use MediaWiki\Context\IContextSource;
14use MediaWiki\HookContainer\HookContainer;
15use MediaWiki\HookContainer\HookRunner;
16use MediaWiki\Html\Html;
17use MediaWiki\HTMLForm\Field\HTMLCheckMatrix;
18use MediaWiki\HTMLForm\Field\HTMLInfoField;
19use MediaWiki\HTMLForm\Field\HTMLMultiSelectField;
20use MediaWiki\HTMLForm\HTMLForm;
21use MediaWiki\HTMLForm\HTMLFormField;
22use MediaWiki\HTMLForm\HTMLNestedFilterable;
23use MediaWiki\Language\ILanguageConverter;
24use MediaWiki\Language\Language;
25use MediaWiki\Language\LanguageConverter;
26use MediaWiki\Language\LanguageConverterFactory;
27use MediaWiki\Language\LanguageNameUtils;
28use MediaWiki\Language\MessageLocalizer;
29use MediaWiki\Linker\LinkRenderer;
30use MediaWiki\MainConfigNames;
31use MediaWiki\MediaWikiServices;
32use MediaWiki\Message\Message;
33use MediaWiki\Output\OutputPage;
34use MediaWiki\Parser\Parser;
35use MediaWiki\Parser\ParserFactory;
36use MediaWiki\Parser\ParserOptions;
37use MediaWiki\Permissions\PermissionManager;
38use MediaWiki\Skin\SkinFactory;
39use MediaWiki\SpecialPage\SpecialPage;
40use MediaWiki\Specials\Forms\PreferencesFormOOUI;
41use MediaWiki\Specials\SpecialListGroupRights;
42use MediaWiki\Specials\SpecialWatchlist;
43use MediaWiki\Status\Status;
44use MediaWiki\Title\NamespaceInfo;
45use MediaWiki\Title\Title;
46use MediaWiki\User\Options\UserOptionsLookup;
47use MediaWiki\User\Options\UserOptionsManager;
48use MediaWiki\User\User;
49use MediaWiki\User\UserGroupManager;
50use MediaWiki\User\UserGroupMembership;
51use MediaWiki\User\UserIdentity;
52use MediaWiki\User\UserTimeCorrection;
53use OOUI\ButtonWidget;
54use OOUI\FieldLayout;
55use OOUI\HorizontalLayout;
56use OOUI\HtmlSnippet;
57use OOUI\LabelWidget;
58use OOUI\MessageWidget;
59use Psr\Log\LoggerAwareTrait;
60use Psr\Log\NullLogger;
61use UnexpectedValueException;
62use Wikimedia\Rdbms\IDBAccessObject;
63
64/**
65 * This is the default implementation of PreferencesFactory.
66 */
67class DefaultPreferencesFactory implements PreferencesFactory {
68    use LoggerAwareTrait;
69
70    /** @var ServiceOptions */
71    protected $options;
72
73    /** @var Language The wiki's content language. */
74    protected $contLang;
75
76    /** @var LanguageNameUtils */
77    protected $languageNameUtils;
78
79    /** @var AuthManager */
80    protected $authManager;
81
82    /** @var LinkRenderer */
83    protected $linkRenderer;
84
85    /** @var NamespaceInfo */
86    protected $nsInfo;
87
88    /** @var PermissionManager */
89    protected $permissionManager;
90
91    /** @var ILanguageConverter */
92    private $languageConverter;
93
94    /** @var HookRunner */
95    private $hookRunner;
96
97    /** @var UserOptionsManager */
98    protected $userOptionsManager;
99
100    /** @var LanguageConverterFactory */
101    private $languageConverterFactory;
102
103    /** @var ParserFactory */
104    private $parserFactory;
105
106    /** @var SkinFactory */
107    private $skinFactory;
108
109    /** @var UserGroupManager */
110    private $userGroupManager;
111
112    /** @var SignatureValidatorFactory */
113    private $signatureValidatorFactory;
114
115    /**
116     * @internal For use by ServiceWiring
117     */
118    public const CONSTRUCTOR_OPTIONS = [
119        MainConfigNames::AllowUserCss,
120        MainConfigNames::AllowUserCssPrefs,
121        MainConfigNames::AllowUserJs,
122        MainConfigNames::DefaultSkin,
123        MainConfigNames::EmailAuthentication,
124        MainConfigNames::EmailConfirmToEdit,
125        MainConfigNames::EnableEditRecovery,
126        MainConfigNames::EnableEmail,
127        MainConfigNames::EnableUserEmail,
128        MainConfigNames::EnableWatchlistLabels,
129        MainConfigNames::EnotifMinorEdits,
130        MainConfigNames::EnotifRevealEditorAddress,
131        MainConfigNames::EnotifUserTalk,
132        MainConfigNames::EnotifWatchlist,
133        MainConfigNames::ForceHTTPS,
134        MainConfigNames::HiddenPrefs,
135        MainConfigNames::ImageLimits,
136        MainConfigNames::LanguageCode,
137        MainConfigNames::LocalTZoffset,
138        MainConfigNames::MaxSigChars,
139        MainConfigNames::RCMaxAge,
140        MainConfigNames::RCShowWatchingUsers,
141        MainConfigNames::RCWatchCategoryMembership,
142        MainConfigNames::SearchMatchRedirectPreference,
143        MainConfigNames::SecureLogin,
144        MainConfigNames::ScriptPath,
145        MainConfigNames::SignatureValidation,
146        MainConfigNames::SkinsPreferred,
147        MainConfigNames::ThumbLimits,
148        MainConfigNames::ThumbnailNamespaces,
149        MainConfigNames::WatchlistExpiry,
150    ];
151
152    /**
153     * @param ServiceOptions $options
154     * @param Language $contLang
155     * @param AuthManager $authManager
156     * @param LinkRenderer $linkRenderer
157     * @param NamespaceInfo $nsInfo
158     * @param PermissionManager $permissionManager
159     * @param ILanguageConverter $languageConverter
160     * @param LanguageNameUtils $languageNameUtils
161     * @param HookContainer $hookContainer
162     * @param UserOptionsLookup $userOptionsLookup Should be an instance of UserOptionsManager
163     * @param LanguageConverterFactory|null $languageConverterFactory
164     * @param ParserFactory|null $parserFactory
165     * @param SkinFactory|null $skinFactory
166     * @param UserGroupManager|null $userGroupManager
167     * @param SignatureValidatorFactory|null $signatureValidatorFactory
168     */
169    public function __construct(
170        ServiceOptions $options,
171        Language $contLang,
172        AuthManager $authManager,
173        LinkRenderer $linkRenderer,
174        NamespaceInfo $nsInfo,
175        PermissionManager $permissionManager,
176        ILanguageConverter $languageConverter,
177        LanguageNameUtils $languageNameUtils,
178        HookContainer $hookContainer,
179        UserOptionsLookup $userOptionsLookup,
180        ?LanguageConverterFactory $languageConverterFactory = null,
181        ?ParserFactory $parserFactory = null,
182        ?SkinFactory $skinFactory = null,
183        ?UserGroupManager $userGroupManager = null,
184        ?SignatureValidatorFactory $signatureValidatorFactory = null
185    ) {
186        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
187
188        $this->options = $options;
189        $this->contLang = $contLang;
190        $this->authManager = $authManager;
191        $this->linkRenderer = $linkRenderer;
192        $this->nsInfo = $nsInfo;
193
194        // We don't use the PermissionManager anymore, but we need to be careful
195        // removing the parameter since this class is extended by GlobalPreferencesFactory
196        // in the GlobalPreferences extension, and that class uses it
197        $this->permissionManager = $permissionManager;
198
199        $this->logger = new NullLogger();
200        $this->languageConverter = $languageConverter;
201        $this->languageNameUtils = $languageNameUtils;
202        $this->hookRunner = new HookRunner( $hookContainer );
203
204        // Don't break GlobalPreferences, fall back to global state if missing services
205        // or if passed a UserOptionsLookup that isn't UserOptionsManager
206        $services = static function () {
207            // BC hack. Use a closure so this can be unit-tested.
208            return MediaWikiServices::getInstance();
209        };
210        $this->userOptionsManager = ( $userOptionsLookup instanceof UserOptionsManager )
211            ? $userOptionsLookup
212            : $services()->getUserOptionsManager();
213        $this->languageConverterFactory = $languageConverterFactory ?? $services()->getLanguageConverterFactory();
214
215        $this->parserFactory = $parserFactory ?? $services()->getParserFactory();
216        $this->skinFactory = $skinFactory ?? $services()->getSkinFactory();
217        $this->userGroupManager = $userGroupManager ?? $services()->getUserGroupManager();
218        $this->signatureValidatorFactory = $signatureValidatorFactory
219            ?? $services()->getSignatureValidatorFactory();
220    }
221
222    /**
223     * @inheritDoc
224     */
225    public function getSaveBlacklist() {
226        return [
227            'realname',
228            'emailaddress',
229        ];
230    }
231
232    /**
233     * @param User $user
234     * @param IContextSource $context
235     * @return array
236     */
237    public function getFormDescriptor( User $user, IContextSource $context ) {
238        $preferences = [];
239
240        OutputPage::setupOOUI(
241            strtolower( $context->getSkin()->getSkinName() ),
242            $context->getLanguage()->getDir()
243        );
244
245        $this->profilePreferences( $user, $context, $preferences );
246        $this->skinPreferences( $user, $context, $preferences );
247        $this->datetimePreferences( $user, $context, $preferences );
248        $this->filesPreferences( $context, $preferences );
249        $this->renderingPreferences( $user, $context, $preferences );
250        $this->editingPreferences( $user, $context, $preferences );
251        $this->rcPreferences( $user, $context, $preferences );
252        $this->watchlistPreferences( $user, $context, $preferences );
253        $this->searchPreferences( $context, $preferences );
254
255        $this->hookRunner->onGetPreferences( $user, $preferences );
256
257        $this->loadPreferenceValues( $user, $context, $preferences );
258        $this->logger->debug( "Created form descriptor for user '{$user->getName()}'" );
259        return $preferences;
260    }
261
262    /**
263     * Simplify form descriptor for validation or something similar.
264     *
265     * @param array $descriptor HTML form descriptor.
266     * @return array
267     */
268    public static function simplifyFormDescriptor( array $descriptor ) {
269        foreach ( $descriptor as $name => &$params ) {
270            // Info fields are useless and can use complicated closure to provide
271            // text, skip all of them.
272            if ( ( isset( $params['type'] ) && $params['type'] === 'info' ) ||
273                // Checking old alias for compatibility with unchanged extensions
274                ( isset( $params['class'] ) && $params['class'] === 'HTMLInfoField' ) ||
275                ( isset( $params['class'] ) && $params['class'] === HTMLInfoField::class )
276            ) {
277                unset( $descriptor[$name] );
278                continue;
279            }
280            // Message parsing is the heaviest load when constructing the field,
281            // but we just want to validate data.
282            foreach ( $params as $key => $value ) {
283                switch ( $key ) {
284                    // Special case, should be kept.
285                    case 'options-message':
286                        break;
287                    // Special case, should be transferred.
288                    case 'options-messages':
289                        unset( $params[$key] );
290                        $params['options'] = $value;
291                        break;
292                    default:
293                        if ( preg_match( '/-messages?$/', $key ) ) {
294                            // Unwanted.
295                            unset( $params[$key] );
296                        }
297                }
298            }
299        }
300        return $descriptor;
301    }
302
303    /**
304     * Loads existing values for a given array of preferences
305     * @param User $user
306     * @param IContextSource $context
307     * @param array &$defaultPreferences Array to load values for
308     * @return array|null
309     */
310    private function loadPreferenceValues( User $user, IContextSource $context, &$defaultPreferences ) {
311        // Remove preferences that wikis don't want to use
312        foreach ( $this->options->get( MainConfigNames::HiddenPrefs ) as $pref ) {
313            unset( $defaultPreferences[$pref] );
314        }
315
316        // For validation.
317        $simplified = self::simplifyFormDescriptor( $defaultPreferences );
318        $form = new HTMLForm( $simplified, $context );
319
320        $disable = !$user->isAllowed( 'editmyoptions' );
321
322        $defaultOptions = $this->userOptionsManager->getDefaultOptions( $user );
323        $userOptions = $this->userOptionsManager->getOptions( $user );
324        $this->applyFilters( $userOptions, $defaultPreferences, 'filterForForm' );
325        // Add in defaults from the user
326        foreach ( $simplified as $name => $_ ) {
327            $info = &$defaultPreferences[$name];
328            if ( $disable && !in_array( $name, $this->getSaveBlacklist() ) ) {
329                $info['disabled'] = 'disabled';
330            }
331            if ( isset( $info['default'] ) ) {
332                // Already set, no problem
333                continue;
334            }
335            $field = $form->getField( $name );
336            $globalDefault = $defaultOptions[$name] ?? null;
337            $prefFromUser = static::getPreferenceForField( $name, $field, $userOptions );
338
339            // If it validates, set it as the default
340            // FIXME: That's not how the validate() function works! Values of nested fields
341            // (e.g. CheckMatrix) would be missing.
342            if ( $prefFromUser !== null && // Make sure we're not just pulling nothing
343                    $field->validate( $prefFromUser, $this->userOptionsManager->getOptions( $user ) ) === true ) {
344                $info['default'] = $prefFromUser;
345            } elseif ( $field->validate( $globalDefault, $this->userOptionsManager->getOptions( $user ) ) === true ) {
346                $info['default'] = $globalDefault;
347            } else {
348                $globalDefault = json_encode( $globalDefault );
349                throw new UnexpectedValueException(
350                    "Default '$globalDefault' is invalid for preference $name of user " . $user->getName()
351                );
352            }
353        }
354
355        return $defaultPreferences;
356    }
357
358    /**
359     * Get preference values for the 'default' param of html form descriptor, compatible
360     * with nested fields.
361     *
362     * @since 1.41
363     * @param string $name
364     * @param HTMLFormField $field
365     * @param array $userOptions
366     * @return array|string
367     */
368    public static function getPreferenceForField( $name, HTMLFormField $field, array $userOptions ) {
369        $val = $userOptions[$name] ?? null;
370
371        if ( $field instanceof HTMLNestedFilterable ) {
372            $val = [];
373            $prefix = $field->mParams['prefix'] ?? $name;
374            // Fetch all possible preference keys of the given field on this wiki.
375            $keys = array_keys( $field->filterDataForSubmit( [] ) );
376            foreach ( $keys as $key ) {
377                if ( $userOptions[$prefix . $key] ?? false ) {
378                    $val[] = $key;
379                }
380            }
381        }
382
383        return $val;
384    }
385
386    /**
387     * Pull option from a user account. Handles stuff like array-type preferences.
388     *
389     * @deprecated since 1.41; Use getPreferenceForField() instead.
390     * @param string $name
391     * @param array $info
392     * @param array $userOptions
393     * @return array|string
394     */
395    protected function getOptionFromUser( $name, $info, array $userOptions ) {
396        $val = $userOptions[$name] ?? null;
397
398        // Handling for multiselect preferences
399        if ( ( isset( $info['type'] ) && $info['type'] == 'multiselect' ) ||
400            // Checking old alias for compatibility with unchanged extensions
401            ( isset( $info['class'] ) && $info['class'] === 'HTMLMultiSelectField' ) ||
402            ( isset( $info['class'] ) && $info['class'] === HTMLMultiSelectField::class )
403        ) {
404            $options = HTMLFormField::flattenOptions( $info['options-messages'] ?? $info['options'] );
405            $prefix = $info['prefix'] ?? $name;
406            $val = [];
407
408            foreach ( $options as $value ) {
409                if ( $userOptions["$prefix$value"] ?? false ) {
410                    $val[] = $value;
411                }
412            }
413        }
414
415        // Handling for checkmatrix preferences
416        if ( ( isset( $info['type'] ) && $info['type'] == 'checkmatrix' ) ||
417            // Checking old alias for compatibility with unchanged extensions
418            ( isset( $info['class'] ) && $info['class'] === 'HTMLCheckMatrix' ) ||
419            ( isset( $info['class'] ) && $info['class'] === HTMLCheckMatrix::class )
420        ) {
421            $columns = HTMLFormField::flattenOptions( $info['columns'] );
422            $rows = HTMLFormField::flattenOptions( $info['rows'] );
423            $prefix = $info['prefix'] ?? $name;
424            $val = [];
425
426            foreach ( $columns as $column ) {
427                foreach ( $rows as $row ) {
428                    if ( $userOptions["$prefix$column-$row"] ?? false ) {
429                        $val[] = "$column-$row";
430                    }
431                }
432            }
433        }
434
435        return $val;
436    }
437
438    /**
439     * @todo Inject user Language instead of using context.
440     * @param User $user
441     * @param IContextSource $context
442     * @param array &$defaultPreferences
443     * @return void
444     */
445    protected function profilePreferences(
446        User $user, IContextSource $context, &$defaultPreferences
447    ) {
448        // retrieving user name for GENDER and misc.
449        $userName = $user->getName();
450
451        // Information panel
452        $defaultPreferences['username'] = [
453            'type' => 'info',
454            'label-message' => [ 'username', $userName ],
455            'default' => $userName,
456            'section' => 'personal/info',
457        ];
458
459        $lang = $context->getLanguage();
460
461        // Get groups to which the user belongs, Skip the default * group, seems useless here
462        $userEffectiveGroups = array_diff(
463            $this->userGroupManager->getUserEffectiveGroups( $user ),
464            [ '*' ]
465        );
466        $defaultPreferences['usergroups'] = [
467            'type' => 'info',
468            'label-message' => [ 'prefs-memberingroups',
469                Message::numParam( count( $userEffectiveGroups ) ), $userName ],
470            'default' => function () use ( $user, $userEffectiveGroups, $context ) {
471                return $this->renderUserGroupList( $user, $userEffectiveGroups, $context );
472            },
473            'raw' => true,
474            'section' => 'personal/info',
475        ];
476
477        $userDisabledGroups = $this->userGroupManager->getUserDisabledGroups( $user );
478        if ( $userDisabledGroups ) {
479            $conditionsLink = SpecialPage::getTitleFor(
480                'Listgrouprights', false,
481                SpecialListGroupRights::RESTRICTED_GROUPS_SECTION_ID
482            )->getFullText();
483            $defaultPreferences['usergroups-disabled'] = [
484                'type' => 'info',
485                'label-message' => [ 'prefs-memberingroupsdisabled',
486                    Message::numParam( count( $userDisabledGroups ) ), $userName ],
487                'default' => function () use ( $user, $userDisabledGroups, $context ) {
488                    return $this->renderUserGroupList( $user, $userDisabledGroups, $context );
489                },
490                'help-message' => [ 'prefs-memberingroupsdisabled-help',
491                    Message::numParam( count( $userDisabledGroups ) ), $user->getName(), $conditionsLink ],
492                'raw' => true,
493                'section' => 'personal/info',
494            ];
495        }
496
497        $contribTitle = SpecialPage::getTitleFor( "Contributions", $userName );
498        $formattedEditCount = $lang->formatNum( $user->getEditCount() );
499        $editCount = $this->linkRenderer->makeLink( $contribTitle, $formattedEditCount );
500
501        $defaultPreferences['editcount'] = [
502            'type' => 'info',
503            'raw' => true,
504            'label-message' => 'prefs-edits',
505            'default' => $editCount,
506            'section' => 'personal/info',
507        ];
508
509        if ( $user->getRegistration() ) {
510            $displayUser = $context->getUser();
511            $userRegistration = $user->getRegistration();
512            $defaultPreferences['registrationdate'] = [
513                'type' => 'info',
514                'label-message' => 'prefs-registration',
515                'default' => $context->msg(
516                    'prefs-registration-date-time',
517                    $lang->userTimeAndDate( $userRegistration, $displayUser ),
518                    $lang->userDate( $userRegistration, $displayUser ),
519                    $lang->userTime( $userRegistration, $displayUser )
520                )->text(),
521                'section' => 'personal/info',
522            ];
523        }
524
525        $canViewPrivateInfo = $user->isAllowed( 'viewmyprivateinfo' );
526        $canEditPrivateInfo = $user->isAllowed( 'editmyprivateinfo' );
527
528        // Actually changeable stuff
529        $defaultPreferences['realname'] = [
530            // (not really "private", but still shouldn't be edited without permission)
531            'type' => $canEditPrivateInfo && $this->authManager->allowsPropertyChange( 'realname' )
532                ? 'text' : 'info',
533            'default' => $user->getRealName(),
534            'section' => 'personal/info',
535            'label-message' => 'yourrealname',
536            'help-message' => 'prefs-help-realname',
537        ];
538
539        if ( $canEditPrivateInfo && $this->authManager->allowsAuthenticationDataChange(
540            new PasswordAuthenticationRequest(), false )->isGood()
541        ) {
542            $defaultPreferences['password'] = [
543                'type' => 'info',
544                'raw' => true,
545                'default' => (string)new ButtonWidget( [
546                    'href' => SpecialPage::getTitleFor( 'ChangePassword' )->getLinkURL( [
547                        'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText()
548                    ] ),
549                    'label' => $context->msg( 'prefs-resetpass' )->text(),
550                ] ),
551                'label-message' => 'yourpassword',
552                // email password reset feature only works for users that have an email set up
553                'help-raw' => $user->getEmail()
554                    ? $context->msg( 'prefs-help-yourpassword',
555                        '[[#mw-prefsection-personal-email|{{int:prefs-email}}]]' )->parse()
556                    : '',
557                'section' => 'personal/accountsecurity',
558            ];
559        }
560        // Only show prefershttps if secure login is turned on
561        if ( !$this->options->get( MainConfigNames::ForceHTTPS )
562            && $this->options->get( MainConfigNames::SecureLogin )
563        ) {
564            $defaultPreferences['prefershttps'] = [
565                'type' => 'toggle',
566                'label-message' => 'tog-prefershttps',
567                'help-message' => 'prefs-help-prefershttps',
568                'section' => 'personal/info'
569            ];
570        }
571
572        $defaultPreferences['downloaduserdata'] = [
573            'type' => 'info',
574            'raw' => true,
575            'label-message' => 'prefs-user-downloaddata-label',
576            'default' => Html::element(
577                'a',
578                [
579                    'href' => $this->options->get( MainConfigNames::ScriptPath ) .
580                        '/api.php?action=query&meta=userinfo&uiprop=*&formatversion=2',
581                ],
582                $context->msg( 'prefs-user-downloaddata-info' )->text()
583            ),
584            'help-message' => [ 'prefs-user-downloaddata-help-message', urlencode( $user->getTitleKey() ) ],
585            'section' => 'personal/info',
586        ];
587
588        $defaultPreferences['restoreprefs'] = [
589            'type' => 'info',
590            'raw' => true,
591            'label-message' => 'prefs-user-restoreprefs-label',
592            'default' => Html::element(
593                'a',
594                [
595                    'href' => SpecialPage::getTitleFor( 'Preferences' )
596                        ->getSubpage( 'reset' )->getLocalURL()
597                ],
598                $context->msg( 'prefs-user-restoreprefs-info' )->text()
599            ),
600            'section' => 'personal/info',
601        ];
602
603        $languages = $this->languageNameUtils->getLanguageNames(
604            LanguageNameUtils::AUTONYMS,
605            LanguageNameUtils::SUPPORTED
606        );
607        $languageCode = $this->options->get( MainConfigNames::LanguageCode );
608        if ( !array_key_exists( $languageCode, $languages ) ) {
609            $languages[$languageCode] = $languageCode;
610            // Sort the array again
611            ksort( $languages );
612        }
613
614        $options = [];
615        foreach ( $languages as $code => $name ) {
616            $options[$code] = $name;
617        }
618
619        $defaultPreferences['language'] = [
620            'type' => 'language',
621            'section' => 'personal/i18n',
622            'useCodex' => true,
623            'label-message' => 'yourlanguage',
624            'languages' => $options,
625        ];
626
627        $neutralGenderMessage = $context->msg( 'gender-notknown' )->escaped() . (
628            !$context->msg( 'gender-unknown' )->isDisabled()
629                ? "<br>" . $context->msg( 'parentheses' )
630                    ->params( $context->msg( 'gender-unknown' )->plain() )
631                    ->escaped()
632                : ''
633        );
634
635        $defaultPreferences['gender'] = [
636            'type' => 'radio',
637            'section' => 'personal/i18n',
638            'options' => [
639                $neutralGenderMessage => 'unknown',
640                $context->msg( 'gender-female' )->escaped() => 'female',
641                $context->msg( 'gender-male' )->escaped() => 'male',
642            ],
643            'label-message' => 'yourgender',
644            'help-message' => 'prefs-help-gender',
645        ];
646
647        // see if there are multiple language variants to choose from
648        if ( !$this->languageConverterFactory->isConversionDisabled() ) {
649
650            foreach ( LanguageConverter::$languagesWithVariants as $langCode ) {
651                if ( $langCode == $this->contLang->getCode() ) {
652                    if ( !$this->languageConverter->hasVariants() ) {
653                        continue;
654                    }
655
656                    $variants = $this->languageConverter->getVariants();
657                    $variantArray = [];
658                    foreach ( $variants as $v ) {
659                        $v = str_replace( '_', '-', strtolower( $v ) );
660                        $variantArray[$v] = $lang->getVariantname( $v, false );
661                    }
662
663                    $options = [];
664                    foreach ( $variantArray as $code => $name ) {
665                        $options[$code] = $name;
666                    }
667
668                    $defaultPreferences['variant'] = [
669                        'label-message' => 'yourvariant',
670                        'type' => 'language',
671                        'section' => 'personal/i18n',
672                        'help-message' => 'prefs-help-variant',
673                        'useCodex' => true,
674                        'languages' => $options,
675                    ];
676                } else {
677                    $defaultPreferences["variant-$langCode"] = [
678                        'type' => 'api',
679                    ];
680                }
681            }
682        }
683
684        // show a preview of the old signature first
685        $oldsigWikiText = $this->parserFactory->getInstance()->preSaveTransform(
686            '~~~',
687            $context->getTitle(),
688            $user,
689            ParserOptions::newFromContext( $context )
690        );
691        $oldsigHTML = Parser::stripOuterParagraph(
692            $context->getOutput()->parseAsContent( $oldsigWikiText )
693        );
694        $signatureFieldConfig = [];
695        // Validate existing signature and show a message about it
696        $signature = $this->userOptionsManager->getOption( $user, 'nickname' );
697        $useFancySig = $this->userOptionsManager->getBoolOption( $user, 'fancysig' );
698        if ( $useFancySig && $signature !== '' ) {
699            $parserOpts = ParserOptions::newFromContext( $context );
700            $validator = $this->signatureValidatorFactory
701                ->newSignatureValidator( $user, $context, $parserOpts );
702            $signatureErrors = $validator->validateSignature( $signature );
703            if ( $signatureErrors ) {
704                $sigValidation = $this->options->get( MainConfigNames::SignatureValidation );
705                $oldsigHTML .= '<p><strong>' .
706                    // Messages used here:
707                    // * prefs-signature-invalid-warning
708                    // * prefs-signature-invalid-new
709                    // * prefs-signature-invalid-disallow
710                    $context->msg( "prefs-signature-invalid-$sigValidation" )->parse() .
711                    '</strong></p>';
712
713                // On initial page load, show the warnings as well
714                // (when posting, you get normal validation errors instead)
715                foreach ( $signatureErrors as &$sigError ) {
716                    $sigError = new HtmlSnippet( $sigError );
717                }
718                if ( !$context->getRequest()->wasPosted() ) {
719                    $signatureFieldConfig = [
720                        'warnings' => $sigValidation !== 'disallow' ? $signatureErrors : null,
721                        'errors' => $sigValidation === 'disallow' ? $signatureErrors : null,
722                    ];
723                }
724            }
725        }
726        $defaultPreferences['oldsig'] = [
727            'type' => 'info',
728            // Normally HTMLFormFields do not display warnings, so we need to use 'rawrow'
729            // and provide the entire OOUI\FieldLayout here
730            'rawrow' => true,
731            'default' => new FieldLayout(
732                new LabelWidget( [
733                    'label' => new HtmlSnippet( $oldsigHTML ),
734                ] ),
735                [
736                    'align' => 'top',
737                    'label' => new HtmlSnippet( $context->msg( 'tog-oldsig' )->parse() )
738                ] + $signatureFieldConfig
739            ),
740            'section' => 'personal/signature',
741        ];
742        $defaultPreferences['nickname'] = [
743            'type' => $this->authManager->allowsPropertyChange( 'nickname' ) ? 'text' : 'info',
744            'maxlength' => $this->options->get( MainConfigNames::MaxSigChars ),
745            'label-message' => 'yournick',
746            'validation-callback' => function ( $signature, $alldata, HTMLForm $form ) {
747                return $this->validateSignature( $signature, $alldata, $form );
748            },
749            'section' => 'personal/signature',
750            'filter-callback' => function ( $signature, array $alldata, HTMLForm $form ) {
751                return $this->cleanSignature( $signature, $alldata, $form );
752            },
753        ];
754        $defaultPreferences['fancysig'] = [
755            'type' => 'toggle',
756            'label-message' => 'tog-fancysig',
757            // show general help about signature at the bottom of the section
758            'help-message' => 'prefs-help-signature',
759            'section' => 'personal/signature'
760        ];
761
762        // Email preferences
763        if ( $this->options->get( MainConfigNames::EnableEmail ) ) {
764            if ( $canViewPrivateInfo ) {
765                $helpMessages = [];
766                $helpMessages[] = $this->options->get( MainConfigNames::EmailConfirmToEdit )
767                        ? 'prefs-help-email-required'
768                        : 'prefs-help-email';
769
770                if ( $this->options->get( MainConfigNames::EnableUserEmail ) ) {
771                    // additional messages when users can send email to each other
772                    $helpMessages[] = 'prefs-help-email-others';
773                }
774
775                $emailAddress = $user->getEmail() ? htmlspecialchars( $user->getEmail() ) : '';
776                if ( $canEditPrivateInfo && $this->authManager->allowsPropertyChange( 'emailaddress' ) ) {
777                    $button = new ButtonWidget( [
778                        'href' => SpecialPage::getTitleFor( 'ChangeEmail' )->getLinkURL( [
779                            'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText()
780                        ] ),
781                        'label' =>
782                            $context->msg( $user->getEmail() ? 'prefs-changeemail' : 'prefs-setemail' )->text(),
783                    ] );
784
785                    $emailAddress .= $emailAddress == '' ? $button : ( '<br />' . $button );
786                }
787
788                $defaultPreferences['emailaddress'] = [
789                    'type' => 'info',
790                    'raw' => true,
791                    'default' => $emailAddress,
792                    'label-message' => 'youremail',
793                    'section' => 'personal/email',
794                    'help-messages' => $helpMessages,
795                    // 'cssclass' chosen below
796                ];
797            }
798
799            $disableEmailPrefs = false;
800
801            $defaultPreferences['requireemail'] = [
802                'type' => 'toggle',
803                'label-message' => 'tog-requireemail',
804                'help-message' => 'prefs-help-requireemail',
805                'section' => 'personal/email',
806                'disabled' => !$user->getEmail(),
807            ];
808
809            if ( $this->options->get( MainConfigNames::EmailAuthentication ) ) {
810                if ( $user->getEmail() ) {
811                    if ( $user->getEmailAuthenticationTimestamp() ) {
812                        // date and time are separate parameters to facilitate localisation.
813                        // $time is kept for backward compat reasons.
814                        // 'emailauthenticated' is also used in SpecialConfirmemail.php
815                        $displayUser = $context->getUser();
816                        $emailTimestamp = $user->getEmailAuthenticationTimestamp();
817                        $time = $lang->userTimeAndDate( $emailTimestamp, $displayUser );
818                        $d = $lang->userDate( $emailTimestamp, $displayUser );
819                        $t = $lang->userTime( $emailTimestamp, $displayUser );
820                        $emailauthenticated = $context->msg( 'emailauthenticated',
821                            $time, $d, $t )->parse() . '<br />';
822                        $emailauthenticationclass = 'mw-email-authenticated';
823                    } else {
824                        $disableEmailPrefs = true;
825                        $emailauthenticated = $context->msg( 'emailnotauthenticated' )->parse() . '<br />' .
826                            new ButtonWidget( [
827                                'href' => SpecialPage::getTitleFor( 'Confirmemail' )->getLinkURL(),
828                                'label' => $context->msg( 'emailconfirmlink' )->text(),
829                            ] );
830                        $emailauthenticationclass = "mw-email-not-authenticated";
831                    }
832                } else {
833                    $disableEmailPrefs = true;
834                    $emailauthenticated = $context->msg( 'noemailprefs' )->escaped();
835                    $emailauthenticationclass = 'mw-email-none';
836                }
837
838                if ( $canViewPrivateInfo ) {
839                    $defaultPreferences['emailauthentication'] = [
840                        'type' => 'info',
841                        'raw' => true,
842                        'section' => 'personal/email',
843                        'label-message' => 'prefs-emailconfirm-label',
844                        'default' => $emailauthenticated,
845                        // Apply the same CSS class used on the input to the message:
846                        'cssclass' => $emailauthenticationclass,
847                    ];
848                }
849            }
850
851            if ( $this->options->get( MainConfigNames::EnableUserEmail ) &&
852                $user->isAllowed( 'sendemail' )
853            ) {
854                $defaultPreferences['disablemail'] = [
855                    'id' => 'wpAllowEmail',
856                    'type' => 'toggle',
857                    'invert' => true,
858                    'section' => 'personal/email',
859                    'label-message' => 'allowemail',
860                    'disabled' => $disableEmailPrefs,
861                ];
862
863                $defaultPreferences['email-allow-new-users'] = [
864                    'id' => 'wpAllowEmailFromNewUsers',
865                    'type' => 'toggle',
866                    'section' => 'personal/email',
867                    'label-message' => 'email-allow-new-users-label',
868                    'help-message' => 'prefs-help-email-allow-new-users',
869                    'disabled' => $disableEmailPrefs,
870                    'disable-if' => [ '!==', 'disablemail', '1' ],
871                ];
872
873                $defaultPreferences['ccmeonemails'] = [
874                    'type' => 'toggle',
875                    'section' => 'personal/email',
876                    'label-message' => 'tog-ccmeonemails',
877                    'disabled' => $disableEmailPrefs,
878                ];
879
880                $defaultPreferences['email-blacklist'] = [
881                    'type' => 'usersmultiselect',
882                    'label-message' => 'email-mutelist-label',
883                    'section' => 'personal/email',
884                    'disabled' => $disableEmailPrefs,
885                    'filter' => MultiUsernameFilter::class,
886                    'excludetemp' => true,
887                ];
888            }
889
890            if ( $this->options->get( MainConfigNames::EnotifWatchlist ) ) {
891                $defaultPreferences['enotifwatchlistpages'] = [
892                    'type' => 'toggle',
893                    'section' => 'personal/email',
894                    'label-message' => 'tog-enotifwatchlistpages',
895                    'disabled' => $disableEmailPrefs,
896                ];
897            }
898            if ( $this->options->get( MainConfigNames::EnotifUserTalk ) ) {
899                $defaultPreferences['enotifusertalkpages'] = [
900                    'type' => 'toggle',
901                    'section' => 'personal/email',
902                    'label-message' => 'tog-enotifusertalkpages',
903                    'disabled' => $disableEmailPrefs,
904                ];
905            }
906            if ( $this->options->get( MainConfigNames::EnotifUserTalk ) ||
907            $this->options->get( MainConfigNames::EnotifWatchlist ) ) {
908                if ( $this->options->get( MainConfigNames::EnotifMinorEdits ) ) {
909                    $defaultPreferences['enotifminoredits'] = [
910                        'type' => 'toggle',
911                        'section' => 'personal/email',
912                        'label-message' => 'tog-enotifminoredits',
913                        'disabled' => $disableEmailPrefs,
914                    ];
915                }
916
917                if ( $this->options->get( MainConfigNames::EnotifRevealEditorAddress ) ) {
918                    $defaultPreferences['enotifrevealaddr'] = [
919                        'type' => 'toggle',
920                        'section' => 'personal/email',
921                        'label-message' => 'tog-enotifrevealaddr',
922                        'disabled' => $disableEmailPrefs,
923                    ];
924                }
925            }
926        }
927    }
928
929    /**
930     * @param User $user
931     * @param IContextSource $context
932     * @param array &$defaultPreferences
933     * @return void
934     */
935    protected function skinPreferences( User $user, IContextSource $context, &$defaultPreferences ) {
936        // Skin selector, if there is at least one valid skin
937        $validSkinNames = $this->getValidSkinNames( $user, $context );
938        if ( $validSkinNames ) {
939            $defaultPreferences['skin'] = [
940                // @phan-suppress-next-line SecurityCheck-XSS False +ve, label is escaped in generateSkinOptions()
941                'type' => 'radio',
942                'options' => $this->generateSkinOptions( $user, $context, $validSkinNames ),
943                'section' => 'rendering/skin',
944            ];
945            $hideCond = [ 'AND' ];
946            foreach ( $validSkinNames as $skinName => $_ ) {
947                $options = $this->skinFactory->getSkinOptions( $skinName );
948                if ( $options['responsive'] ?? false ) {
949                    $hideCond[] = [ '!==', 'skin', $skinName ];
950                }
951            }
952            if ( $hideCond === [ 'AND' ] ) {
953                $hideCond = [];
954            }
955            $defaultPreferences['skin-responsive'] = [
956                'type' => 'check',
957                'label-message' => 'prefs-skin-responsive',
958                'section' => 'rendering/skin/skin-prefs',
959                'help-message' => 'prefs-help-skin-responsive',
960                'hide-if' => $hideCond,
961            ];
962        }
963
964        $allowUserCss = $this->options->get( MainConfigNames::AllowUserCss );
965        $allowUserJs = $this->options->get( MainConfigNames::AllowUserJs );
966        $safeMode = $this->userOptionsManager->getOption( $user, 'forcesafemode' );
967        // Create links to user CSS/JS pages for all skins.
968        // This code is basically copied from generateSkinOptions().
969        // @todo Refactor this and the similar code in generateSkinOptions().
970        if ( $allowUserCss || $allowUserJs ) {
971            if ( $safeMode ) {
972                $defaultPreferences['customcssjs-safemode'] = [
973                    'type' => 'info',
974                    'raw' => true,
975                    'rawrow' => true,
976                    'section' => 'rendering/skin',
977                    'default' => new FieldLayout(
978                        new MessageWidget( [
979                            'label' => new HtmlSnippet( $context->msg( 'prefs-custom-cssjs-safemode' )->parse() ),
980                            'type' => 'warning',
981                        ] )
982                    ),
983                ];
984            } else {
985                $linkTools = [];
986                $userName = $user->getName();
987
988                if ( $allowUserCss ) {
989                    $cssPage = Title::makeTitleSafe( NS_USER, $userName . '/common.css' );
990                    $cssLinkText = $context->msg( 'prefs-custom-css' )->text();
991                    $linkTools[] = $this->linkRenderer->makeLink( $cssPage, $cssLinkText );
992                }
993
994                if ( $allowUserJs ) {
995                    $jsPage = Title::makeTitleSafe( NS_USER, $userName . '/common.js' );
996                    $jsLinkText = $context->msg( 'prefs-custom-js' )->text();
997                    $linkTools[] = $this->linkRenderer->makeLink( $jsPage, $jsLinkText );
998                }
999
1000                $defaultPreferences['commoncssjs'] = [
1001                    'type' => 'info',
1002                    'raw' => true,
1003                    'default' => $context->getLanguage()->pipeList( $linkTools ),
1004                    'label-message' => 'prefs-common-config',
1005                    'section' => 'rendering/skin',
1006                ];
1007            }
1008        }
1009    }
1010
1011    /**
1012     * @param IContextSource $context
1013     * @param array &$defaultPreferences
1014     */
1015    protected function filesPreferences( IContextSource $context, &$defaultPreferences ) {
1016        $defaultPreferences['imagesize'] = [
1017            'type' => 'select',
1018            'options' => $this->getImageSizes( $context ),
1019            'label-message' => 'imagemaxsize',
1020            'section' => 'rendering/files',
1021        ];
1022        $sizes = self::getNormalizedThumbSizes(
1023            $this->options->get( MainConfigNames::ThumbLimits ),
1024            $this->userOptionsManager->getDefaultOptions()
1025        );
1026        $defaultPreferences['thumbsize'] = [
1027            'type' => 'select',
1028            'options' => $this->getThumbSizes( $context ),
1029            'label-message' => 'thumbsize',
1030            'help' => $context->msg( 'thumbsize-help' )->params( $sizes[ 1 ] ),
1031            'section' => 'rendering/files',
1032        ];
1033        $user = $context->getUser();
1034        $limits = $this->options->get( MainConfigNames::ThumbLimits );
1035        $currentSize = $limits[ $this->userOptionsManager->getOption( $user, 'thumbsize' ) ];
1036        // Correct legacy values in next save (can be removed once T376152 is resolved)
1037        if ( !in_array( $currentSize, $sizes ) ) {
1038            // using a non-standard preference so map to the closest.
1039            $closestSize = $limits[1];
1040            if ( $currentSize < $limits[0] ) {
1041                $closestSize = $limits[0];
1042            } elseif ( $currentSize > $limits[1] ) {
1043                $closestSize = $limits[2];
1044            }
1045            $defaultPreferences['thumbsize']['default'] = array_search( $closestSize, $limits );
1046        }
1047    }
1048
1049    /**
1050     * @param User $user
1051     * @param IContextSource $context
1052     * @param array &$defaultPreferences
1053     * @return void
1054     */
1055    protected function datetimePreferences(
1056        User $user, IContextSource $context, &$defaultPreferences
1057    ) {
1058        $dateOptions = $this->getDateOptions( $context );
1059        if ( $dateOptions ) {
1060            $defaultPreferences['date'] = [
1061                'type' => 'radio',
1062                'options' => $dateOptions,
1063                'section' => 'rendering/dateformat',
1064            ];
1065        }
1066
1067        // Info
1068        $now = wfTimestampNow();
1069        $lang = $context->getLanguage();
1070        $nowlocal = Html::element( 'span', [ 'id' => 'wpLocalTime' ],
1071            $lang->userTime( $now, $user ) );
1072        $nowserver = $lang->userTime( $now, $user,
1073                [ 'format' => false, 'timecorrection' => false ] ) .
1074            Html::hidden( 'wpServerTime', (int)substr( $now, 8, 2 ) * 60 + (int)substr( $now, 10, 2 ) );
1075
1076        $defaultPreferences['nowserver'] = [
1077            'type' => 'info',
1078            'raw' => 1,
1079            'label-message' => 'servertime',
1080            'default' => $nowserver,
1081            'section' => 'rendering/timeoffset',
1082        ];
1083
1084        $defaultPreferences['nowlocal'] = [
1085            'type' => 'info',
1086            'raw' => 1,
1087            'label-message' => 'localtime',
1088            'default' => $nowlocal,
1089            'section' => 'rendering/timeoffset',
1090        ];
1091
1092        $userTimeCorrection = (string)$this->userOptionsManager->getOption( $user, 'timecorrection' );
1093        // This value should already be normalized by UserTimeCorrection, so it should always be valid and not
1094        // in the legacy format. However, let's be sure about that and normalize it again.
1095        // Also, recompute the offset because it can change with DST.
1096        $userTimeCorrectionObj = new UserTimeCorrection(
1097            $userTimeCorrection,
1098            null,
1099            $this->options->get( MainConfigNames::LocalTZoffset )
1100        );
1101
1102        if ( $userTimeCorrectionObj->getCorrectionType() === UserTimeCorrection::OFFSET ) {
1103            $tzDefault = UserTimeCorrection::formatTimezoneOffset( $userTimeCorrectionObj->getTimeOffset() );
1104        } else {
1105            $tzDefault = $userTimeCorrectionObj->toString();
1106        }
1107
1108        $defaultPreferences['timecorrection'] = [
1109            'type' => 'timezone',
1110            'label-message' => 'timezonelegend',
1111            'default' => $tzDefault,
1112            'size' => 20,
1113            'section' => 'rendering/timeoffset',
1114            'id' => 'wpTimeCorrection',
1115            'filter' => TimezoneFilter::class,
1116        ];
1117    }
1118
1119    /**
1120     * @param User $user
1121     * @param MessageLocalizer $l10n
1122     * @param array &$defaultPreferences
1123     */
1124    protected function renderingPreferences(
1125        User $user,
1126        MessageLocalizer $l10n,
1127        &$defaultPreferences
1128    ) {
1129        // Diffs
1130        $defaultPreferences['diffonly'] = [
1131            'type' => 'toggle',
1132            'section' => 'rendering/diffs',
1133            'label-message' => 'tog-diffonly',
1134        ];
1135        $defaultPreferences['norollbackdiff'] = [
1136            'type' => 'toggle',
1137            'section' => 'rendering/diffs',
1138            'label-message' => 'tog-norollbackdiff',
1139        ];
1140        $defaultPreferences['diff-type'] = [
1141            'type' => 'api',
1142        ];
1143
1144        // Page Rendering
1145        if ( $this->options->get( MainConfigNames::AllowUserCssPrefs ) ) {
1146            $defaultPreferences['underline'] = [
1147                'type' => 'select',
1148                'options' => [
1149                    $l10n->msg( 'underline-never' )->text() => 0,
1150                    $l10n->msg( 'underline-always' )->text() => 1,
1151                    $l10n->msg( 'underline-default' )->text() => 2,
1152                ],
1153                'label-message' => 'tog-underline',
1154                'section' => 'rendering/advancedrendering',
1155            ];
1156        }
1157
1158        $defaultPreferences['showhiddencats'] = [
1159            'type' => 'toggle',
1160            'section' => 'rendering/advancedrendering',
1161            'label-message' => 'tog-showhiddencats'
1162        ];
1163
1164        if ( $user->isAllowed( 'rollback' ) ) {
1165            $defaultPreferences['showrollbackconfirmation'] = [
1166                'type' => 'toggle',
1167                'section' => 'rendering/advancedrendering',
1168                'label-message' => 'tog-showrollbackconfirmation',
1169            ];
1170        }
1171
1172        $defaultPreferences['forcesafemode'] = [
1173            'type' => 'toggle',
1174            'section' => 'rendering/advancedrendering',
1175            'label-message' => 'tog-forcesafemode',
1176            'help-message' => 'prefs-help-forcesafemode'
1177        ];
1178    }
1179
1180    /**
1181     * @param User $user
1182     * @param MessageLocalizer $l10n
1183     * @param array &$defaultPreferences
1184     */
1185    protected function editingPreferences( User $user, MessageLocalizer $l10n, &$defaultPreferences ) {
1186        $defaultPreferences['editsectiononrightclick'] = [
1187            'type' => 'toggle',
1188            'section' => 'editing/advancedediting',
1189            'label-message' => 'tog-editsectiononrightclick',
1190        ];
1191        $defaultPreferences['editondblclick'] = [
1192            'type' => 'toggle',
1193            'section' => 'editing/advancedediting',
1194            'label-message' => 'tog-editondblclick',
1195        ];
1196
1197        if ( $this->options->get( MainConfigNames::AllowUserCssPrefs ) ) {
1198            $defaultPreferences['editfont'] = [
1199                'type' => 'select',
1200                'section' => 'editing/editor',
1201                'label-message' => 'editfont-style',
1202                'options' => [
1203                    $l10n->msg( 'editfont-monospace' )->text() => 'monospace',
1204                    $l10n->msg( 'editfont-sansserif' )->text() => 'sans-serif',
1205                    $l10n->msg( 'editfont-serif' )->text() => 'serif',
1206                ]
1207            ];
1208        }
1209
1210        if ( $user->isAllowed( 'minoredit' ) ) {
1211            $defaultPreferences['minordefault'] = [
1212                'type' => 'toggle',
1213                'section' => 'editing/editor',
1214                'label-message' => 'tog-minordefault',
1215            ];
1216        }
1217
1218        $defaultPreferences['forceeditsummary'] = [
1219            'type' => 'toggle',
1220            'section' => 'editing/editor',
1221            'label-message' => 'tog-forceeditsummary',
1222        ];
1223
1224        // T350653
1225        if ( $this->options->get( MainConfigNames::EnableEditRecovery ) ) {
1226            $defaultPreferences['editrecovery'] = [
1227                'type' => 'toggle',
1228                'section' => 'editing/editor',
1229                'label-message' => 'tog-editrecovery',
1230                'help-message' => [
1231                    'tog-editrecovery-help',
1232                    'https://meta.wikimedia.org/wiki/Talk:Community_Wishlist_Survey_2023/Edit-recovery_feature',
1233                ],
1234            ];
1235        }
1236
1237        $defaultPreferences['useeditwarning'] = [
1238            'type' => 'toggle',
1239            'section' => 'editing/editor',
1240            'label-message' => 'tog-useeditwarning',
1241        ];
1242
1243        $defaultPreferences['previewonfirst'] = [
1244            'type' => 'toggle',
1245            'section' => 'editing/preview',
1246            'label-message' => 'tog-previewonfirst',
1247        ];
1248        $defaultPreferences['previewontop'] = [
1249            'type' => 'toggle',
1250            'section' => 'editing/preview',
1251            'label-message' => 'tog-previewontop',
1252        ];
1253        $defaultPreferences['uselivepreview'] = [
1254            'type' => 'toggle',
1255            'section' => 'editing/preview',
1256            'label-message' => 'tog-uselivepreview',
1257        ];
1258    }
1259
1260    /**
1261     * @param User $user
1262     * @param MessageLocalizer $l10n
1263     * @param array &$defaultPreferences
1264     */
1265    protected function rcPreferences( User $user, MessageLocalizer $l10n, &$defaultPreferences ) {
1266        $rcMaxAge = $this->options->get( MainConfigNames::RCMaxAge );
1267        $rcMax = ceil( $rcMaxAge / ( 3600 * 24 ) );
1268        $defaultPreferences['rcdays'] = [
1269            'type' => 'float',
1270            'label-message' => 'recentchangesdays',
1271            'section' => 'rc/displayrc',
1272            'min' => 1 / 24,
1273            'max' => $rcMax,
1274            'help-message' => [ 'recentchangesdays-max', Message::numParam( $rcMax ) ],
1275        ];
1276        $defaultPreferences['rclimit'] = [
1277            'type' => 'int',
1278            'min' => 1,
1279            'max' => 1000,
1280            'label-message' => 'recentchangescount',
1281            'help-message' => 'prefs-help-recentchangescount',
1282            'section' => 'rc/displayrc',
1283            'filter' => IntvalFilter::class,
1284        ];
1285        $defaultPreferences['usenewrc'] = [
1286            'type' => 'toggle',
1287            'label-message' => 'tog-usenewrc',
1288            'section' => 'rc/advancedrc',
1289        ];
1290        $defaultPreferences['hideminor'] = [
1291            'type' => 'toggle',
1292            'label-message' => 'tog-hideminor',
1293            'section' => 'rc/changesrc',
1294        ];
1295        $defaultPreferences['pst-cssjs'] = [
1296            'type' => 'api',
1297        ];
1298        $defaultPreferences['rcfilters-rc-collapsed'] = [
1299            'type' => 'api',
1300        ];
1301        $defaultPreferences['rcfilters-wl-collapsed'] = [
1302            'type' => 'api',
1303        ];
1304        $defaultPreferences['rcfilters-saved-queries'] = [
1305            'type' => 'api',
1306        ];
1307        $defaultPreferences['rcfilters-wl-saved-queries'] = [
1308            'type' => 'api',
1309        ];
1310        // Override RCFilters preferences for RecentChanges 'limit'
1311        $defaultPreferences['rcfilters-limit'] = [
1312            'type' => 'api',
1313        ];
1314        $defaultPreferences['rcfilters-saved-queries-versionbackup'] = [
1315            'type' => 'api',
1316        ];
1317        $defaultPreferences['rcfilters-wl-saved-queries-versionbackup'] = [
1318            'type' => 'api',
1319        ];
1320
1321        if ( $this->options->get( MainConfigNames::RCWatchCategoryMembership ) ) {
1322            $defaultPreferences['hidecategorization'] = [
1323                'type' => 'toggle',
1324                'label-message' => 'tog-hidecategorization',
1325                'section' => 'rc/changesrc',
1326            ];
1327        }
1328
1329        if ( $user->useRCPatrol() ) {
1330            $defaultPreferences['hidepatrolled'] = [
1331                'type' => 'toggle',
1332                'section' => 'rc/changesrc',
1333                'label-message' => 'tog-hidepatrolled',
1334            ];
1335        }
1336
1337        if ( $user->useNPPatrol() ) {
1338            $defaultPreferences['newpageshidepatrolled'] = [
1339                'type' => 'toggle',
1340                'section' => 'rc/changesrc',
1341                'label-message' => 'tog-newpageshidepatrolled',
1342            ];
1343        }
1344
1345        if ( $this->options->get( MainConfigNames::RCShowWatchingUsers ) ) {
1346            $defaultPreferences['shownumberswatching'] = [
1347                'type' => 'toggle',
1348                'section' => 'rc/advancedrc',
1349                'label-message' => 'tog-shownumberswatching',
1350            ];
1351        }
1352
1353        $defaultPreferences['rcenhancedfilters-disable'] = [
1354            'type' => 'toggle',
1355            'section' => 'rc/advancedrc',
1356            'label-message' => 'rcfilters-preference-label',
1357            'help-message' => 'rcfilters-preference-help',
1358        ];
1359    }
1360
1361    /**
1362     * @param User $user
1363     * @param IContextSource $context
1364     * @param array &$defaultPreferences
1365     */
1366    protected function watchlistPreferences(
1367        User $user, IContextSource $context, &$defaultPreferences
1368    ) {
1369        $watchlistdaysMax = ceil( $this->options->get( MainConfigNames::RCMaxAge ) / ( 3600 * 24 ) );
1370
1371        if ( $user->isAllowed( 'editmywatchlist' ) ) {
1372            $editWatchlistLinks = [];
1373            $editWatchlistModes = [
1374                'edit' => [ 'subpage' => false, 'flags' => [] ],
1375                'raw' => [ 'subpage' => 'raw', 'flags' => [] ],
1376                'clear' => [ 'subpage' => 'clear', 'flags' => [] ],
1377            ];
1378            foreach ( $editWatchlistModes as $mode => $options ) {
1379                // Messages: prefs-editwatchlist-edit, prefs-editwatchlist-raw, prefs-editwatchlist-clear
1380                $editWatchlistLinks[] =
1381                    new ButtonWidget( [
1382                        'href' => SpecialPage::getTitleFor( 'EditWatchlist', $options['subpage'] )->getLinkURL(),
1383                        'flags' => $options[ 'flags' ],
1384                        'label' => new HtmlSnippet(
1385                            $context->msg( "prefs-editwatchlist-{$mode}" )->parse()
1386                        ),
1387                    ] );
1388            }
1389
1390            $defaultPreferences['editwatchlist'] = [
1391                'type' => 'info',
1392                'raw' => true,
1393                // Improve button spacing when they are wrapped (T353365)
1394                'default' => (string)new HorizontalLayout( [ 'items' => $editWatchlistLinks ] ),
1395                'label-message' => 'prefs-editwatchlist-label',
1396                'section' => 'watchlist/editwatchlist',
1397            ];
1398
1399            // If watchlist labels are enabled, show WatchlistLabels button
1400            if ( $this->options->get( MainConfigNames::EnableWatchlistLabels ) ) {
1401                $watchlistLabelsLink = new ButtonWidget( [
1402                    'href' => SpecialPage::getTitleFor( 'WatchlistLabels' )->getLinkURL(),
1403                    'label' => new HtmlSnippet(
1404                        $context->msg( 'prefs-managewatchlistlabels' )->parse()
1405                    ),
1406                ] );
1407
1408                $defaultPreferences['editwatchlistlabels'] = [
1409                    'type' => 'info',
1410                    'raw' => true,
1411                    'default' => (string)$watchlistLabelsLink,
1412                    'label-message' => 'prefs-editwatchlistlabels-label',
1413                    'section' => 'watchlist/editwatchlist',
1414                ];
1415            }
1416
1417        }
1418
1419        $defaultPreferences['watchlistdays'] = [
1420            'type' => 'float',
1421            'min' => 1 / 24,
1422            'max' => $watchlistdaysMax,
1423            'section' => 'watchlist/displaywatchlist',
1424            'help-message' => [ 'prefs-watchlist-days-max', Message::numParam( $watchlistdaysMax ) ],
1425            'label-message' => 'prefs-watchlist-days',
1426        ];
1427        $defaultPreferences['wllimit'] = [
1428            'type' => 'int',
1429            'min' => 1,
1430            'max' => 1000,
1431            'label-message' => 'prefs-watchlist-edits',
1432            'help-message' => 'prefs-watchlist-edits-max',
1433            'section' => 'watchlist/displaywatchlist',
1434            'filter' => IntvalFilter::class,
1435        ];
1436        $defaultPreferences['extendwatchlist'] = [
1437            'type' => 'toggle',
1438            'section' => 'watchlist/advancedwatchlist',
1439            'label-message' => 'tog-extendwatchlist',
1440        ];
1441        $defaultPreferences['watchlisthideminor'] = [
1442            'type' => 'toggle',
1443            'section' => 'watchlist/changeswatchlist',
1444            'label-message' => 'tog-watchlisthideminor',
1445        ];
1446        $defaultPreferences['watchlisthidebots'] = [
1447            'type' => 'toggle',
1448            'section' => 'watchlist/changeswatchlist',
1449            'label-message' => 'tog-watchlisthidebots',
1450        ];
1451        $defaultPreferences['watchlisthideown'] = [
1452            'type' => 'toggle',
1453            'section' => 'watchlist/changeswatchlist',
1454            'label-message' => 'tog-watchlisthideown',
1455        ];
1456        $defaultPreferences['watchlisthideanons'] = [
1457            'type' => 'toggle',
1458            'section' => 'watchlist/changeswatchlist',
1459            'label-message' => 'tog-watchlisthideanons',
1460        ];
1461        $defaultPreferences['watchlisthideliu'] = [
1462            'type' => 'toggle',
1463            'section' => 'watchlist/changeswatchlist',
1464            'label-message' => 'tog-watchlisthideliu',
1465        ];
1466
1467        if ( !SpecialWatchlist::checkStructuredFilterUiEnabled( $user ) ) {
1468            $defaultPreferences['watchlistreloadautomatically'] = [
1469                'type' => 'toggle',
1470                'section' => 'watchlist/advancedwatchlist',
1471                'label-message' => 'tog-watchlistreloadautomatically',
1472            ];
1473        }
1474
1475        $defaultPreferences['watchlistunwatchlinks'] = [
1476            'type' => 'toggle',
1477            'section' => 'watchlist/advancedwatchlist',
1478            'label-message' => 'tog-watchlistunwatchlinks',
1479        ];
1480
1481        if ( $this->options->get( MainConfigNames::RCWatchCategoryMembership ) ) {
1482            $defaultPreferences['watchlisthidecategorization'] = [
1483                'type' => 'toggle',
1484                'section' => 'watchlist/changeswatchlist',
1485                'label-message' => 'tog-watchlisthidecategorization',
1486            ];
1487        }
1488
1489        if ( $user->useRCPatrol() ) {
1490            $defaultPreferences['watchlisthidepatrolled'] = [
1491                'type' => 'toggle',
1492                'section' => 'watchlist/changeswatchlist',
1493                'label-message' => 'tog-watchlisthidepatrolled',
1494            ];
1495        }
1496
1497        if ( $this->options->get( MainConfigNames::WatchlistExpiry ) ) {
1498            $defaultPreferences["watchstar-expiry"] = [
1499                'type' => 'select',
1500                'options' => WatchAction::getExpiryOptionsFromMessage( $context ),
1501                'label-message' => "tog-watchstar-expiry",
1502                'section' => 'watchlist/pageswatchlist',
1503            ];
1504        }
1505
1506        $watchTypes = [
1507            'edit' => 'watchdefault',
1508            'move' => 'watchmoves',
1509        ];
1510
1511        // Kinda hacky
1512        if ( $user->isAllowedAny( 'createpage', 'createtalk' ) ) {
1513            $watchTypes['read'] = 'watchcreations';
1514        }
1515
1516        // Move uncommon actions to end of list
1517        $watchTypes += [
1518            'rollback' => 'watchrollback',
1519            'upload' => 'watchuploads',
1520            'delete' => 'watchdeletion',
1521        ];
1522
1523        foreach ( $watchTypes as $action => $pref ) {
1524            if ( $user->isAllowed( $action ) ) {
1525                // Messages:
1526                // tog-watchdefault, tog-watchmoves, tog-watchdeletion,
1527                // tog-watchcreations, tog-watchuploads, tog-watchrollback
1528                $defaultPreferences[$pref] = [
1529                    'type' => 'toggle',
1530                    'section' => 'watchlist/pageswatchlist',
1531                    'label-message' => "tog-$pref",
1532                ];
1533
1534                if ( in_array( $action, [ 'edit', 'read', 'rollback' ] ) &&
1535                    $this->options->get( MainConfigNames::WatchlistExpiry )
1536                ) {
1537                    $defaultPreferences["$pref-expiry"] = [
1538                        'type' => 'select',
1539                        'options' => WatchAction::getExpiryOptionsFromMessage( $context ),
1540                        'label-message' => "tog-watch-expiry",
1541                        'section' => 'watchlist/pageswatchlist',
1542                        'hide-if' => [ '!==', $pref, '1' ],
1543                        'cssclass' => 'mw-prefs-indent',
1544                    ];
1545                }
1546            }
1547        }
1548
1549        $defaultPreferences['watchlisttoken'] = [
1550            'type' => 'api',
1551        ];
1552
1553        // T408235
1554        $defaultPreferences['watchlistlabelonboarding'] = [
1555            'type' => 'api',
1556        ];
1557
1558        $tokenButton = new ButtonWidget( [
1559            'href' => SpecialPage::getTitleFor( 'ResetTokens' )->getLinkURL( [
1560                'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText()
1561            ] ),
1562            'label' => $context->msg( 'prefs-watchlist-managetokens' )->text(),
1563        ] );
1564        $defaultPreferences['watchlisttoken-info'] = [
1565            'type' => 'info',
1566            'section' => 'watchlist/tokenwatchlist',
1567            'label-message' => 'prefs-watchlist-token',
1568            'help-message' => 'prefs-help-tokenmanagement',
1569            'raw' => true,
1570            'default' => (string)$tokenButton,
1571        ];
1572
1573        $defaultPreferences['wlenhancedfilters-disable'] = [
1574            'type' => 'toggle',
1575            'section' => 'watchlist/advancedwatchlist',
1576            'label-message' => 'rcfilters-watchlist-preference-label',
1577            'help-message' => 'rcfilters-watchlist-preference-help',
1578        ];
1579    }
1580
1581    /**
1582     * @param IContextSource $context
1583     * @param array &$defaultPreferences
1584     */
1585    protected function searchPreferences( $context, &$defaultPreferences ) {
1586        $defaultPreferences['search-special-page'] = [
1587            'type' => 'api',
1588        ];
1589
1590        foreach ( $this->nsInfo->getValidNamespaces() as $n ) {
1591            $defaultPreferences['searchNs' . $n] = [
1592                'type' => 'api',
1593            ];
1594        }
1595
1596        if ( $this->options->get( MainConfigNames::SearchMatchRedirectPreference ) ) {
1597            $defaultPreferences['search-match-redirect'] = [
1598                'type' => 'toggle',
1599                'section' => 'searchoptions/searchmisc',
1600                'label-message' => 'search-match-redirect-label',
1601                'help-message' => 'search-match-redirect-help',
1602            ];
1603        } else {
1604            $defaultPreferences['search-match-redirect'] = [
1605                'type' => 'api',
1606            ];
1607        }
1608
1609        $defaultPreferences['searchlimit'] = [
1610            'type' => 'int',
1611            'min' => 1,
1612            'max' => 500,
1613            'section' => 'searchoptions/searchmisc',
1614            'label-message' => 'searchlimit-label',
1615            'help-message' => $context->msg( 'searchlimit-help', 500 ),
1616            'filter' => IntvalFilter::class,
1617        ];
1618
1619        // show a preference for thumbnails from namespaces other than NS_FILE,
1620        // only when there they're actually configured to be served
1621        $thumbNamespaces = $this->options->get( MainConfigNames::ThumbnailNamespaces );
1622        $thumbNamespacesFormatted = array_combine(
1623            $thumbNamespaces,
1624            array_map(
1625                static function ( $namespaceId ) use ( $context ) {
1626                    return $namespaceId === NS_MAIN
1627                        ? $context->msg( 'blanknamespace' )->escaped()
1628                        : $context->getLanguage()->getFormattedNsText( $namespaceId );
1629                },
1630                $thumbNamespaces
1631            )
1632        );
1633        $defaultThumbNamespacesFormatted =
1634            array_intersect_key( $thumbNamespacesFormatted, [ NS_FILE => 1 ] ) ?? [];
1635        $extraThumbNamespacesFormatted =
1636            array_diff_key( $thumbNamespacesFormatted, [ NS_FILE => 1 ] );
1637        if ( $extraThumbNamespacesFormatted ) {
1638            $defaultPreferences['search-thumbnail-extra-namespaces'] = [
1639                'type' => 'toggle',
1640                'section' => 'searchoptions/searchmisc',
1641                'label-message' => 'search-thumbnail-extra-namespaces-label',
1642                'help-message' => $context->msg(
1643                    'search-thumbnail-extra-namespaces-message',
1644                    Message::listParam( $extraThumbNamespacesFormatted ),
1645                    count( $extraThumbNamespacesFormatted ),
1646                    Message::listParam( $defaultThumbNamespacesFormatted ),
1647                    count( $defaultThumbNamespacesFormatted )
1648                ),
1649            ];
1650        }
1651    }
1652
1653    /**
1654     * Custom skin string comparison function that takes into account current and preferred skins.
1655     *
1656     * @param string $a
1657     * @param string $b
1658     * @param string $currentSkin
1659     * @param array $preferredSkins
1660     * @return int
1661     */
1662    private static function sortSkinNames( $a, $b, $currentSkin, $preferredSkins ) {
1663        // Display the current skin first in the list
1664        if ( strcasecmp( $a, $currentSkin ) === 0 ) {
1665            return -1;
1666        }
1667        if ( strcasecmp( $b, $currentSkin ) === 0 ) {
1668            return 1;
1669        }
1670        // Display preferred skins over other skins
1671        if ( count( $preferredSkins ) ) {
1672            $aPreferred = array_search( $a, $preferredSkins );
1673            $bPreferred = array_search( $b, $preferredSkins );
1674            // Cannot use ! operator because array_search returns the
1675            // index of the array item if found (i.e. 0) and false otherwise
1676            if ( $aPreferred !== false && $bPreferred === false ) {
1677                return -1;
1678            }
1679            if ( $aPreferred === false && $bPreferred !== false ) {
1680                return 1;
1681            }
1682            // When both skins are preferred, default to the ordering
1683            // specified by the preferred skins config array
1684            if ( $aPreferred !== false && $bPreferred !== false ) {
1685                return strcasecmp( $aPreferred, $bPreferred );
1686            }
1687        }
1688        // Use normal string comparison if both strings are not preferred
1689        return strcasecmp( $a, $b );
1690    }
1691
1692    /**
1693     * Gat valid skin names for the given user, which the 'useskin' query string and user
1694     * options should be taken into account.
1695     *
1696     * @param User $user
1697     * @param IContextSource $context
1698     * @return array Associative array in the format of [ 'skin name' => 'display name' ].
1699     */
1700    private function getValidSkinNames( User $user, IContextSource $context ) {
1701        // Only show skins that aren't disabled
1702        $validSkinNames = $this->skinFactory->getAllowedSkins();
1703        $allInstalledSkins = $this->skinFactory->getInstalledSkins();
1704
1705        // Display the installed skin the user has specifically requested via useskin=….
1706        $useSkin = $context->getRequest()->getRawVal( 'useskin' );
1707        if ( $useSkin !== null && isset( $allInstalledSkins[$useSkin] )
1708            && $context->msg( "skinname-$useSkin" )->exists()
1709        ) {
1710            $validSkinNames[$useSkin] = $useSkin;
1711        }
1712
1713        // Display the skin if the user has set it as a preference already before it was hidden.
1714        $currentUserSkin = $this->userOptionsManager->getOption( $user, 'skin' );
1715        if ( isset( $allInstalledSkins[$currentUserSkin] )
1716            && $context->msg( "skinname-$currentUserSkin" )->exists()
1717        ) {
1718            $validSkinNames[$currentUserSkin] = $currentUserSkin;
1719        }
1720
1721        foreach ( $validSkinNames as $skinkey => &$skinname ) {
1722            $msg = $context->msg( "skinname-{$skinkey}" );
1723            if ( $msg->exists() ) {
1724                $skinname = htmlspecialchars( $msg->text() );
1725            }
1726        }
1727
1728        $preferredSkins = $this->options->get( MainConfigNames::SkinsPreferred );
1729        // Sort by the internal name, so that the ordering is the same for each display language,
1730        // especially if some skin names are translated to use a different alphabet and some are not.
1731        uksort( $validSkinNames, function ( $a, $b ) use ( $currentUserSkin, $preferredSkins ) {
1732            return $this->sortSkinNames( $a, $b, $currentUserSkin, $preferredSkins );
1733        } );
1734
1735        return $validSkinNames;
1736    }
1737
1738    /**
1739     * Renders a list of user groups, sorting them so that temporary memberships are
1740     * listed before non-temporary ones.
1741     *
1742     * @param UserIdentity $user User for who load the group expiration data
1743     * @param array $userGroupsToShow List of user groups to show
1744     * @param IContextSource $context
1745     */
1746    private function renderUserGroupList(
1747        UserIdentity $user,
1748        array $userGroupsToShow,
1749        IContextSource $context
1750    ): string {
1751        $userGroupMemberships = $this->userGroupManager->getUserGroupMemberships( $user );
1752        $userGroups = $userMembers = $userTempGroups = $userTempMembers = [];
1753        foreach ( $userGroupsToShow as $ug ) {
1754            $groupStringOrObject = $userGroupMemberships[$ug] ?? $ug;
1755
1756            $userG = UserGroupMembership::getLinkHTML( $groupStringOrObject, $context );
1757            $userM = UserGroupMembership::getLinkHTML( $groupStringOrObject, $context, $user->getName() );
1758
1759            // Store expiring groups separately, so we can place them before non-expiring
1760            // groups in the list. This is to avoid the ambiguity of something like
1761            // "administrator, bureaucrat (until X date)" -- users might wonder whether the
1762            // expiry date applies to both groups, or just the last one
1763            if ( $groupStringOrObject instanceof UserGroupMembership &&
1764                $groupStringOrObject->getExpiry()
1765            ) {
1766                $userTempGroups[] = $userG;
1767                $userTempMembers[] = $userM;
1768            } else {
1769                $userGroups[] = $userG;
1770                $userMembers[] = $userM;
1771            }
1772        }
1773        sort( $userGroups );
1774        sort( $userMembers );
1775        sort( $userTempGroups );
1776        sort( $userTempMembers );
1777        $userGroups = array_merge( $userTempGroups, $userGroups );
1778        $userMembers = array_merge( $userTempMembers, $userMembers );
1779        $lang = $context->getLanguage();
1780        return $context->msg( 'prefs-memberingroups-type' )
1781            ->rawParams( $lang->commaList( $userGroups ), $lang->commaList( $userMembers ) )
1782            ->escaped();
1783    }
1784
1785    /**
1786     * @param User $user
1787     * @param IContextSource $context
1788     * @param array $validSkinNames
1789     * @return array Text/links to display as key; $skinkey as value
1790     */
1791    protected function generateSkinOptions( User $user, IContextSource $context, array $validSkinNames ) {
1792        $ret = [];
1793
1794        $mptitle = Title::newMainPage();
1795        $previewtext = $context->msg( 'skin-preview' )->escaped();
1796        $defaultSkin = $this->options->get( MainConfigNames::DefaultSkin );
1797        $allowUserCss = $this->options->get( MainConfigNames::AllowUserCss );
1798        $allowUserJs = $this->options->get( MainConfigNames::AllowUserJs );
1799        $safeMode = $this->userOptionsManager->getOption( $user, 'forcesafemode' );
1800        $foundDefault = false;
1801        foreach ( $validSkinNames as $skinkey => $sn ) {
1802            $linkTools = [];
1803
1804            // Mark the default skin
1805            if ( strcasecmp( $skinkey, $defaultSkin ) === 0 ) {
1806                $linkTools[] = $context->msg( 'default' )->escaped();
1807                $foundDefault = true;
1808            }
1809
1810            // Create talk page link if relevant message exists.
1811            $talkPageMsg = $context->msg( "$skinkey-prefs-talkpage" );
1812            if ( $talkPageMsg->exists() ) {
1813                $linkTools[] = $talkPageMsg->parse();
1814            }
1815
1816            // Create preview link
1817            $mplink = htmlspecialchars( $mptitle->getLocalURL( [ 'useskin' => $skinkey ] ) );
1818            $linkTools[] = "<a target='_blank' href=\"$mplink\">$previewtext</a>";
1819
1820            if ( !$safeMode ) {
1821                // Create links to user CSS/JS pages
1822                // @todo Refactor this and the similar code in skinPreferences().
1823                if ( $allowUserCss ) {
1824                    $cssPage = Title::makeTitleSafe( NS_USER, $user->getName() . '/' . $skinkey . '.css' );
1825                    $cssLinkText = $context->msg( 'prefs-custom-css' )->text();
1826                    $linkTools[] = $this->linkRenderer->makeLink( $cssPage, $cssLinkText );
1827                }
1828
1829                if ( $allowUserJs ) {
1830                    $jsPage = Title::makeTitleSafe( NS_USER, $user->getName() . '/' . $skinkey . '.js' );
1831                    $jsLinkText = $context->msg( 'prefs-custom-js' )->text();
1832                    $linkTools[] = $this->linkRenderer->makeLink( $jsPage, $jsLinkText );
1833                }
1834            }
1835
1836            $display = $sn . ' ' . $context->msg( 'parentheses' )
1837                ->rawParams( $context->getLanguage()->pipeList( $linkTools ) )
1838                ->escaped();
1839            $ret[$display] = $skinkey;
1840        }
1841
1842        if ( !$foundDefault ) {
1843            // If the default skin is not available, things are going to break horribly because the
1844            // default value for skin selector will not be a valid value. Let's just not show it then.
1845            return [];
1846        }
1847
1848        return $ret;
1849    }
1850
1851    /**
1852     * @param IContextSource $context
1853     * @return array
1854     */
1855    protected function getDateOptions( IContextSource $context ) {
1856        $lang = $context->getLanguage();
1857        $dateopts = $lang->getDatePreferences();
1858
1859        $ret = [];
1860
1861        if ( $dateopts ) {
1862            if ( !in_array( 'default', $dateopts ) ) {
1863                $dateopts[] = 'default'; // Make sure default is always valid T21237
1864            }
1865
1866            // FIXME KLUGE: site default might not be valid for user language
1867            global $wgDefaultUserOptions;
1868            if ( !in_array( $wgDefaultUserOptions['date'], $dateopts ) ) {
1869                $wgDefaultUserOptions['date'] = 'default';
1870            }
1871
1872            $epoch = wfTimestampNow();
1873            foreach ( $dateopts as $key ) {
1874                if ( $key == 'default' ) {
1875                    $formatted = $context->msg( 'datedefault' )->escaped();
1876                } else {
1877                    $formatted = htmlspecialchars( $lang->timeanddate( $epoch, false, $key ) );
1878                }
1879                $ret[$formatted] = $key;
1880            }
1881        }
1882        return $ret;
1883    }
1884
1885    /**
1886     * @param MessageLocalizer $l10n
1887     * @return array
1888     */
1889    protected function getImageSizes( MessageLocalizer $l10n ) {
1890        $ret = [];
1891        $pixels = $l10n->msg( 'unit-pixel' )->text();
1892
1893        foreach ( $this->options->get( MainConfigNames::ImageLimits ) as $index => $limits ) {
1894            // Note: A left-to-right marker (U+200E) is inserted, see T144386
1895            $display = "{$limits[0]}\u{200E}×{$limits[1]}$pixels";
1896            $ret[$display] = $index;
1897        }
1898
1899        return $ret;
1900    }
1901
1902    /**
1903     * Normalizes thumbnail options (which can support more than 3 values) to min, default and max values
1904     * for correspondance with user preferences options available to the user (small, regular, large).
1905     *
1906     * Note, for now, this requires that the small size is at least 180px to smoothness the transition to three
1907     * thumbnail sizes. Numbers below 180px are historical artifacts and we do not want users on Wikimedia sites to
1908     * ever receive thumbnails that small. This is temporary until migrations on Wikimedia sites have occurred and must
1909     * be removed before the 1.48 release so third parties are not impacted by the change and can make their own
1910     * decisions.
1911     *
1912     * @internal only for use outside this class by ResourceLoader/SkinModule
1913     * @param array $limits as defined in $wgThumbLimits
1914     * @param array $defaultUserOptions
1915     * @return array of three thumbnail sizes representing small, medium, large
1916     */
1917    public static function getNormalizedThumbSizes( array $limits, array $defaultUserOptions ) {
1918        $smallSize = max( 180, min( $limits ) );
1919        $defaultIndex = $defaultUserOptions[ 'thumbsize' ] ?? 0;
1920        $defaultSize = $limits[ $defaultIndex ];
1921        $largeSize = max( $limits );
1922        return [ $smallSize, $defaultSize, $largeSize ];
1923    }
1924
1925    /**
1926     * @param MessageLocalizer $l10n
1927     * @return array
1928     */
1929    protected function getThumbSizes( MessageLocalizer $l10n ) {
1930        $config = $this->options;
1931        $limits = $config->get( MainConfigNames::ThumbLimits );
1932        $sizes = self::getNormalizedThumbSizes( $limits, $this->userOptionsManager->getDefaultOptions() );
1933
1934        return [
1935            $l10n->msg( 'thumbsize-small' )->text() => array_search( $sizes[ 0 ], $limits ),
1936            $l10n->msg( 'thumbsize-regular' )->text() => array_search( $sizes[ 1 ], $limits ),
1937            $l10n->msg( 'thumbsize-large' )->text() => array_search( $sizes[ 2 ], $limits ),
1938        ];
1939    }
1940
1941    /**
1942     * @param mixed $signature
1943     * @param array $alldata
1944     * @param HTMLForm $form
1945     * @return bool|string|string[]
1946     */
1947    protected function validateSignature( $signature, $alldata, HTMLForm $form ) {
1948        $sigValidation = $this->options->get( MainConfigNames::SignatureValidation );
1949        $maxSigChars = $this->options->get( MainConfigNames::MaxSigChars );
1950        if ( is_string( $signature ) && mb_strlen( $signature ) > $maxSigChars ) {
1951            return $form->msg( 'badsiglength' )->numParams( $maxSigChars )->escaped();
1952        }
1953
1954        if ( $signature === null || $signature === '' ) {
1955            // Make sure leaving the field empty is valid, since that's used as the default (T288151).
1956            // Code using this preference in Parser::getUserSig() handles this case specially.
1957            return true;
1958        }
1959
1960        // Remaining checks only apply to fancy signatures
1961        if ( !( isset( $alldata['fancysig'] ) && $alldata['fancysig'] ) ) {
1962            return true;
1963        }
1964
1965        // HERE BE DRAGONS:
1966        //
1967        // If this value is already saved as the user's signature, treat it as valid, even if it
1968        // would be invalid to save now, and even if $wgSignatureValidation is set to 'disallow'.
1969        //
1970        // It can become invalid when we introduce new validation, or when the value just transcludes
1971        // some page containing the real signature and that page is edited (which we can't validate),
1972        // or when someone's username is changed.
1973        //
1974        // Otherwise it would be completely removed when the user opens their preferences page, which
1975        // would be very unfriendly.
1976        $user = $form->getUser();
1977        if (
1978            $signature === $this->userOptionsManager->getOption( $user, 'nickname' ) &&
1979            (bool)$alldata['fancysig'] === $this->userOptionsManager->getBoolOption( $user, 'fancysig' )
1980        ) {
1981            return true;
1982        }
1983
1984        if ( $sigValidation === 'new' || $sigValidation === 'disallow' ) {
1985            // Validate everything
1986            $parserOpts = ParserOptions::newFromContext( $form->getContext() );
1987            $validator = $this->signatureValidatorFactory
1988                ->newSignatureValidator( $user, $form->getContext(), $parserOpts );
1989            $errors = $validator->validateSignature( $signature );
1990            if ( $errors ) {
1991                return $errors;
1992            }
1993        }
1994
1995        // Quick check for mismatched HTML tags in the input.
1996        // Note that this is easily fooled by wikitext templates or bold/italic markup.
1997        // We're only keeping this until Parsoid is integrated and guaranteed to be available.
1998        if ( $this->parserFactory->getInstance()->validateSig( $signature ) === false ) {
1999            return $form->msg( 'badsig' )->escaped();
2000        }
2001
2002        return true;
2003    }
2004
2005    /**
2006     * @param string $signature
2007     * @param array $alldata
2008     * @param HTMLForm $form
2009     * @return string
2010     */
2011    protected function cleanSignature( $signature, $alldata, HTMLForm $form ) {
2012        if ( isset( $alldata['fancysig'] ) && $alldata['fancysig'] ) {
2013            $signature = $this->parserFactory->getInstance()->cleanSig( $signature );
2014        } else {
2015            // When no fancy sig used, make sure ~{3,5} get removed.
2016            $signature = Parser::cleanSigInSig( $signature );
2017        }
2018
2019        return $signature;
2020    }
2021
2022    /**
2023     * @param User $user
2024     * @param IContextSource $context
2025     * @param class-string<PreferencesFormOOUI> $formClass
2026     * @param array $remove Array of items to remove
2027     * @return HTMLForm
2028     */
2029    public function getForm(
2030        User $user,
2031        IContextSource $context,
2032        $formClass = PreferencesFormOOUI::class,
2033        array $remove = []
2034    ) {
2035        // We use ButtonWidgets in some of the getPreferences() functions
2036        $context->getOutput()->enableOOUI();
2037
2038        // Note that the $user parameter of getFormDescriptor() is deprecated.
2039        $formDescriptor = $this->getFormDescriptor( $user, $context );
2040        if ( count( $remove ) ) {
2041            $removeKeys = array_fill_keys( $remove, true );
2042            $formDescriptor = array_diff_key( $formDescriptor, $removeKeys );
2043        }
2044
2045        // Remove type=api preferences. They are not intended for rendering in the form.
2046        foreach ( $formDescriptor as $name => $info ) {
2047            if ( isset( $info['type'] ) && $info['type'] === 'api' ) {
2048                unset( $formDescriptor[$name] );
2049            }
2050        }
2051
2052        /**
2053         * @var PreferencesFormOOUI $htmlForm
2054         */
2055        $htmlForm = new $formClass( $formDescriptor, $context, 'prefs' );
2056
2057        // This allows users to opt-in to hidden skins. While this should be discouraged and is not
2058        // discoverable, this allows users to still use hidden skins while preventing new users from
2059        // adopting unsupported skins. If no useskin=… parameter was provided, it will not show up
2060        // in the resulting URL.
2061        $htmlForm->setAction( $context->getTitle()->getLocalURL( [
2062            'useskin' => $context->getRequest()->getRawVal( 'useskin' )
2063        ] ) );
2064
2065        $htmlForm->setModifiedUser( $user );
2066        $htmlForm->setOptionsEditable( $user->isAllowed( 'editmyoptions' ) );
2067        $htmlForm->setPrivateInfoEditable( $user->isAllowed( 'editmyprivateinfo' ) );
2068        $htmlForm->setId( 'mw-prefs-form' );
2069        $htmlForm->setAutocomplete( 'off' );
2070        $htmlForm->setSubmitTextMsg( 'saveprefs' );
2071        // Used message keys: 'accesskey-preferences-save', 'tooltip-preferences-save'
2072        $htmlForm->setSubmitTooltip( 'preferences-save' );
2073        $htmlForm->setSubmitID( 'prefcontrol' );
2074        $htmlForm->setSubmitCallback(
2075            function ( array $formData, PreferencesFormOOUI $form ) use ( $formDescriptor ) {
2076                return $this->submitForm( $formData, $form, $formDescriptor );
2077            }
2078        );
2079
2080        return $htmlForm;
2081    }
2082
2083    /**
2084     * Handle the form submission if everything validated properly
2085     *
2086     * @param array $formData
2087     * @param PreferencesFormOOUI $form
2088     * @param array[] $formDescriptor
2089     * @return bool|Status|string
2090     */
2091    protected function saveFormData( $formData, PreferencesFormOOUI $form, array $formDescriptor ) {
2092        $user = $form->getModifiedUser();
2093        $hiddenPrefs = $this->options->get( MainConfigNames::HiddenPrefs );
2094        $result = true;
2095
2096        if ( !$user->isAllowedAny( 'editmyprivateinfo', 'editmyoptions' ) ) {
2097            return Status::newFatal( 'mypreferencesprotected' );
2098        }
2099
2100        // Filter input
2101        $this->applyFilters( $formData, $formDescriptor, 'filterFromForm' );
2102
2103        // Fortunately, the realname field is MUCH simpler
2104        // (not really "private", but still shouldn't be edited without permission)
2105
2106        if ( !in_array( 'realname', $hiddenPrefs )
2107            && $user->isAllowed( 'editmyprivateinfo' )
2108            && array_key_exists( 'realname', $formData )
2109        ) {
2110            $realName = $formData['realname'];
2111            $user->setRealName( $realName );
2112        }
2113
2114        if ( $user->isAllowed( 'editmyoptions' ) ) {
2115            $oldUserOptions = $this->userOptionsManager->getOptions( $user );
2116
2117            foreach ( $this->getSaveBlacklist() as $b ) {
2118                unset( $formData[$b] );
2119            }
2120
2121            // If users have saved a value for a preference which has subsequently been disabled
2122            // via $wgHiddenPrefs, we don't want to destroy that setting in case the preference
2123            // is subsequently re-enabled
2124            foreach ( $hiddenPrefs as $pref ) {
2125                // If the user has not set a non-default value here, the default will be returned
2126                // and subsequently discarded
2127                $formData[$pref] = $this->userOptionsManager->getOption( $user, $pref, null, true );
2128            }
2129
2130            // If the user changed the rclimit preference, also change the rcfilters-rclimit preference
2131            if (
2132                isset( $formData['rclimit'] ) &&
2133                intval( $formData[ 'rclimit' ] ) !== $this->userOptionsManager->getIntOption( $user, 'rclimit' )
2134            ) {
2135                $formData['rcfilters-limit'] = $formData['rclimit'];
2136            }
2137
2138            // Keep old preferences from interfering due to back-compat code, etc.
2139            $optionsToReset = $this->getOptionNamesForReset( $user, $form->getContext(), 'unused' );
2140            $this->userOptionsManager->resetOptionsByName( $user, $optionsToReset );
2141
2142            foreach ( $formData as $key => $value ) {
2143                // If we're creating a new local override, we need to explicitly pass
2144                // GLOBAL_OVERRIDE to setOption(), otherwise the update would be ignored
2145                // due to the conflicting global option.
2146                $except = !empty( $formData[$key . UserOptionsLookup::LOCAL_EXCEPTION_SUFFIX] );
2147                $this->userOptionsManager->setOption( $user, $key, $value,
2148                    $except ? UserOptionsManager::GLOBAL_OVERRIDE : UserOptionsManager::GLOBAL_IGNORE );
2149            }
2150
2151            $this->hookRunner->onPreferencesFormPreSave(
2152                $formData, $form, $user, $result, $oldUserOptions );
2153        }
2154
2155        $user->saveSettings();
2156
2157        return $result;
2158    }
2159
2160    /**
2161     * Applies filters to preferences either before or after form usage
2162     *
2163     * @param array &$preferences
2164     * @param array $formDescriptor
2165     * @param string $verb Name of the filter method to call, either 'filterFromForm' or
2166     *         'filterForForm'
2167     */
2168    protected function applyFilters( array &$preferences, array $formDescriptor, $verb ) {
2169        foreach ( $formDescriptor as $preference => $desc ) {
2170            if ( !isset( $desc['filter'] ) || !isset( $preferences[$preference] ) ) {
2171                continue;
2172            }
2173            $filterDesc = $desc['filter'];
2174            if ( $filterDesc instanceof Filter ) {
2175                $filter = $filterDesc;
2176            } elseif ( class_exists( $filterDesc ) ) {
2177                $filter = new $filterDesc();
2178            } elseif ( is_callable( $filterDesc ) ) {
2179                $filter = $filterDesc();
2180            } else {
2181                throw new UnexpectedValueException(
2182                    "Unrecognized filter type for preference '$preference'"
2183                );
2184            }
2185            $preferences[$preference] = $filter->$verb( $preferences[$preference] );
2186        }
2187    }
2188
2189    /**
2190     * Save the form data and reload the page
2191     *
2192     * @param array $formData
2193     * @param PreferencesFormOOUI $form
2194     * @param array $formDescriptor
2195     * @return Status
2196     */
2197    protected function submitForm(
2198        array $formData,
2199        PreferencesFormOOUI $form,
2200        array $formDescriptor
2201    ) {
2202        $res = $this->saveFormData( $formData, $form, $formDescriptor );
2203
2204        if ( $res === true ) {
2205            $context = $form->getContext();
2206            $urlOptions = [];
2207
2208            $urlOptions += $form->getExtraSuccessRedirectParameters();
2209
2210            $url = $form->getTitle()->createFragmentTarget(
2211                $form->getRequest()->getVal( 'returntoanchor' ) ?? ''
2212            )->getFullURL( $urlOptions );
2213
2214            // Set session data for the success message
2215            $context->getRequest()->getSession()->set( 'specialPreferencesSaveSuccess', 1 );
2216
2217            $context->getOutput()->redirect( $url );
2218        }
2219
2220        return ( $res === true ? Status::newGood() : $res );
2221    }
2222
2223    /**
2224     * @param User $user
2225     * @param IContextSource $context
2226     * @param array|null $options
2227     * @return string[]
2228     */
2229    public function getResetKinds(
2230        User $user, IContextSource $context, $options = null
2231    ): array {
2232        $options ??= $this->userOptionsManager->loadUserOptions( $user );
2233
2234        $prefs = $this->getFormDescriptor( $user, $context );
2235        $mapping = [];
2236
2237        // Pull out the "special" options, so they don't get converted as
2238        // multiselect or checkmatrix.
2239        $specialOptions = array_fill_keys( $this->getSaveBlacklist(), true );
2240        foreach ( $specialOptions as $name => $value ) {
2241            unset( $prefs[$name] );
2242        }
2243
2244        // Multiselect and checkmatrix options are stored in the database with
2245        // one key per option, each having a boolean value. Extract those keys.
2246        $multiselectOptions = [];
2247        foreach ( $prefs as $name => $info ) {
2248            if ( ( isset( $info['type'] ) && $info['type'] == 'multiselect' ) ||
2249                // Checking old alias for compatibility with unchanged extensions
2250                ( isset( $info['class'] ) && $info['class'] === 'HTMLMultiSelectField' ) ||
2251                ( isset( $info['class'] ) && $info['class'] === HTMLMultiSelectField::class )
2252            ) {
2253                $opts = HTMLFormField::flattenOptions( $info['options'] ?? $info['options-messages'] );
2254                $prefix = $info['prefix'] ?? $name;
2255
2256                foreach ( $opts as $value ) {
2257                    $multiselectOptions["$prefix$value"] = true;
2258                }
2259
2260                unset( $prefs[$name] );
2261            }
2262        }
2263        $checkmatrixOptions = [];
2264        foreach ( $prefs as $name => $info ) {
2265            if ( ( isset( $info['type'] ) && $info['type'] == 'checkmatrix' ) ||
2266                // Checking old alias for compatibility with unchanged extensions
2267                ( isset( $info['class'] ) && $info['class'] === 'HTMLCheckMatrix' ) ||
2268                ( isset( $info['class'] ) && $info['class'] === HTMLCheckMatrix::class )
2269            ) {
2270                $columns = HTMLFormField::flattenOptions( $info['columns'] );
2271                $rows = HTMLFormField::flattenOptions( $info['rows'] );
2272                $prefix = $info['prefix'] ?? $name;
2273
2274                foreach ( $columns as $column ) {
2275                    foreach ( $rows as $row ) {
2276                        $checkmatrixOptions["$prefix$column-$row"] = true;
2277                    }
2278                }
2279
2280                unset( $prefs[$name] );
2281            }
2282        }
2283
2284        // $value is ignored
2285        foreach ( $options as $key => $value ) {
2286            if ( isset( $prefs[$key] ) ) {
2287                $mapping[$key] = 'registered';
2288            } elseif ( isset( $multiselectOptions[$key] ) ) {
2289                $mapping[$key] = 'registered-multiselect';
2290            } elseif ( isset( $checkmatrixOptions[$key] ) ) {
2291                $mapping[$key] = 'registered-checkmatrix';
2292            } elseif ( isset( $specialOptions[$key] ) ) {
2293                $mapping[$key] = 'special';
2294            } elseif ( str_starts_with( $key, 'userjs-' ) ) {
2295                $mapping[$key] = 'userjs';
2296            } elseif ( str_ends_with( $key, UserOptionsLookup::LOCAL_EXCEPTION_SUFFIX ) ) {
2297                $mapping[$key] = 'local-exception';
2298            } else {
2299                $mapping[$key] = 'unused';
2300            }
2301        }
2302
2303        return $mapping;
2304    }
2305
2306    public function listResetKinds(): array {
2307        return [
2308            'registered',
2309            'registered-multiselect',
2310            'registered-checkmatrix',
2311            'special',
2312            'userjs',
2313            'local-exception',
2314            'unused'
2315        ];
2316    }
2317
2318    /**
2319     * @param User $user
2320     * @param IContextSource $context
2321     * @param string|string[] $kinds
2322     * @return string[]
2323     */
2324    public function getOptionNamesForReset( User $user, IContextSource $context, $kinds ) {
2325        $oldOptions = $this->userOptionsManager->loadUserOptions( $user, IDBAccessObject::READ_LATEST );
2326
2327        if ( !is_array( $kinds ) ) {
2328            $kinds = [ $kinds ];
2329        }
2330
2331        if ( in_array( 'all', $kinds ) ) {
2332            return array_keys( $oldOptions );
2333        } else {
2334            $optionKinds = $this->getResetKinds( $user, $context );
2335            $kinds = array_intersect( $kinds, $this->listResetKinds() );
2336            $optionNames = [];
2337
2338            foreach ( $oldOptions as $key => $value ) {
2339                if ( in_array( $optionKinds[$key], $kinds ) ) {
2340                    $optionNames[] = $key;
2341                }
2342            }
2343            return $optionNames;
2344        }
2345    }
2346}