1<?php 2 3namespace Vector; 4 5use Config; 6use HTMLForm; 7use MediaWiki\MediaWikiServices; 8use OutputPage; 9use ResourceLoaderContext; 10use Skin; 11use SkinTemplate; 12use SkinVector; 13use Title; 14use User; 15use Vector\HTMLForm\Fields\HTMLLegacySkinVersionField; 16 17/** 18 * Presentation hook handlers for Vector skin. 19 * 20 * Hook handler method names should be in the form of: 21 * on<HookName>() 22 * @package Vector 23 * @internal 24 */ 25class Hooks { 26 /** 27 * Passes config variables to Vector (modern) ResourceLoader module. 28 * @param ResourceLoaderContext $context 29 * @param Config $config 30 * @return array 31 */ 32 public static function getVectorResourceLoaderConfig( 33 ResourceLoaderContext $context, 34 Config $config 35 ) { 36 return [ 37 'wgVectorSearchHost' => $config->get( 'VectorSearchHost' ), 38 ]; 39 } 40 41 /** 42 * Generates config variables for skins.vector.search Resource Loader module (defined in 43 * skin.json). 44 * 45 * @param ResourceLoaderContext $context 46 * @param Config $config 47 * @return array<string,mixed> 48 */ 49 public static function getVectorWvuiSearchResourceLoaderConfig( 50 ResourceLoaderContext $context, 51 Config $config 52 ): array { 53 $result = $config->get( 'VectorWvuiSearchOptions' ); 54 $result['highlightQuery'] = 55 VectorServices::getLanguageService()->canWordsBeSplitSafely( $context->getLanguage() ); 56 57 return $result; 58 } 59 60 /** 61 * SkinPageReadyConfig hook handler 62 * 63 * Replace searchModule provided by skin. 64 * 65 * @since 1.35 66 * @param ResourceLoaderContext $context 67 * @param mixed[] &$config Associative array of configurable options 68 * @return void This hook must not abort, it must return no value 69 */ 70 public static function onSkinPageReadyConfig( 71 ResourceLoaderContext $context, 72 array &$config 73 ) { 74 // It's better to exit before any additional check 75 if ( $context->getSkin() !== 'vector' ) { 76 return; 77 } 78 79 // Tell the `mediawiki.page.ready` module not to wire up search. 80 // This allows us to use $wgVectorUseWvuiSearch to decide to load 81 // the historic jquery autocomplete search or the new Vue implementation. 82 // ResourceLoaderContext has no knowledge of legacy / modern Vector 83 // and from its point of view they are the same thing. 84 // Please see the modules `skins.vector.js` and `skins.vector.legacy.js` 85 // for the wire up of search. 86 // The related method self::getVectorResourceLoaderConfig handles which 87 // search to load. 88 $config['search'] = false; 89 } 90 91 /** 92 * Transforms watch item inside the action navigation menu 93 * 94 * @param array &$content_navigation 95 */ 96 private static function updateActionsMenu( &$content_navigation ) { 97 $key = null; 98 if ( isset( $content_navigation['actions']['watch'] ) ) { 99 $key = 'watch'; 100 } 101 if ( isset( $content_navigation['actions']['unwatch'] ) ) { 102 $key = 'unwatch'; 103 } 104 105 // Promote watch link from actions to views and add an icon 106 if ( $key !== null ) { 107 self::appendClassToListItem( 108 $content_navigation['actions'][$key], 109 'icon' 110 ); 111 $content_navigation['views'][$key] = $content_navigation['actions'][$key]; 112 unset( $content_navigation['actions'][$key] ); 113 } 114 } 115 116 /** 117 * Updates class list on list item 118 * 119 * @param array &$item to update for use in makeListItem 120 * @param array $classes to add to the item 121 * @param bool $applyToLink (optional) and defaults to false. 122 * If set will modify `link-class` instead of `class` 123 */ 124 private static function addListItemClass( &$item, $classes, $applyToLink = false ) { 125 $property = $applyToLink ? 'link-class' : 'class'; 126 $existingClass = $item[$property] ?? []; 127 128 if ( is_array( $existingClass ) ) { 129 $item[$property] = array_merge( $existingClass, $classes ); 130 } elseif ( is_string( $existingClass ) ) { 131 // treat as string 132 $item[$property] = array_merge( [ $existingClass ], $classes ); 133 } else { 134 $item[$property] = $classes; 135 } 136 } 137 138 /** 139 * Updates the class on an existing item taking into account whether 140 * a class exists there already. 141 * 142 * @param array &$item 143 * @param string $newClass 144 */ 145 private static function appendClassToListItem( &$item, $newClass ) { 146 self::addListItemClass( $item, [ $newClass ] ); 147 } 148 149 /** 150 * Adds an icon to the list item of a menu. 151 * 152 * @param array &$item 153 * @param string $icon_name 154 */ 155 private static function addIconToListItem( &$item, $icon_name ) { 156 // Set the default menu icon classes. 157 $menu_icon_classes = [ 'mw-ui-icon', 'mw-ui-icon-before', 'mw-ui-icon-wikimedia-' . $icon_name ]; 158 self::addListItemClass( $item, $menu_icon_classes, true ); 159 } 160 161 /** 162 * Updates personal navigation menu (user links) for modern Vector wherein user page, create account and login links 163 * are removed from the dropdown to be handled separately. In legacy Vector, the custom "user-page" bucket is 164 * removed to preserve existing behavior. 165 * 166 * @param SkinTemplate $sk 167 * @param array &$content_navigation 168 */ 169 private static function updateUserLinksItems( $sk, &$content_navigation ) { 170 $COLLAPSE_MENU_ITEM_CLASS = 'user-links-collapsible-item'; 171 172 // For logged-in users in modern Vector, rearrange some links in the personal toolbar. 173 if ( $sk->loggedin ) { 174 // Remove user page from personal menu dropdown for logged in users at higher resolutions. 175 self::appendClassToListItem( 176 $content_navigation['user-menu']['userpage'], 177 $COLLAPSE_MENU_ITEM_CLASS 178 ); 179 // Remove logout link from user-menu and recreate it in SkinVector, 180 unset( $content_navigation['user-menu']['logout'] ); 181 // Don't show icons for anon menu items (besides login and create account). 182 // Prefix user link items with associated icon. 183 $user_menu = $content_navigation['user-menu']; 184 // Loop through each menu to check/append its link classes. 185 foreach ( $user_menu as $menu_key => $menu_value ) { 186 $icon_name = $menu_value['icon'] ?? ''; 187 self::addIconToListItem( $content_navigation['user-menu'][$menu_key], $icon_name ); 188 } 189 } else { 190 // Remove "Not logged in" from personal menu dropdown for anon users. 191 unset( $content_navigation['user-menu']['anonuserpage'] ); 192 // "Create account" link is handled manually by Vector 193 unset( $content_navigation['user-menu']['createaccount'] ); 194 // "Login" link is handled manually by Vector 195 unset( $content_navigation['user-menu']['login'] ); 196 // Remove duplicate "Login" link added by SkinTemplate::buildPersonalUrls if group read permissions 197 // are set to false. 198 unset( $content_navigation['user-menu']['login-private'] ); 199 } 200 201 // ULS and user page links are hidden at lower resolutions. 202 if ( $content_navigation['user-interface-preferences'] ) { 203 self::appendClassToListItem( 204 $content_navigation['user-interface-preferences']['uls'], 205 $COLLAPSE_MENU_ITEM_CLASS 206 ); 207 } 208 if ( $content_navigation['user-page'] ) { 209 self::appendClassToListItem( 210 $content_navigation['user-page']['userpage'], 211 $COLLAPSE_MENU_ITEM_CLASS 212 ); 213 214 // Style the user page link as mw-ui-button. 215 self::addListItemClass( 216 $content_navigation['user-page']['userpage'], 217 [ 'mw-ui-button', 'mw-ui-quiet' ], 218 true 219 ); 220 } 221 } 222 223 /** 224 * Make an icon 225 * 226 * @internal for use inside Vector skin. 227 * @param string $name 228 * @return string of HTML 229 */ 230 public static function makeIcon( $name ) { 231 // Html::makeLink will pass this through rawElement 232 return '<span class="mw-ui-icon mw-ui-icon-' . $name . '"></span>'; 233 } 234 235 /** 236 * Updates user interface preferences for modern Vector to upgrade icon/button menu items. 237 * 238 * @param SkinTemplate $sk 239 * @param array &$content_navigation 240 * @param string $menu identifier 241 */ 242 private static function updateMenuItems( $sk, &$content_navigation, $menu ) { 243 foreach ( $content_navigation[$menu] as $key => $item ) { 244 $hasButton = $item['button'] ?? false; 245 $hideText = $item['text-hidden'] ?? false; 246 $icon = $item['icon'] ?? ''; 247 unset( $item['button'] ); 248 unset( $item['icon'] ); 249 unset( $item['text-hidden'] ); 250 251 if ( $hasButton ) { 252 $item['link-class'][] = 'mw-ui-button mw-ui-quiet'; 253 } 254 255 if ( $icon ) { 256 if ( $hideText ) { 257 $item['link-class'][] = 'mw-ui-icon mw-ui-icon-element mw-ui-icon-' . $icon; 258 } else { 259 $item['link-html'] = self::makeIcon( $icon ); 260 } 261 } 262 $content_navigation[$menu][$key] = $item; 263 } 264 } 265 266 /** 267 * Upgrades Vector's watch action to a watchstar. 268 * This is invoked inside SkinVector, not via skin registration, as skin hooks 269 * are not guaranteed to run last. 270 * This can possibly be revised based on the outcome of T287622. 271 * 272 * @see https://www.mediawiki.org/wiki/Manual:Hooks/SkinTemplateNavigation 273 * @param SkinTemplate $sk 274 * @param array &$content_navigation 275 */ 276 public static function onSkinTemplateNavigation( $sk, &$content_navigation ) { 277 $title = $sk->getRelevantTitle(); 278 279 if ( $sk->getSkinName() === 'vector' ) { 280 if ( 281 $sk->getConfig()->get( 'VectorUseIconWatch' ) && 282 $title && $title->canExist() 283 ) { 284 self::updateActionsMenu( $content_navigation ); 285 } 286 287 if ( isset( $content_navigation['user-menu'] ) ) { 288 if ( self::isSkinVersionLegacy() ) { 289 // Remove user page from personal toolbar since it will be inside the personal menu for logged-in 290 // users in legacy Vector. 291 unset( $content_navigation['user-page'] ); 292 } else { 293 // For modern Vector, rearrange some links in the personal toolbar. 294 self::updateUserLinksItems( $sk, $content_navigation ); 295 } 296 } 297 298 if ( !self::isSkinVersionLegacy() ) { 299 // Upgrade preferences and notifications to icon buttons 300 // for extensions that have opted in. 301 if ( isset( $content_navigation['user-interface-preferences'] ) ) { 302 self::updateMenuItems( 303 $sk, $content_navigation, 'user-interface-preferences' 304 ); 305 } 306 if ( isset( $content_navigation['notifications'] ) ) { 307 self::updateMenuItems( 308 $sk, $content_navigation, 'notifications' 309 ); 310 } 311 } 312 } 313 } 314 315 /** 316 * Add Vector preferences to the user's Special:Preferences page directly underneath skins. 317 * 318 * @param User $user User whose preferences are being modified. 319 * @param array[] &$prefs Preferences description array, to be fed to a HTMLForm object. 320 */ 321 public static function onGetPreferences( User $user, array &$prefs ) { 322 if ( !self::getConfig( Constants::CONFIG_KEY_SHOW_SKIN_PREFERENCES ) ) { 323 // Do not add Vector skin specific preferences. 324 return; 325 } 326 327 // Preferences to add. 328 $vectorPrefs = [ 329 Constants::PREF_KEY_SKIN_VERSION => [ 330 'class' => HTMLLegacySkinVersionField::class, 331 // The checkbox title. 332 'label-message' => 'prefs-vector-enable-vector-1-label', 333 // Show a little informational snippet underneath the checkbox. 334 'help-message' => 'prefs-vector-enable-vector-1-help', 335 // The tab location and title of the section to insert the checkbox. The bit after the slash 336 // indicates that a prefs-skin-prefs string will be provided. 337 'section' => 'rendering/skin/skin-prefs', 338 'default' => self::isSkinVersionLegacy(), 339 // Only show this section when the Vector skin is checked. The JavaScript client also uses 340 // this state to determine whether to show or hide the whole section. 341 'hide-if' => [ '!==', 'wpskin', Constants::SKIN_NAME ], 342 ], 343 Constants::PREF_KEY_SIDEBAR_VISIBLE => [ 344 'type' => 'api', 345 'default' => self::getConfig( Constants::CONFIG_KEY_DEFAULT_SIDEBAR_VISIBLE_FOR_AUTHORISED_USER ) 346 ], 347 ]; 348 349 // Seek the skin preference section to add Vector preferences just below it. 350 $skinSectionIndex = array_search( 'skin', array_keys( $prefs ) ); 351 if ( $skinSectionIndex !== false ) { 352 // Skin preference section found. Inject Vector skin-specific preferences just below it. 353 // This pattern can be found in Popups too. See T246162. 354 $vectorSectionIndex = $skinSectionIndex + 1; 355 $prefs = array_slice( $prefs, 0, $vectorSectionIndex, true ) 356 + $vectorPrefs 357 + array_slice( $prefs, $vectorSectionIndex, null, true ); 358 } else { 359 // Skin preference section not found. Just append Vector skin-specific preferences. 360 $prefs += $vectorPrefs; 361 } 362 } 363 364 /** 365 * Hook executed on user's Special:Preferences form save. This is used to convert the boolean 366 * presentation of skin version to a version string. That is, a single preference change by the 367 * user may trigger two writes: a boolean followed by a string. 368 * 369 * @param array $formData Form data submitted by user 370 * @param HTMLForm $form A preferences form 371 * @param User $user Logged-in user 372 * @param bool &$result Variable defining is form save successful 373 * @param array $oldPreferences 374 */ 375 public static function onPreferencesFormPreSave( 376 array $formData, 377 HTMLForm $form, 378 User $user, 379 &$result, 380 $oldPreferences 381 ) { 382 $isVectorEnabled = ( $formData[ 'skin' ] ?? '' ) === Constants::SKIN_NAME; 383 384 if ( !$isVectorEnabled && array_key_exists( Constants::PREF_KEY_SKIN_VERSION, $oldPreferences ) ) { 385 // The setting was cleared. However, this is likely because a different skin was chosen and 386 // the skin version preference was hidden. 387 MediaWikiServices::getInstance()->getUserOptionsManager()->setOption( 388 $user, 389 Constants::PREF_KEY_SKIN_VERSION, 390 $oldPreferences[ Constants::PREF_KEY_SKIN_VERSION ] 391 ); 392 } 393 } 394 395 /** 396 * Called one time when initializing a users preferences for a newly created account. 397 * 398 * @param User $user Newly created user object. 399 * @param bool $isAutoCreated 400 */ 401 public static function onLocalUserCreated( User $user, $isAutoCreated ) { 402 $default = self::getConfig( Constants::CONFIG_KEY_DEFAULT_SKIN_VERSION_FOR_NEW_ACCOUNTS ); 403 // Permanently set the default preference. The user can later change this preference, however, 404 // self::onLocalUserCreated() will not be executed for that account again. 405 MediaWikiServices::getInstance()->getUserOptionsManager()->setOption( 406 $user, 407 Constants::PREF_KEY_SKIN_VERSION, 408 $default 409 ); 410 } 411 412 /** 413 * Called when OutputPage::headElement is creating the body tag to allow skins 414 * and extensions to add attributes they might need to the body of the page. 415 * 416 * @param OutputPage $out 417 * @param Skin $sk 418 * @param string[] &$bodyAttrs 419 */ 420 public static function onOutputPageBodyAttributes( OutputPage $out, Skin $sk, &$bodyAttrs ) { 421 if ( !$sk instanceof SkinVector ) { 422 return; 423 } 424 425 // As of 2020/08/13, this CSS class is referred to by the following deployed extensions: 426 // 427 // - VisualEditor 428 // - CodeMirror 429 // - WikimediaEvents 430 // 431 // See https://codesearch.wmcloud.org/deployed/?q=skin-vector-legacy for an up-to-date 432 // list. 433 if ( self::isSkinVersionLegacy() ) { 434 $bodyAttrs['class'] .= ' skin-vector-legacy'; 435 } 436 437 // Determine the search widget treatment to send to the user 438 if ( VectorServices::getFeatureManager()->isFeatureEnabled( Constants::FEATURE_USE_WVUI_SEARCH ) ) { 439 $bodyAttrs['class'] .= ' skin-vector-search-vue'; 440 } 441 442 $config = $sk->getConfig(); 443 // Should we disable the max-width styling? 444 if ( !self::isSkinVersionLegacy() && $sk->getTitle() && self::shouldDisableMaxWidth( 445 $config->get( 'VectorMaxWidthOptions' ), 446 $sk->getTitle(), 447 $out->getRequest()->getValues() 448 ) ) { 449 $bodyAttrs['class'] .= ' skin-vector-disable-max-width'; 450 } 451 } 452 453 /** 454 * Per the $options configuration (for use with $wgVectorMaxWidthOptions) 455 * determine whether max-width should be disabled on the page. 456 * For the main page: Check the value of $options['exclude']['mainpage'] 457 * For all other pages, the following will happen: 458 * - the array $options['include'] of canonical page names will be checked 459 * against the current page. If a page has been listed there, function will return false 460 * (max-width will not be disabled) 461 * Max width is disabled if: 462 * 1) The current namespace is listed in array $options['exclude']['namespaces'] 463 * OR 464 * 2) The query string matches one of the name and value pairs $exclusions['querystring']. 465 * Note the wildcard "*" for a value, will match all query string values for the given 466 * query string parameter. 467 * 468 * @internal only for use inside tests. 469 * @param array $options 470 * @param Title $title 471 * @param array $requestValues 472 * @return bool 473 */ 474 public static function shouldDisableMaxWidth( array $options, Title $title, array $requestValues ) { 475 $canonicalTitle = $title->getRootTitle(); 476 477 $inclusions = $options['include'] ?? []; 478 $exclusions = $options['exclude'] ?? []; 479 480 if ( $title->isMainPage() ) { 481 // only one check to make 482 return $exclusions['mainpage'] ?? false; 483 } elseif ( $canonicalTitle->isSpecialPage() ) { 484 $canonicalTitle->fixSpecialName(); 485 } 486 487 // 488 // Check the inclusions based on the canonical title 489 // The inclusions are checked first as these trump any exclusions. 490 // 491 // Now we have the canonical title and the inclusions link we look for any matches. 492 foreach ( $inclusions as $titleText ) { 493 $includedTitle = Title::newFromText( $titleText ); 494 495 if ( $canonicalTitle->equals( $includedTitle ) ) { 496 return false; 497 } 498 } 499 500 // 501 // Check the exclusions 502 // If nothing matches the exclusions to determine what should happen 503 // 504 $excludeNamespaces = $exclusions['namespaces'] ?? []; 505 // Max width is disabled on certain namespaces 506 if ( $title->inNamespaces( $excludeNamespaces ) ) { 507 return true; 508 } 509 $excludeQueryString = $exclusions['querystring'] ?? []; 510 511 foreach ( $excludeQueryString as $param => $excludedParamValue ) { 512 $paramValue = $requestValues[$param] ?? false; 513 if ( $paramValue ) { 514 if ( $excludedParamValue === '*' ) { 515 // check wildcard 516 return true; 517 } elseif ( $paramValue === $excludedParamValue ) { 518 // Check if the excluded param value matches 519 return true; 520 } 521 } 522 } 523 524 return false; 525 } 526 527 /** 528 * NOTE: Please use ResourceLoaderGetConfigVars hook instead if possible 529 * for adding config to the page. 530 * Adds config variables to JS that depend on current page/request. 531 * 532 * Adds a config flag that can disable saving the VectorSidebarVisible 533 * user preference when the sidebar menu icon is clicked. 534 * 535 * @param array &$vars Array of variables to be added into the output. 536 * @param OutputPage $out OutputPage instance calling the hook 537 */ 538 public static function onMakeGlobalVariablesScript( &$vars, OutputPage $out ) { 539 if ( !$out->getSkin() instanceof SkinVector ) { 540 return; 541 } 542 543 $user = $out->getUser(); 544 545 if ( $user->isRegistered() && self::isSkinVersionLegacy() ) { 546 $vars[ 'wgVectorDisableSidebarPersistence' ] = 547 self::getConfig( 548 Constants::CONFIG_KEY_DISABLE_SIDEBAR_PERSISTENCE 549 ); 550 } 551 } 552 553 /** 554 * Get a configuration variable such as `Constants::CONFIG_KEY_SHOW_SKIN_PREFERENCES`. 555 * 556 * @param string $name Name of configuration option. 557 * @return mixed Value configured. 558 * @throws \ConfigException 559 */ 560 private static function getConfig( $name ) { 561 return self::getServiceConfig()->get( $name ); 562 } 563 564 /** 565 * @return \Config 566 */ 567 private static function getServiceConfig() { 568 return MediaWikiServices::getInstance()->getService( Constants::SERVICE_CONFIG ); 569 } 570 571 /** 572 * Gets whether the current skin version is the legacy version. 573 * 574 * @see VectorServices::getFeatureManager 575 * 576 * @return bool 577 */ 578 private static function isSkinVersionLegacy(): bool { 579 return !VectorServices::getFeatureManager()->isFeatureEnabled( Constants::FEATURE_LATEST_SKIN ); 580 } 581} 582