'#', 'id' => 'ca-talk-sticky-header', 'event' => 'talk-sticky-header', 'icon' => 'wikimedia-speechBubbles', 'is-quiet' => true, 'tabindex' => '-1', 'class' => 'sticky-header-icon' ]; private const SUBJECT_ICON = [ 'href' => '#', 'id' => 'ca-subject-sticky-header', 'event' => 'subject-sticky-header', 'icon' => 'wikimedia-article', 'is-quiet' => true, 'tabindex' => '-1', 'class' => 'sticky-header-icon' ]; private const HISTORY_ICON = [ 'href' => '#', 'id' => 'ca-history-sticky-header', 'event' => 'history-sticky-header', 'icon' => 'wikimedia-history', 'is-quiet' => true, 'tabindex' => '-1', 'class' => 'sticky-header-icon' ]; // Event and icon will be updated depending on watchstar state private const WATCHSTAR_ICON = [ 'href' => '#', 'id' => 'ca-watchstar-sticky-header', 'event' => 'watch-sticky-header', 'icon' => 'wikimedia-star', 'is-quiet' => true, 'tabindex' => '-1', 'class' => 'sticky-header-icon mw-watchlink' ]; private const EDIT_VE_ICON = [ 'href' => '#', 'id' => 'ca-ve-edit-sticky-header', 'event' => 've-edit-sticky-header', 'icon' => 'wikimedia-edit', 'is-quiet' => true, 'tabindex' => '-1', 'class' => 'sticky-header-icon' ]; private const EDIT_WIKITEXT_ICON = [ 'href' => '#', 'id' => 'ca-edit-sticky-header', 'event' => 'wikitext-edit-sticky-header', 'icon' => 'wikimedia-wikiText', 'is-quiet' => true, 'tabindex' => '-1', 'class' => 'sticky-header-icon' ]; private const EDIT_PROTECTED_ICON = [ 'href' => '#', 'id' => 'ca-viewsource-sticky-header', 'event' => 've-edit-protected-sticky-header', 'icon' => 'wikimedia-editLock', 'is-quiet' => true, 'tabindex' => '-1', 'class' => 'sticky-header-icon' ]; private const SEARCH_SHOW_THUMBNAIL_CLASS = 'vector-search-box-show-thumbnail'; private const SEARCH_AUTO_EXPAND_WIDTH_CLASS = 'vector-search-box-auto-expand-width'; private const CLASS_PROGRESSIVE = 'mw-ui-progressive'; abstract protected function isLegacy(): bool; /** * Calls getLanguages with caching. * @return array */ protected function getLanguagesCached(): array { if ( $this->languages === null ) { $this->languages = $this->getLanguages(); } return $this->languages; } /** * This should be upstreamed to the Skin class in core once the logic is finalized. * Returns false if the page is a special page without any languages, or if an action * other than view is being used. * @return bool */ private function canHaveLanguages(): bool { if ( $this->getContext()->getActionName() !== 'view' ) { return false; } $title = $this->getTitle(); // Defensive programming - if a special page has added languages explicitly, best to show it. if ( $title && $title->isSpecialPage() && empty( $this->getLanguagesCached() ) ) { return false; } return true; } /** * @param string $location Either 'top' or 'bottom' is accepted. * @return bool */ protected function isLanguagesInContentAt( $location ) { if ( !$this->canHaveLanguages() ) { return false; } $featureManager = VectorServices::getFeatureManager(); $inContent = $featureManager->isFeatureEnabled( Constants::FEATURE_LANGUAGE_IN_HEADER ); $isMainPage = $this->getTitle() ? $this->getTitle()->isMainPage() : false; switch ( $location ) { case 'top': return $isMainPage ? $inContent && $featureManager->isFeatureEnabled( Constants::FEATURE_LANGUAGE_IN_MAIN_PAGE_HEADER ) : $inContent; case 'bottom': return $inContent && $isMainPage && !$featureManager->isFeatureEnabled( Constants::FEATURE_LANGUAGE_IN_MAIN_PAGE_HEADER ); default: throw new RuntimeException( 'unknown language button location' ); } } /** * Whether or not the languages are out of the sidebar and in the content either at * the top or the bottom. * @return bool */ final protected function isLanguagesInContent() { return $this->isLanguagesInContentAt( 'top' ) || $this->isLanguagesInContentAt( 'bottom' ); } /** * Whether languages should be hidden. * FIXME: Function should be removed as part of T319355 * * @return bool */ abstract protected function shouldHideLanguages(): bool; /** * Returns HTML for the create account link inside the anon user links * @param string[] $returnto array of query strings used to build the login link * @return string */ private function getCreateAccountHTML( $returnto ) { $createAccountData = $this->buildCreateAccountData( $returnto ); $createAccountData = array_merge( $createAccountData, [ 'class' => [ 'vector-menu-content-item', ], 'collapsible' => true, 'icon' => $createAccountData['icon'], 'button' => false ] ); $createAccountData = Hooks::updateLinkData( $createAccountData ); return $this->makeLink( 'create-account', $createAccountData ); } /** * Returns HTML for the create account button, login button and learn more link inside the anon user menu * @param string[] $returnto array of query strings used to build the login link * @param bool $useCombinedLoginLink if a combined login/signup link will be used * @param bool $isTempUser * @param bool $includeLearnMoreLink Pass `true` to include the learn more * link in the menu for anon users. This param will be inert for temp users. * @return string */ private function getAnonMenuBeforePortletHTML( $returnto, $useCombinedLoginLink, $isTempUser, $includeLearnMoreLink ) { $templateParser = $this->getTemplateParser(); $loginLinkData = array_merge( $this->buildLoginData( $returnto, $useCombinedLoginLink ), [ 'class' => [ 'vector-menu-content-item', 'vector-menu-content-item-login' ], ] ); $loginLinkData = Hooks::updateLinkData( $loginLinkData ); $templateData = [ 'htmlCreateAccount' => $this->getCreateAccountHTML( $returnto ), 'htmlLogin' => $this->makeLink( 'login', $loginLinkData ), 'data-anon-editor' => [] ]; $templateName = $isTempUser ? 'UserLinks__templogin' : 'UserLinks__login'; if ( !$isTempUser && $includeLearnMoreLink ) { $learnMoreLinkData = [ 'text' => $this->msg( 'vector-anon-user-menu-pages-learn' )->text(), 'href' => Title::newFromText( $this->msg( 'vector-intro-page' )->text() )->getLocalURL(), 'aria-label' => $this->msg( 'vector-anon-user-menu-pages-label' )->text(), ]; $templateData['data-anon-editor'] = [ 'htmlLearnMoreLink' => $this->makeLink( '', $learnMoreLinkData ), 'msgLearnMore' => $this->msg( 'vector-anon-user-menu-pages' ) ]; } return $templateParser->processTemplate( $templateName, $templateData ); } /** * Returns HTML for the logout button that should be placed in the user (personal) menu * after the menu itself. * @return string */ private function getLogoutHTML() { $logoutLinkData = array_merge( $this->buildLogoutLinkData(), [ 'class' => [ 'vector-menu-content-item', 'vector-menu-content-item-logout' ], ] ); $logoutLinkData = Hooks::updateLinkData( $logoutLinkData ); $templateParser = $this->getTemplateParser(); return $templateParser->processTemplate( 'UserLinks__logout', [ 'htmlLogout' => $this->makeLink( 'logout', $logoutLinkData ) ] ); } /** * Returns template data for UserLinks.mustache * FIXME: Move to component (T322089) * * @param array $userMenuData existing menu template data to be transformed and copied for UserLinks * @param array $overflowMenuData existing menu template data to be transformed and copied for UserLinks * @param User $user the context user * @return array */ final protected function getUserLinksTemplateData( array $userMenuData, array $overflowMenuData, User $user ): array { $isAnon = !$user->isRegistered(); $isTempUser = $user->isTemp(); $returnto = $this->getReturnToParam(); $useCombinedLoginLink = $this->useCombinedLoginLink(); $userMenuOverflowData = Hooks::updateDropdownMenuData( $overflowMenuData ); $userMenuData = Hooks::updateDropdownMenuData( $this->getUserMenuPortletData( $userMenuData ) ); unset( $userMenuOverflowData[ 'label' ] ); if ( $isAnon || $isTempUser ) { $userMenuData[ 'html-before-portal' ] .= $this->getAnonMenuBeforePortletHTML( $returnto, $useCombinedLoginLink, $isTempUser, // T317789: The `anontalk` and `anoncontribs` links will not be added to // the menu if `$wgGroupPermissions['*']['edit']` === false which can // leave the menu empty due to our removal of other user menu items in // `Hooks::updateUserLinksDropdownItems`. In this case, we do not want // to render the anon "learn more" link. !$userMenuData['is-empty'] ); } else { // Appending as to not override data potentially set by the onSkinAfterPortlet hook. $userMenuData[ 'html-after-portal' ] .= $this->getLogoutHTML(); } $moreItems = substr_count( $userMenuOverflowData['html-items'], ' $moreItems > 3, 'data-user-menu-overflow' => $userMenuOverflowData, 'data-user-menu' => $userMenuData ]; } /** * @inheritDoc */ protected function runOnSkinTemplateNavigationHooks( SkinTemplate $skin, &$content_navigation ) { parent::runOnSkinTemplateNavigationHooks( $skin, $content_navigation ); Hooks::onSkinTemplateNavigation( $skin, $content_navigation ); } /** * Check whether ULS is enabled * * @return bool */ final protected function isULSExtensionEnabled(): bool { return ExtensionRegistry::getInstance()->isLoaded( 'UniversalLanguageSelector' ); } /** * Change the portlets menu so the label is the selected variant * @param array $portletData * @return array */ final protected function updateVariantsMenuLabel( array $portletData ): array { $languageConverterFactory = MediaWikiServices::getInstance()->getLanguageConverterFactory(); $pageLang = $this->getTitle()->getPageLanguage(); $converter = $languageConverterFactory->getLanguageConverter( $pageLang ); $portletData['label'] = $pageLang->getVariantname( $converter->getPreferredVariant() ); // T289523 Add aria-label data to the language variant switcher. $portletData['aria-label'] = $this->msg( 'vector-language-variant-switcher-label' ); return $portletData; } /** * Generate data needed to generate the sticky header. * @param array $searchBoxData * @param bool $includeEditIcons * @return array */ final protected function getStickyHeaderData( $searchBoxData, $includeEditIcons ): array { $btns = [ self::TALK_ICON, self::SUBJECT_ICON, self::HISTORY_ICON, self::WATCHSTAR_ICON, ]; if ( $includeEditIcons ) { $btns[] = self::EDIT_WIKITEXT_ICON; $btns[] = self::EDIT_PROTECTED_ICON; $btns[] = self::EDIT_VE_ICON; } $btns[] = $this->getAddSectionButtonData(); $tocPortletData = Hooks::updateDropdownMenuData( [ 'id' => 'p-sticky-header-toc', 'class' => 'mw-portlet mw-portlet-sticky-header-toc vector-sticky-header-toc', 'html-items' => '', 'html-vector-menu-checkbox-attributes' => 'tabindex="-1"', 'html-vector-menu-heading-attributes' => 'tabindex="-1"', 'button' => true, 'text-hidden' => true, 'icon' => 'listBullet' ] ); // Show sticky ULS if the ULS extension is enabled and the ULS in header is not hidden $showStickyULS = $this->isULSExtensionEnabled() && !$this->shouldHideLanguages(); return [ 'data-sticky-header-toc' => $tocPortletData, 'data-primary-action' => $showStickyULS ? $this->getULSButtonData() : null, 'data-button-start' => [ 'label' => $this->msg( 'search' ), 'icon' => 'wikimedia-search', 'is-quiet' => true, 'tabindex' => '-1', 'class' => 'vector-sticky-header-search-toggle', 'event' => 'ui.' . $searchBoxData['form-id'] . '.icon' ], 'data-search' => $searchBoxData, 'data-buttons' => $btns, ]; } /** * @inheritDoc */ public function getTemplateData(): array { $skin = $this; $parentData = parent::getTemplateData(); // // Naming conventions for Mustache parameters. // // Value type (first segment): // - Prefix "is" or "has" for boolean values. // - Prefix "msg-" for interface message text. // - Prefix "html-" for raw HTML. // - Prefix "data-" for an array of template parameters that should be passed directly // to a template partial. // - Prefix "array-" for lists of any values. // // Source of value (first or second segment) // - Segment "page-" for data relating to the current page (e.g. Title, WikiPage, or OutputPage). // - Segment "hook-" for any thing generated from a hook. // It should be followed by the name of the hook in hyphenated lowercase. // // Conditionally used values must use null to indicate absence (not false or ''). $commonSkinData = array_merge( $parentData, [ 'is-legacy' => $this->isLegacy(), 'input-location' => $this->getSearchBoxInputLocation(), 'data-search-box' => $this->getSearchData( $parentData['data-search-box'], !$this->isLegacy(), // is primary mode of search true, 'searchform', true ) ] ); return $commonSkinData; } /** * Annotates search box with Vector-specific information * * @param array $searchBoxData * @param bool $isCollapsible * @param bool $isPrimary * @param string $formId * @param bool $autoExpandWidth * @return array modified version of $searchBoxData */ final protected function getSearchData( array $searchBoxData, bool $isCollapsible, bool $isPrimary, string $formId, bool $autoExpandWidth ) { $searchClass = 'vector-search-box-vue '; if ( $isCollapsible ) { $searchClass .= ' vector-search-box-collapses '; } if ( $this->doesSearchHaveThumbnails() ) { $searchClass .= ' ' . self::SEARCH_SHOW_THUMBNAIL_CLASS . ( $autoExpandWidth ? ' ' . self::SEARCH_AUTO_EXPAND_WIDTH_CLASS : '' ); } // Annotate search box with a component class. $searchBoxData['class'] = trim( $searchClass ); $searchBoxData['is-collapsible'] = $isCollapsible; $searchBoxData['is-primary'] = $isPrimary; $searchBoxData['form-id'] = $formId; // At lower resolutions the search input is hidden search and only the submit button is shown. // It should behave like a form submit link (e.g. submit the form with no input value). // We'll wire this up in a later task T284242. $collapseIconAttrs = Linker::tooltipAndAccesskeyAttribs( 'search' ); $searchBoxData['data-collapse-icon'] = array_merge( [ 'href' => Title::newFromText( $searchBoxData['page-title'] )->getLocalUrl(), 'label' => $this->msg( 'search' ), 'icon' => 'wikimedia-search', 'is-quiet' => true, 'class' => 'search-toggle', ], $collapseIconAttrs ); return $searchBoxData; } /** * Gets the value of the "input-location" parameter for the SearchBox Mustache template. * * @return string Either `Constants::SEARCH_BOX_INPUT_LOCATION_DEFAULT` or * `Constants::SEARCH_BOX_INPUT_LOCATION_MOVED` */ private function getSearchBoxInputLocation(): string { if ( $this->isLegacy() ) { return Constants::SEARCH_BOX_INPUT_LOCATION_DEFAULT; } return Constants::SEARCH_BOX_INPUT_LOCATION_MOVED; } /** * @inheritDoc */ public function isResponsive() { // Check it's enabled by user preference and configuration $responsive = parent::isResponsive() && $this->getConfig()->get( 'VectorResponsive' ); // For historic reasons, the viewport is added when Vector is loaded on the mobile // domain. This is only possible for 3rd parties or by useskin parameter as there is // no preference for changing mobile skin. Only need to check if $responsive is falsey. if ( !$responsive && ExtensionRegistry::getInstance()->isLoaded( 'MobileFrontend' ) ) { $mobFrontContext = MediaWikiServices::getInstance()->getService( 'MobileFrontend.Context' ); if ( $mobFrontContext->shouldDisplayMobileView() ) { return true; } } return $responsive; } /** * Returns `true` if Vue search is enabled to show thumbnails and `false` otherwise. * Note this is only relevant for Vue search experience (not legacy search). * * @return bool */ private function doesSearchHaveThumbnails(): bool { return $this->getConfig()->get( 'VectorWvuiSearchOptions' )['showThumbnail']; } /** * Get the ULS button label, accounting for the number of available * languages. * * @return array */ private function getULSLabels(): array { $numLanguages = count( $this->getLanguagesCached() ); if ( $numLanguages === 0 ) { return [ 'label' => $this->msg( 'vector-no-language-button-label' )->text(), 'aria-label' => $this->msg( 'vector-no-language-button-aria-label' )->text() ]; } else { return [ 'label' => $this->msg( 'vector-language-button-label' )->numParams( $numLanguages )->escaped(), 'aria-label' => $this->msg( 'vector-language-button-aria-label' )->numParams( $numLanguages )->escaped() ]; } } /** * Creates button data for the "Add section" button in the sticky header * * @return array */ private function getAddSectionButtonData() { return [ 'href' => '#', 'id' => 'ca-addsection-sticky-header', 'event' => 'addsection-sticky-header', 'html-vector-button-icon' => Hooks::makeIcon( 'wikimedia-speechBubbleAdd-progressive' ), 'label' => $this->msg( [ 'vector-2022-action-addsection', 'skin-action-addsection' ] ), 'is-quiet' => true, 'tabindex' => '-1', 'class' => 'sticky-header-icon mw-ui-primary mw-ui-progressive' ]; } /** * Creates button data for the ULS button in the sticky header * * @return array */ private function getULSButtonData() { $numLanguages = count( $this->getLanguagesCached() ); return [ 'id' => 'p-lang-btn-sticky-header', 'class' => 'mw-interlanguage-selector', 'is-quiet' => true, 'tabindex' => '-1', 'label' => $this->getULSLabels()[ 'label' ], 'html-vector-button-icon' => Hooks::makeIcon( 'wikimedia-language' ), 'event' => 'ui.dropdown-p-lang-btn-sticky-header' ]; } /** * Creates portlet data for the ULS button in the header * * @param array $langData * @param int $numLanguages * @param bool $atTop * @return array */ final protected function getULSPortletData( array $langData, int $numLanguages, bool $atTop ) { $className = $langData['class'] ?? ''; $languageButtonData = [ 'id' => 'p-lang-btn', 'label' => $this->getULSLabels()['label'], 'aria-label' => $this->getULSLabels()['aria-label'], // ext.uls.interface attaches click handler to this selector. 'checkbox-class' => ' mw-interlanguage-selector ', 'icon' => 'language-progressive', 'class' => $atTop ? $className . ' mw-ui-icon-flush-right' : $className, 'button' => true, 'heading-class' => self::CLASS_PROGRESSIVE . ' mw-portlet-lang-heading-' . strval( $numLanguages ), ]; // Adds class to hide language button // Temporary solution to T287206, can be removed when ULS dialog includes interwiki links if ( $this->shouldHideLanguages() ) { $languageButtonData['class'] .= ' mw-portlet-empty'; } return Hooks::updateDropdownMenuData( array_merge( $langData, $languageButtonData ) ); } /** * Creates portlet data for the user menu dropdown * FIXME: Move to SkinVector22 * * @param array $portletData * @return array */ private function getUserMenuPortletData( $portletData ) { // T317789: Core can undesirably add an 'emptyPortlet' class that hides the // user menu. This is a result of us manually removing items from the menu // in Hooks::updateUserLinksDropdownItems which can make // SkinTemplate::getPortletData apply the `emptyPortlet` class if there are // no menu items. Since we subsequently add menu items in // SkinVector::getUserLinksTemplateData, the `emptyPortlet` class is // innaccurate. This is why we add the desired classes, `mw-portlet` and // `mw-portlet-personal` here instead. This can potentially be removed upon // completion of T319356. // // Also, add target class to apply different icon to personal menu dropdown for logged in users. $portletData['class'] = 'mw-portlet mw-portlet-personal vector-user-menu vector-menu-dropdown'; $portletData['class'] .= $this->loggedin ? ' vector-user-menu-logged-in' : ' vector-user-menu-logged-out'; if ( $this->getUser()->isTemp() ) { $icon = 'userAnonymous'; } elseif ( $this->loggedin ) { $icon = 'userAvatar'; } else { $icon = 'ellipsis'; // T287494 We use tooltip messages to provide title attributes on hover over certain menu icons. // For modern Vector, the "tooltip-p-personal" key is set to "User menu" which is appropriate for // the user icon (dropdown indicator for user links menu) for logged-in users. // This overrides the tooltip for the user links menu icon which is an ellipsis for anonymous users. $portletData['html-tooltip'] = Linker::tooltip( 'vector-anon-user-menu-title' ); } $portletData['icon'] = $icon; $portletData['button'] = true; $portletData['text-hidden'] = true; return $portletData; } }