wikimedia/mediawiki-core

View on GitHub
includes/skins/components/SkinComponentFooter.php

Summary

Maintainability
C
1 day
Test Coverage
<?php

namespace MediaWiki\Skin;

use Action;
use Article;
use CreditsAction;
use MediaWiki\Config\Config;
use MediaWiki\HookContainer\ProtectedHookAccessorTrait;
use MediaWiki\Html\Html;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Title\Title;

class SkinComponentFooter implements SkinComponent {
    use ProtectedHookAccessorTrait;

    /** @var SkinComponentRegistryContext */
    private $skinContext;

    /**
     * @param SkinComponentRegistryContext $skinContext
     */
    public function __construct( SkinComponentRegistryContext $skinContext ) {
        $this->skinContext = $skinContext;
    }

    /**
     * Run SkinAddFooterLinks hook on menu data to insert additional menu items specifically in footer.
     *
     * @return array
     */
    private function getTemplateDataFooter(): array {
        $data = [
            'info' => $this->formatFooterInfoData(
                $this->getFooterInfoData()
            ),
            'places' => $this->getSiteFooterLinks(),
        ];
        $skin = $this->skinContext->getContextSource()->getSkin();
        foreach ( $data as $key => $existingItems ) {
            $newItems = [];
            $this->getHookRunner()->onSkinAddFooterLinks( $skin, $key, $newItems );
            foreach ( $newItems as $index => $linkHTML ) {
                $data[ $key ][ $index ] = [
                    'id' => 'footer-' . $key . '-' . $index,
                    'html' => $linkHTML,
                ];
            }
        }
        return $data;
    }

    /**
     * @inheritDoc
     */
    public function getTemplateData(): array {
        $footerData = $this->getTemplateDataFooter();

        // Create the menu components from the footer data.
        $footerInfoMenuData = new SkinComponentMenu(
            'footer-info',
            $footerData['info'],
            $this->skinContext->getMessageLocalizer()
        );
        $footerSiteMenuData = new SkinComponentMenu(
            'footer-places',
            $footerData['places'],
            $this->skinContext->getMessageLocalizer()
        );

        // To conform the footer menu data to the current SkinMustache specification,
        // run the derived data through a cleanup function to unset unexpected data properties
        // until the spec is updated to reflect the new properties introduced by the menu component.
        // See https://www.mediawiki.org/wiki/Manual:SkinMustache.php#DataFooter
        $footerMenuData = [];
        $footerMenuData['data-info'] = $footerInfoMenuData->getTemplateData();
        $footerMenuData['data-places'] = $footerSiteMenuData->getTemplateData();
        $footerMenuData['data-icons'] = $this->getFooterIcons();
        $footerMenuData = $this->formatFooterDataForCurrentSpec( $footerMenuData );

        return [
            'data-info' => $footerMenuData['data-info'],
            'data-places' => $footerMenuData['data-places'],
            'data-icons' => $footerMenuData['data-icons']
        ];
    }

    /**
     * Get the footer data containing standard footer links.
     *
     * All values are resolved and can be added to by the
     * SkinAddFooterLinks hook.
     *
     * @since 1.40
     * @internal
     * @return array
     */
    private function getFooterInfoData(): array {
        $action = null;
        $skinContext = $this->skinContext;
        $out = $skinContext->getOutput();
        $ctx = $skinContext->getContextSource();
        // This needs to be the relevant Title rather than just the raw Title for e.g. special pages that render content
        $title = $skinContext->getRelevantTitle();
        $titleExists = $title && $title->exists();
        $config = $skinContext->getConfig();
        $maxCredits = $config->get( MainConfigNames::MaxCredits );
        $showCreditsIfMax = $config->get( MainConfigNames::ShowCreditsIfMax );
        $useCredits = $titleExists
            && $out->isArticle()
            && $out->isRevisionCurrent()
            && $maxCredits !== 0;

        /** @var CreditsAction $action */
        if ( $useCredits ) {
            $article = Article::newFromWikiPage( $skinContext->getWikiPage(), $ctx );
            $action = Action::factory( 'credits', $article, $ctx );
        }

        '@phan-var CreditsAction $action';
        return [
            'lastmod' => !$useCredits ? $this->lastModified() : null,
            'numberofwatchingusers' => null,
            'credits' => $useCredits && $action ?
                $action->getCredits( $maxCredits, $showCreditsIfMax ) : null,
            'copyright' => $titleExists &&
            $out->showsCopyright() ? $this->getCopyright() : null,
        ];
    }

    /**
     * @return string
     */
    private function getCopyright() {
        $copyright = new SkinComponentCopyright( $this->skinContext );
        return $copyright->getTemplateData()[ 'html' ];
    }

    /**
     * Format the footer data containing standard footer links for passing
     * into SkinComponentMenu.
     *
     * @since 1.40
     * @internal
     * @param array $data raw footer data
     * @return array
     */
    private function formatFooterInfoData( array $data ): array {
        $formattedData = [];
        foreach ( $data as $key => $item ) {
            if ( $item ) {
                $formattedData[ $key ] = [
                    'id' => 'footer-info-' . $key,
                    'html' => $item
                ];
            }
        }
        return $formattedData;
    }

    /**
     * Gets the link to the wiki's privacy policy, about page, and disclaimer page
     *
     * @internal
     * @return array data array for 'privacy', 'about', 'disclaimer'
     */
    private function getSiteFooterLinks(): array {
        $siteLinksData = [];
        $siteLinks = [
            'privacy' => [ 'privacy', 'privacypage' ],
            'about' => [ 'aboutsite', 'aboutpage' ],
            'disclaimers' => [ 'disclaimers', 'disclaimerpage' ]
        ];
        $localizer = $this->skinContext->getMessageLocalizer();

        foreach ( $siteLinks as $key => $siteLink ) {
            // Check if the link description has been disabled in the default language.
            // If disabled, it is disabled for all languages.
            if ( !$localizer->msg( $siteLink[0] )->inContentLanguage()->isDisabled() ) {
                // Display the link for the user, described in their language (which may or may not be the same as the
                // default language), but make the link target be the one site-wide page.
                $title = Title::newFromText( $localizer->msg( $siteLink[1] )->inContentLanguage()->text() );
                if ( $title !== null ) {
                    $siteLinksData[$key] = [
                        'id' => "footer-places-$key",
                        'text' => $localizer->msg( $siteLink[0] )->text(),
                        'href' => $title->fixSpecialName()->getLinkURL()
                    ];
                }
            }
        }
        return $siteLinksData;
    }

    /**
     * Renders a $wgFooterIcons icon according to the method's arguments
     *
     * @param Config $config
     * @param array|string $icon The icon to build the html for, see $wgFooterIcons
     *   for the format of this array.
     * @param string $withImage Whether to use the icon's image or output
     *   a text-only footer icon.
     * @return string HTML
     * @internal for use in Skin only
     */
    public static function makeFooterIconHTML( Config $config, $icon, string $withImage = 'withImage' ): string {
        if ( is_string( $icon ) ) {
            $html = $icon;
        } else { // Assuming array
            $url = $icon['url'] ?? null;
            unset( $icon['url'] );
            if ( isset( $icon['src'] ) && $withImage === 'withImage' ) {
                // Lazy-load footer icons, since they're not part of the printed view.
                $icon['loading'] = 'lazy';
                // do this the lazy way, just pass icon data as an attribute array
                $html = Html::element( 'img', $icon );
            } else {
                $html = htmlspecialchars( $icon['alt'] ?? '' );
            }
            if ( $url ) {
                $html = Html::rawElement(
                    'a',
                    [
                        'href' => $url,
                        // Using a fake Codex link button, as this is the long-expected UX; our apologies.
                        'class' => [
                            'cdx-button', 'cdx-button--fake-button',
                            'cdx-button--size-large', 'cdx-button--fake-button--enabled'
                        ],
                        'target' => $config->get( MainConfigNames::ExternalLinkTarget ),
                    ],
                    $html
                );
            }
        }
        return $html;
    }

    /**
     * Get data representation of icons
     *
     * @internal for use in Skin only
     * @param Config $config
     * @return array
     */
    public static function getFooterIconsData( Config $config ) {
        $footericons = [];
        foreach (
            $config->get( MainConfigNames::FooterIcons ) as $footerIconsKey => &$footerIconsBlock
        ) {
            if ( count( $footerIconsBlock ) > 0 ) {
                $footericons[$footerIconsKey] = [];
                foreach ( $footerIconsBlock as &$footerIcon ) {
                    if ( isset( $footerIcon['src'] ) ) {
                        if ( !isset( $footerIcon['width'] ) ) {
                            $footerIcon['width'] = 88;
                        }
                        if ( !isset( $footerIcon['height'] ) ) {
                            $footerIcon['height'] = 31;
                        }
                    }

                    // Only output icons which have an image.
                    // For historic reasons this mimics the `icononly` option
                    // for BaseTemplate::getFooterIcons.
                    // In some cases the icon may be an empty array.
                    // Filter these out. (See T269776)
                    if ( is_string( $footerIcon ) || isset( $footerIcon['src'] ) ) {
                        $footericons[$footerIconsKey][] = $footerIcon;
                    }
                }

                // If no valid icons with images were added, unset the parent array
                // Should also prevent empty arrays from when no copyright is set.
                if ( !count( $footericons[$footerIconsKey] ) ) {
                    unset( $footericons[$footerIconsKey] );
                }
            }
        }
        return $footericons;
    }

    /**
     * Gets the link to the wiki's privacy policy, about page, and disclaimer page
     *
     * @internal
     * @return array data array for 'privacy', 'about', 'disclaimer'
     * @suppress SecurityCheck-DoubleEscaped
     */
    private function getFooterIcons(): array {
        $dataIcons = [];
        $skinContext = $this->skinContext;
        // If footer icons are enabled append to the end of the rows
        $footerIcons = $skinContext->getFooterIcons();

        if ( count( $footerIcons ) > 0 ) {
            $icons = [];
            foreach ( $footerIcons as $blockName => $blockIcons ) {
                $html = '';
                foreach ( $blockIcons as $icon ) {
                    $html .= $skinContext->makeFooterIcon( $icon );
                }
                // For historic reasons this mimics the `icononly` option
                // for BaseTemplate::getFooterIcons. Empty rows should not be output.
                if ( $html ) {
                    $block = htmlspecialchars( $blockName );
                    $icons[$block] = [
                        'name' => $block,
                        'id' => 'footer-' . $block . 'ico',
                        'html' => $html,
                        'class' => [ 'noprint' ],
                    ];
                }
            }

            // Empty rows should not be output.
            // This is how Vector has behaved historically but we can revisit later if necessary.
            if ( count( $icons ) > 0 ) {
                $dataIcons = new SkinComponentMenu(
                    'footer-icons',
                    $icons,
                    $this->skinContext->getMessageLocalizer(),
                    '',
                    []
                );
            }
        }

        return $dataIcons ? $dataIcons->getTemplateData() : [];
    }

    /**
     * Get finalized footer menu data and reformat to fit current specification.
     *
     * See https://www.mediawiki.org/wiki/Manual:SkinMustache.php#DataFooter
     * This method should be removed once the specification is updated and
     * new data properties provided by the menu component are ok to output.
     *
     * @internal
     * @param array $data
     * @return array
     */
    private function formatFooterDataForCurrentSpec( array $data ): array {
        $formattedData = [];
        foreach ( $data as $key => $item ) {
            unset( $item['html-tooltip'] );
            unset( $item['html-items'] );
            unset( $item['html-after-portal'] );
            unset( $item['html-before-portal'] );
            unset( $item['label'] );
            unset( $item['class'] );
            foreach ( $item['array-items'] ?? [] as $index => $arrayItem ) {
                unset( $item['array-items'][$index]['html-item'] );
            }
            $formattedData[$key] = $item;
            $formattedData[$key]['className'] = $key === 'data-icons' ? 'noprint' : null;
        }
        return $formattedData;
    }

    /**
     * Get the timestamp of the latest revision, formatted in user language
     *
     * @internal for use in Skin.php only
     * @return string
     */
    private function lastModified() {
        $skinContext = $this->skinContext;
        $out = $skinContext->getOutput();
        $timestamp = $out->getRevisionTimestamp();

        // No cached timestamp, load it from the database
        // TODO: This code shouldn't be necessary, revision ID should always be available
        // Move this logic to OutputPage::getRevisionTimestamp if needed.
        if ( $timestamp === null ) {
            $revId = $out->getRevisionId();
            if ( $revId !== null ) {
                $timestamp = MediaWikiServices::getInstance()->getRevisionLookup()->getTimestampFromId( $revId );
            }
        }

        $lastModified = new SkinComponentLastModified(
            $skinContext,
            $timestamp
        );

        return $lastModified->getTemplateData()['text'];
    }
}