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