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