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