includes/MobileFrontendHooks.php
<?php
// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
use MediaWiki\Actions\ActionEntryPoint;
use MediaWiki\Api\ApiQuerySiteinfo;
use MediaWiki\Api\Hook\APIQuerySiteInfoGeneralInfoHook;
use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\Auth\AuthManager;
use MediaWiki\Cache\Hook\HTMLFileCache__useFileCacheHook;
use MediaWiki\ChangeTags\Hook\ChangeTagsListActiveHook;
use MediaWiki\ChangeTags\Hook\ListDefinedTagsHook;
use MediaWiki\ChangeTags\Taggable;
use MediaWiki\Config\Config;
use MediaWiki\Context\IContextSource;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\Gadgets\GadgetRepo;
use MediaWiki\Hook\LoginFormValidErrorMessagesHook;
use MediaWiki\Hook\ManualLogEntryBeforePublishHook;
use MediaWiki\Hook\MediaWikiPerformActionHook;
use MediaWiki\Hook\PostLoginRedirectHook;
use MediaWiki\Hook\RecentChange_saveHook;
use MediaWiki\Hook\RequestContextCreateSkinHook;
use MediaWiki\Hook\SkinAddFooterLinksHook;
use MediaWiki\Hook\SkinAfterBottomScriptsHook;
use MediaWiki\Hook\TitleSquidURLsHook;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Html\Html;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Output\Hook\AfterBuildFeedLinksHook;
use MediaWiki\Output\Hook\BeforePageDisplayHook;
use MediaWiki\Output\Hook\BeforePageRedirectHook;
use MediaWiki\Output\Hook\GetCacheVaryCookiesHook;
use MediaWiki\Output\Hook\MakeGlobalVariablesScriptHook;
use MediaWiki\Output\Hook\OutputPageBeforeHTMLHook;
use MediaWiki\Output\Hook\OutputPageBodyAttributesHook;
use MediaWiki\Output\Hook\OutputPageParserOutputHook;
use MediaWiki\Output\OutputPage;
use MediaWiki\Page\Hook\ArticleParserOptionsHook;
use MediaWiki\Page\Hook\BeforeDisplayNoArticleTextHook;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Preferences\Hook\GetPreferencesHook;
use MediaWiki\Registration\ExtensionRegistry;
use MediaWiki\Request\WebRequest;
use MediaWiki\ResourceLoader as RL;
use MediaWiki\ResourceLoader\Hook\ResourceLoaderSiteModulePagesHook;
use MediaWiki\ResourceLoader\Hook\ResourceLoaderSiteStylesModulePagesHook;
use MediaWiki\ResourceLoader\ResourceLoader;
use MediaWiki\SpecialPage\Hook\AuthChangeFormFieldsHook;
use MediaWiki\SpecialPage\Hook\SpecialPage_initListHook;
use MediaWiki\SpecialPage\Hook\SpecialPageBeforeExecuteHook;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;
use MediaWiki\User\Hook\UserGetDefaultOptionsHook;
use MediaWiki\User\Options\UserOptionsLookup;
use MediaWiki\User\User;
use MediaWiki\Utils\UrlUtils;
use MediaWiki\Watchlist\WatchlistManager;
use MobileFrontend\Api\ApiParseExtender;
use MobileFrontend\ContentProviders\DefaultContentProvider;
use MobileFrontend\Features\FeaturesManager;
use MobileFrontend\Hooks\HookRunner;
use MobileFrontend\Models\MobilePage;
use MobileFrontend\Transforms\LazyImageTransform;
use MobileFrontend\Transforms\MakeSectionsTransform;
/**
* Hook handlers for MobileFrontend extension
*
* If your hook changes the behaviour of the Minerva skin, you are in the wrong place.
* Any changes relating to Minerva should go into Minerva.hooks.php
*/
class MobileFrontendHooks implements
APIQuerySiteInfoGeneralInfoHook,
AuthChangeFormFieldsHook,
RequestContextCreateSkinHook,
BeforeDisplayNoArticleTextHook,
OutputPageBeforeHTMLHook,
OutputPageBodyAttributesHook,
ResourceLoaderSiteStylesModulePagesHook,
ResourceLoaderSiteModulePagesHook,
SkinAfterBottomScriptsHook,
SkinAddFooterLinksHook,
BeforePageRedirectHook,
MediaWikiPerformActionHook,
GetCacheVaryCookiesHook,
SpecialPage_initListHook,
ListDefinedTagsHook,
ChangeTagsListActiveHook,
RecentChange_saveHook,
SpecialPageBeforeExecuteHook,
PostLoginRedirectHook,
BeforePageDisplayHook,
GetPreferencesHook,
OutputPageParserOutputHook,
ArticleParserOptionsHook,
HTMLFileCache__useFileCacheHook,
LoginFormValidErrorMessagesHook,
AfterBuildFeedLinksHook,
MakeGlobalVariablesScriptHook,
TitleSquidURLsHook,
UserGetDefaultOptionsHook,
ManualLogEntryBeforePublishHook
{
private const MOBILE_PREFERENCES_SECTION = 'rendering/mobile';
public const MOBILE_PREFERENCES_SPECIAL_PAGES = 'mobile-specialpages';
public const MOBILE_PREFERENCES_EDITOR = 'mobile-editor';
public const MOBILE_PREFERENCES_FONTSIZE = 'mf-font-size';
public const MOBILE_PREFERENCES_EXPAND_SECTIONS = 'mf-expand-sections';
private const ENABLE_SPECIAL_PAGE_OPTIMISATIONS = '1';
// This should always be kept in sync with Codex `@min-width-breakpoint-tablet`
// in mediawiki.skin.variables.less
private const DEVICE_WIDTH_TABLET = '640px';
private HookContainer $hookContainer;
private Config $config;
private SkinFactory $skinFactory;
private UserOptionsLookup $userOptionsLookup;
private WatchlistManager $watchlistManager;
private MobileContext $mobileContext;
private FeaturesManager $featuresManager;
private ?GadgetRepo $gadgetRepo;
public function __construct(
HookContainer $hookContainer,
Config $config,
SkinFactory $skinFactory,
UserOptionsLookup $userOptionsLookup,
WatchlistManager $watchlistManager,
MobileContext $mobileContext,
FeaturesManager $featuresManager,
?GadgetRepo $gadgetRepo
) {
$this->hookContainer = $hookContainer;
$this->config = $config;
$this->skinFactory = $skinFactory;
$this->userOptionsLookup = $userOptionsLookup;
$this->watchlistManager = $watchlistManager;
$this->mobileContext = $mobileContext;
$this->featuresManager = $featuresManager;
$this->gadgetRepo = $gadgetRepo;
}
/**
* Obtain the default mobile skin
*
* @throws SkinException If a factory function isn't registered for the skin name
* @return Skin
*/
protected function getDefaultMobileSkin(): Skin {
$defaultSkin = $this->config->get( 'DefaultMobileSkin' ) ?:
$this->config->get( MainConfigNames::DefaultSkin );
return $this->skinFactory->makeSkin( Skin::normalizeKey( $defaultSkin ) );
}
/**
* RequestContextCreateSkin hook handler
* @see https://www.mediawiki.org/wiki/Manual:Hooks/RequestContextCreateSkin
*
* @param IContextSource $context The RequestContext object the skin is being created for.
* @param Skin|null|string &$skin A variable reference you may set a Skin instance or string
* key on to override the skin that will be used for the context.
* @return bool
*/
public function onRequestContextCreateSkin( $context, &$skin ) {
$mobileContext = $this->mobileContext;
$mobileContext->doToggling();
if ( !$mobileContext->shouldDisplayMobileView() ) {
return true;
}
// Handle any X-Analytics header values in the request by adding them
// as log items. X-Analytics header values are serialized key=value
// pairs, separated by ';', used for analytics purposes.
$xanalytics = $mobileContext->getRequest()->getHeader( 'X-Analytics' );
if ( $xanalytics ) {
$xanalytics_arr = explode( ';', $xanalytics );
if ( count( $xanalytics_arr ) > 1 ) {
foreach ( $xanalytics_arr as $xanalytics_item ) {
$mobileContext->addAnalyticsLogItemFromXAnalytics( $xanalytics_item );
}
} else {
$mobileContext->addAnalyticsLogItemFromXAnalytics( $xanalytics );
}
}
// log whether user is using beta/stable
$mobileContext->logMobileMode();
// Allow overriding of skin by useskin e.g. useskin=vector&useformat=mobile or by
// setting the mobileskin preferences (api only currently)
$userSkin = $context->getRequest()->getRawVal( 'useskin' ) ??
$this->userOptionsLookup->getOption(
$context->getUser(), 'mobileskin'
);
if ( $userSkin && Skin::normalizeKey( $userSkin ) === $userSkin ) {
$skin = $this->skinFactory->makeSkin( $userSkin );
} else {
$skin = $this->getDefaultMobileSkin();
}
$hookRunner = new HookRunner( $this->hookContainer );
$hookRunner->onRequestContextCreateSkinMobile( $mobileContext, $skin );
return false;
}
/**
* Update the footer
* @param Skin $skin
* @param string $key the current key for the current group (row) of footer links.
* e.g. `info` or `places`.
* @param array &$footerLinks an empty array that can be populated with new links.
* keys should be strings and will be used for generating the ID of the footer item
* and value should be an HTML string.
*/
public function onSkinAddFooterLinks( Skin $skin, string $key, array &$footerLinks ) {
$context = $this->mobileContext;
if ( $key === 'places' ) {
if ( $context->shouldDisplayMobileView() ) {
$terms = MobileFrontendSkinHooks::getTermsLink( $skin );
if ( $terms ) {
$footerLinks['terms-use'] = $terms;
}
$footerLinks['desktop-toggle'] = MobileFrontendSkinHooks::getDesktopViewLink( $skin, $context );
} else {
// If desktop site append a mobile view link
$footerLinks['mobileview'] =
MobileFrontendSkinHooks::getMobileViewLink( $skin, $context );
}
}
}
/**
* SkinAfterBottomScripts hook handler
* @see https://www.mediawiki.org/wiki/Manual:Hooks/SkinAfterBottomScripts
*
* Adds an inline script for lazy loading the images in Grade C browsers.
*
* @param Skin $skin
* @param string &$html bottomScripts text. Append to $text to add additional
* text/scripts after the stock bottom scripts.
*/
public function onSkinAfterBottomScripts( $skin, &$html ) {
// TODO: We may want to enable the following script on Desktop Minerva...
// ... when Minerva is widely used.
if (
$this->mobileContext->shouldDisplayMobileView() &&
$this->featuresManager->isFeatureAvailableForCurrentUser( 'MFLazyLoadImages' )
) {
$html .= Html::inlineScript( ResourceLoader::filter( 'minify-js',
LazyImageTransform::gradeCImageSupport()
) );
}
}
/**
* BeforeDisplayNoArticleText hook handler
* @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforeDisplayNoArticleText
*
* @param Article $article The (empty) article
* @return bool This hook can abort
*/
public function onBeforeDisplayNoArticleText( $article ) {
$displayMobileView = $this->mobileContext->shouldDisplayMobileView();
$title = $article->getTitle();
// if the page is a userpage
// @todo: Upstream to core (T248347).
if ( $displayMobileView &&
$title->inNamespaces( NS_USER ) &&
!$title->isSubpage()
) {
$out = $article->getContext()->getOutput();
$userpagetext = ExtMobileFrontend::blankUserPageHTML( $out, $title );
if ( $userpagetext ) {
// Replace the default message with ours
$out->addHTML( $userpagetext );
return false;
}
}
return true;
}
/**
* OutputPageBeforeHTML hook handler
* @see https://www.mediawiki.org/wiki/Manual:Hooks/OutputPageBeforeHTML
*
* Applies MobileFormatter to mobile viewed content
*
* @param OutputPage $out the OutputPage object to which wikitext is added
* @param string &$text the HTML to be wrapped inside the #mw-content-text element
*/
public function onOutputPageBeforeHTML( $out, &$text ) {
// This hook can be executed more than once per page view if the page content is composed from
// multiple sources! Anything that doesn't depend on $text should use onBeforePageDisplay.
$context = $this->mobileContext;
$title = $out->getTitle();
$displayMobileView = $context->shouldDisplayMobileView();
if ( !$title ) {
return;
}
$options = $this->config->get( 'MFMobileFormatterOptions' );
$excludeNamespaces = $options['excludeNamespaces'] ?? [];
// Perform a few extra changes if we are in mobile mode
$namespaceAllowed = !$title->inNamespaces( $excludeNamespaces );
$provider = new DefaultContentProvider( $text );
$originalProviderClass = DefaultContentProvider::class;
( new HookRunner( $this->hookContainer ) )->onMobileFrontendContentProvider(
$provider, $out
);
$isParse = ApiParseExtender::isParseAction(
$context->getRequest()->getText( 'action' )
);
if ( get_class( $provider ) === $originalProviderClass ) {
// This line is important to avoid the default content provider running unnecessarily
// on desktop views.
$useContentProvider = $displayMobileView;
$runMobileFormatter = $displayMobileView && (
// T245160 - don't run the mobile formatter on old revisions.
// Note if not the default content provider we ignore this requirement.
$title->getLatestRevID() > 0 ||
// Always allow the formatter in ApiParse
$isParse
);
} else {
// When a custom content provider is enabled, always use it.
$useContentProvider = true;
$runMobileFormatter = $displayMobileView;
}
if ( $namespaceAllowed && $useContentProvider ) {
$text = ExtMobileFrontend::domParseWithContentProvider(
$provider, $out, $runMobileFormatter
);
}
}
/**
* Modifies the `<body>` element's attributes.
*
* By default, the `class` attribute is set to the output's "bodyClassName"
* property.
*
* @param OutputPage $out
* @param Skin $skin
* @param string[] &$bodyAttrs
*/
public function onOutputPageBodyAttributes( $out, $skin, &$bodyAttrs ): void {
/** @var \MobileFrontend\Amc\UserMode $userMode */
$userMode = MediaWikiServices::getInstance()->getService( 'MobileFrontend.AMC.UserMode' );
$isMobile = $this->mobileContext->shouldDisplayMobileView();
// FIXME: This can be removed when existing references have been updated.
if ( $isMobile && !$userMode->isEnabled() ) {
$bodyAttrs['class'] .= ' mw-mf-amc-disabled';
}
if ( $isMobile ) {
// Add a class to the body so that TemplateStyles (which can only
// access html and body) and gadgets have something to check for.
// @stable added in 1.38
$bodyAttrs['class'] .= ' mw-mf';
}
}
/**
* BeforePageRedirect hook handler
* @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforePageRedirect
*
* Ensures URLs are handled properly for select special pages.
* @param OutputPage $out
* @param string &$redirect URL string, modifiable
* @param string &$code HTTP code (eg '301' or '302'), modifiable
*/
public function onBeforePageRedirect( $out, &$redirect, &$code ) {
$shouldDisplayMobileView = $this->mobileContext->shouldDisplayMobileView();
if ( !$shouldDisplayMobileView ) {
return;
}
// T45123: force mobile URLs only for local redirects
if ( $this->mobileContext->isLocalUrl( $redirect ) ) {
$out->addVaryHeader( 'X-Subdomain' );
$redirect = $this->mobileContext->getMobileUrl( $redirect );
}
}
/**
* MediaWikiPerformActionHook hook handler
* @see https://www.mediawiki.org/wiki/Manual:Hooks/MediaWikiPerformActionHook
*
* Set Diff page to diff-only mode for mobile view
*
* @param OutputPage $output Context output
* @param Article $article Article on which the action will be performed
* @param Title $title Title on which the action will be performed
* @param User $user Context user
* @param WebRequest $request Context request
* @param ActionEntryPoint $entryPoint
* @return bool|void True or no return value to continue or false to abort
*/
public function onMediaWikiPerformAction( $output, $article, $title, $user,
$request, $entryPoint
) {
if ( !$this->mobileContext->shouldDisplayMobileView() ) {
// this code should only apply to mobile view.
return;
}
// Default to diff-only mode on mobile diff pages if not specified.
if ( $request->getCheck( 'diff' ) && !$request->getCheck( 'diffonly' ) ) {
$request->setVal( 'diffonly', 'true' );
}
}
/**
* ResourceLoaderSiteStylesModulePages hook handler
* @see https://www.mediawiki.org/wiki/Manual:Hooks/ResourceLoaderSiteStylesModulePages
*
* @param string $skin
* @param array &$pages to sort modules from.
*/
public function onResourceLoaderSiteStylesModulePages( $skin, array &$pages ): void {
$ctx = $this->mobileContext;
// Use Mobile.css instead of MediaWiki:Common.css on mobile views.
if ( $ctx->shouldDisplayMobileView() && $this->config->get( 'MFCustomSiteModules' ) ) {
unset( $pages['MediaWiki:Common.css'] );
unset( $pages['MediaWiki:Print.css'] );
if ( $this->config->get( 'MFSiteStylesRenderBlocking' ) ) {
$pages['MediaWiki:Mobile.css'] = [ 'type' => 'style' ];
}
}
}
/**
* ResourceLoaderSiteModulePages hook handler
* @see https://www.mediawiki.org/wiki/Manual:Hooks/ResourceLoaderSiteModulePages
*
* @param string $skin
* @param array &$pages to sort modules from.
*/
public function onResourceLoaderSiteModulePages( $skin, array &$pages ): void {
$ctx = $this->mobileContext;
// Use Mobile.js instead of MediaWiki:Common.js and MediaWiki:<skinname.js> on mobile views.
if ( $ctx->shouldDisplayMobileView() && $this->config->get( 'MFCustomSiteModules' ) ) {
unset( $pages['MediaWiki:Common.js'] );
$pages['MediaWiki:Mobile.js'] = [ 'type' => 'script' ];
if ( !$this->config->get( 'MFSiteStylesRenderBlocking' ) ) {
$pages['MediaWiki:Mobile.css'] = [ 'type' => 'style' ];
}
}
}
/**
* GetCacheVaryCookies hook handler
* @see https://www.mediawiki.org/wiki/Manual:Hooks/GetCacheVaryCookies
*
* @param OutputPage $out
* @param array &$cookies array of cookies name, add a value to it
* if you want to add a cookie that have to vary cache options
*/
public function onGetCacheVaryCookies( $out, &$cookies ) {
// Enables mobile cookies on wikis w/o mobile domain
$cookies[] = MobileContext::USEFORMAT_COOKIE_NAME;
// Don't redirect to mobile if user had explicitly opted out of it
$cookies[] = MobileContext::STOP_MOBILE_REDIRECT_COOKIE_NAME;
if (
$this->mobileContext->shouldDisplayMobileView() ||
!$this->mobileContext->hasMobileDomain()
) {
// beta cookie
$cookies[] = MobileContext::OPTIN_COOKIE_NAME;
}
}
/**
* Generate config for usage inside MobileFrontend
* This should be used for variables which:
* - vary with the html
* - variables that should work cross skin including anonymous users
* - used for both, stable and beta mode (don't use
* MobileContext::isBetaGroupMember in this function - T127860)
*
* @return array
*/
public static function getResourceLoaderMFConfigVars() {
$vars = [];
$config = MediaWikiServices::getInstance()->getService( 'MobileFrontend.Config' );
$mfScriptPath = $config->get( 'MFScriptPath' );
$pageProps = $config->get( 'MFQueryPropModules' );
$searchParams = $config->get( 'MFSearchAPIParams' );
// Avoid API warnings and allow integration with optional extensions.
if ( $mfScriptPath || ExtensionRegistry::getInstance()->isLoaded( 'PageImages' ) ) {
$pageProps[] = 'pageimages';
$searchParams = array_merge_recursive( $searchParams, [
'piprop' => 'thumbnail',
'pithumbsize' => MobilePage::SMALL_IMAGE_WIDTH,
'pilimit' => 50,
] );
}
// Get the licensing agreement that is displayed in the uploading interface.
$vars += [
// Page.js
'wgMFMobileFormatterHeadings' => $config->get( 'MFMobileFormatterOptions' )['headings'],
// extendSearchParams
'wgMFSearchAPIParams' => $searchParams,
'wgMFQueryPropModules' => $pageProps,
// SearchGateway.js
'wgMFSearchGenerator' => $config->get( 'MFSearchGenerator' ),
// PhotoListGateway.js, SearchGateway.js
'wgMFThumbnailSizes' => [
'tiny' => MobilePage::TINY_IMAGE_WIDTH,
'small' => MobilePage::SMALL_IMAGE_WIDTH,
],
'wgMFEnableJSConsoleRecruitment' => $config->get( 'MFEnableJSConsoleRecruitment' ),
// Browser.js
'wgMFDeviceWidthTablet' => self::DEVICE_WIDTH_TABLET,
// toggle.js
'wgMFCollapseSectionsByDefault' => $config->get( 'MFCollapseSectionsByDefault' ),
// extendSearchParams.js
'wgMFTrackBlockNotices' => $config->get( 'MFTrackBlockNotices' ),
];
return $vars;
}
/**
* @param MobileContext $context
* @return array
*/
private function getWikibaseStaticConfigVars(
MobileContext $context
) {
$features = array_keys( $this->config->get( 'MFDisplayWikibaseDescriptions' ) );
$result = [ 'wgMFDisplayWikibaseDescriptions' => [] ];
$descriptionsEnabled = $this->featuresManager->isFeatureAvailableForCurrentUser(
'MFEnableWikidataDescriptions'
);
foreach ( $features as $feature ) {
$result['wgMFDisplayWikibaseDescriptions'][$feature] = $descriptionsEnabled &&
$context->shouldShowWikibaseDescriptions( $feature, $this->config );
}
return $result;
}
/**
* Should special pages be replaced with mobile formatted equivalents?
*
* @internal
* @param User $user for which we need to make the decision based on user prefs
* @return bool whether special pages should be substituted with
* mobile friendly equivalents
*/
public function shouldMobileFormatSpecialPages( $user ) {
$enabled = $this->config->get( 'MFEnableMobilePreferences' );
if ( !$enabled ) {
return true;
}
if ( !$user->isSafeToLoad() ) {
// if not isSafeToLoad
// assume an anonymous session
// (see I2a6ef640d328106c88331da7c53785486e16a353)
return true;
}
$userOption = $this->userOptionsLookup->getOption(
$user,
self::MOBILE_PREFERENCES_SPECIAL_PAGES,
self::ENABLE_SPECIAL_PAGE_OPTIMISATIONS
);
return $userOption === self::ENABLE_SPECIAL_PAGE_OPTIMISATIONS;
}
/**
* Hook for SpecialPage_initList in SpecialPageFactory.
*
* @param array &$list list of special page classes
*/
public function onSpecialPage_initList( &$list ) {
$user = $this->mobileContext->getUser();
// Perform substitutions of pages that are unsuitable for mobile
// FIXME: Upstream these changes to core.
if (
$this->mobileContext->shouldDisplayMobileView() &&
$this->shouldMobileFormatSpecialPages( $user ) &&
$user->isSafeToLoad()
) {
if (
!$this->featuresManager->isFeatureAvailableForCurrentUser( 'MFUseDesktopSpecialEditWatchlistPage' )
) {
$list['EditWatchlist'] = SpecialMobileEditWatchlist::class;
}
}
}
/**
* ListDefinedTags hook handler
* @see https://www.mediawiki.org/wiki/Manual:Hooks/ListDefinedTags
*
* @param array &$tags The list of tags. Add your extension's tags to this array.
*/
public function onListDefinedTags( &$tags ) {
$this->addDefinedTags( $tags );
}
/**
* ChangeTagsListActive hook handler
* @see https://www.mediawiki.org/wiki/Manual:Hooks/ChangeTagsListActive
*
* @param array &$tags The list of tags. Add your extension's tags to this array.
*/
public function onChangeTagsListActive( &$tags ) {
$this->addDefinedTags( $tags );
}
/**
* @param array &$tags
*/
public function addDefinedTags( &$tags ) {
$tags[] = 'mobile edit';
$tags[] = 'mobile web edit';
}
/**
* RecentChange_save hook handler that tags mobile changes
* @see https://www.mediawiki.org/wiki/Manual:Hooks/RecentChange_save
*
* @param RecentChange $recentChange
*/
public function onRecentChange_save( $recentChange ) {
self::onTaggableObjectCreation( $recentChange );
}
/**
* ManualLogEntryBeforePublish hook handler that tags actions logged when user uses mobile mode
* @see https://www.mediawiki.org/wiki/Manual:Hooks/ManualLogEntryBeforePublish
*
* @param ManualLogEntry $logEntry
*/
public function onManualLogEntryBeforePublish( $logEntry ): void {
self::onTaggableObjectCreation( $logEntry );
}
/**
* @param Taggable $taggable Object to tag
*/
public static function onTaggableObjectCreation( Taggable $taggable ) {
/** @var MobileContext $context */
$context = MediaWikiServices::getInstance()->getService( 'MobileFrontend.Context' );
$userAgent = $context->getRequest()->getHeader( "User-agent" );
if ( $context->shouldDisplayMobileView() ) {
$taggable->addTags( [ 'mobile edit' ] );
// Tag as mobile web edit specifically, if it isn't coming from the apps
if ( strpos( $userAgent, 'WikipediaApp/' ) !== 0 ) {
$taggable->addTags( [ 'mobile web edit' ] );
}
}
}
/**
* AbuseFilter-generateUserVars hook handler that adds a user_mobile variable.
* Altering the variables generated for a specific user
*
* @see hooks.txt in AbuseFilter extension
* @param VariableHolder $vars object to add vars to
* @param User $user
* @param RecentChange|null $rc If the variables should be generated for an RC entry, this
* is the entry. Null if it's for the current action being filtered.
*/
public static function onAbuseFilterGenerateUserVars( $vars, $user, ?RecentChange $rc = null ) {
$services = MediaWikiServices::getInstance();
if ( !$rc ) {
/** @var MobileContext $context */
$context = $services->getService( 'MobileFrontend.Context' );
$vars->setVar( 'user_mobile', $context->shouldDisplayMobileView() );
} else {
$dbr = $services->getConnectionProvider()->getReplicaDatabase();
$tags = $services->getChangeTagsStore()->getTags( $dbr, $rc->getAttribute( 'rc_id' ) );
$val = (bool)array_intersect( $tags, [ 'mobile edit', 'mobile web edit' ] );
$vars->setVar( 'user_mobile', $val );
}
}
/**
* AbuseFilter-builder hook handler that adds user_mobile variable to list
* of valid vars
*
* @param array &$builder Array in AbuseFilter::getBuilderValues to add to.
*/
public static function onAbuseFilterBuilder( &$builder ) {
$builder['vars']['user_mobile'] = 'user-mobile';
}
/**
* Invocation of hook SpecialPageBeforeExecute
*
* We use this hook to ensure that login/account creation pages
* are redirected to HTTPS if they are not accessed via HTTPS and
* $wgSecureLogin == true - but only when using the
* mobile site.
*
* @param SpecialPage $special
* @param string $subpage subpage name
*/
public function onSpecialPageBeforeExecute( $special, $subpage ) {
$isMobileView = $this->mobileContext->shouldDisplayMobileView();
$taglines = $this->mobileContext->getConfig()->get( 'MFSpecialPageTaglines' );
$name = $special->getName();
if ( $isMobileView ) {
$out = $special->getOutput();
// FIXME: mobile.special.styles should be replaced with mediawiki.special module
$out->addModuleStyles(
[ 'mobile.special.styles' ]
);
// FIXME: Should be moved to MediaWiki core module.
if ( $name === 'Userlogin' || $name === 'CreateAccount' ) {
$out->addModules( 'mobile.special.userlogin.scripts' );
}
if ( array_key_exists( $name, $taglines ) ) {
self::setTagline( $out, $out->msg( $taglines[$name] )->parse() );
}
}
}
/**
* PostLoginRedirect hook handler
* @see https://www.mediawiki.org/wiki/Manual:Hooks/PostLoginRedirect
*
* Used here to handle watchlist actions made by anons to be handled after
* login or account creation redirect.
*
* @inheritDoc
*/
public function onPostLoginRedirect( &$returnTo, &$returnToQuery, &$type ) {
$context = $this->mobileContext;
if ( !$context->shouldDisplayMobileView() ) {
return;
}
// If 'watch' is set from the login form, watch the requested article
$campaign = $context->getRequest()->getRawVal( 'campaign' );
// The user came from one of the drawers that prompted them to login.
// We must watch the article per their original intent.
if ( $campaign === 'mobile_watchPageActionCta' ||
wfArrayToCgi( $returnToQuery ) === 'article_action=watch'
) {
$title = Title::newFromText( $returnTo );
// protect against watching special pages (these cannot be watched!)
if ( $title !== null && !$title->isSpecialPage() ) {
$this->watchlistManager->addWatch( $context->getAuthority(), $title );
}
}
}
/**
* BeforePageDisplay hook handler
* @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforePageDisplay
*
* @param OutputPage $out
* @param Skin $skin Skin object that will be used to generate the page, added in 1.13.
*/
public function onBeforePageDisplay( $out, $skin ): void {
$context = $this->mobileContext;
$mfEnableXAnalyticsLogging = $this->config->get( 'MFEnableXAnalyticsLogging' );
$mfNoIndexPages = $this->config->get( 'MFNoindexPages' );
$isCanonicalLinkHandledByCore = $this->config->get( 'EnableCanonicalServerLink' );
$hasMobileUrl = $context->hasMobileDomain();
$displayMobileView = $context->shouldDisplayMobileView();
$title = $skin->getTitle();
// an canonical/alternate link is only useful, if the mobile and desktop URL are different
// and $wgMFNoindexPages needs to be true
if ( $hasMobileUrl && $mfNoIndexPages ) {
$link = false;
if ( !$displayMobileView ) {
// add alternate link to desktop sites - bug T91183
$desktopUrl = $title->getFullURL();
$link = [
'rel' => 'alternate',
'media' => 'only screen and (max-width: ' . self::DEVICE_WIDTH_TABLET . ')',
'href' => $context->getMobileUrl( $desktopUrl ),
];
} elseif ( !$isCanonicalLinkHandledByCore ) {
$link = [
'rel' => 'canonical',
'href' => $title->getFullURL(),
];
}
if ( $link ) {
$out->addLink( $link );
}
}
// set the vary header to User-Agent, if mobile frontend auto detects, if the mobile
// view should be delivered and the same url is used for desktop and mobile devices
// Bug: T123189
if (
$this->config->get( 'MFVaryOnUA' ) &&
$this->config->get( 'MFAutodetectMobileView' ) &&
!$hasMobileUrl
) {
$out->addVaryHeader( 'User-Agent' );
}
// Set X-Analytics HTTP response header if necessary
if ( $displayMobileView ) {
$analyticsHeader = ( $mfEnableXAnalyticsLogging ? $context->getXAnalyticsHeader() : false );
if ( $analyticsHeader ) {
$resp = $out->getRequest()->response();
$resp->header( $analyticsHeader );
}
// in mobile view: always add vary header
$out->addVaryHeader( 'Cookie' );
if ( $this->config->get( 'MFEnableManifest' ) ) {
$out->addLink(
[
'rel' => 'manifest',
'href' => wfAppendQuery(
wfScript( 'api' ),
[ 'action' => 'webapp-manifest' ]
)
]
);
}
// In mobile mode, MediaWiki:Common.css/MediaWiki:Common.js is not loaded.
// We load MediaWiki:Mobile.css/js instead
// We load mobile.init so that lazy loading images works on all skins
$out->addModules( [ 'mobile.init' ] );
$out->addModuleStyles( [ 'mobile.init.styles' ] );
$fontSize = $this->userOptionsLookup->getOption(
$context->getUser(), self::MOBILE_PREFERENCES_FONTSIZE
) ?? 'small';
// If sections are never collapsed by default, we do not show an "expand sections"
// option in Special:MobileOptions so the user option is ignored.
$siteDefaultCollapseSections = $context->getConfig()->get( 'MFCollapseSectionsByDefault' );
$userSectionsPreference = $this->userOptionsLookup->getOption(
$context->getUser(), self::MOBILE_PREFERENCES_EXPAND_SECTIONS
) ? '1' : '0';
$expandSections = $siteDefaultCollapseSections ? $userSectionsPreference : '1';
/** @var \MobileFrontend\Amc\UserMode $userMode */
$userMode = MediaWikiServices::getInstance()->getService( 'MobileFrontend.AMC.UserMode' );
$amc = !$userMode->isEnabled() ? '0' : '1';
$context->getOutput()->addHtmlClasses( [
'mf-expand-sections-clientpref-' . $expandSections,
'mf-font-size-clientpref-' . $fontSize,
'mw-mf-amc-clientpref-' . $amc
] );
}
// T204691
$theme = $this->config->get( 'MFManifestThemeColor' );
if ( $theme && $displayMobileView ) {
$out->addMeta( 'theme-color', $theme );
}
if ( $displayMobileView ) {
// Adds inline script to allow opening of sections while JS is still loading
$out->prependHTML( MakeSectionsTransform::interimTogglingSupport() );
}
}
/**
* AfterBuildFeedLinks hook handler. Remove all feed links in mobile view.
*
* @param array &$tags Added feed links
*/
public function onAfterBuildFeedLinks( &$tags ) {
if (
$this->mobileContext->shouldDisplayMobileView() &&
!$this->config->get( 'MFRSSFeedLink' )
) {
$tags = [];
}
}
/**
* Register default preferences for MobileFrontend
*
* @param array &$defaultUserOptions Reference to default options array
*/
public function onUserGetDefaultOptions( &$defaultUserOptions ) {
if ( $this->config->get( 'MFEnableMobilePreferences' ) ) {
$defaultUserOptions += [
self::MOBILE_PREFERENCES_SPECIAL_PAGES => self::ENABLE_SPECIAL_PAGE_OPTIMISATIONS,
];
}
}
/**
* GetPreferences hook handler
* @see https://www.mediawiki.org/wiki/Manual:Hooks/GetPreferences
*
* @param User $user User whose preferences are being modified
* @param array &$preferences Preferences description array, to be fed to an HTMLForm object
*/
public function onGetPreferences( $user, &$preferences ) {
$definition = [
'type' => 'api',
'default' => '',
];
$preferences[MobileContext::USER_MODE_PREFERENCE_NAME] = $definition;
$preferences[self::MOBILE_PREFERENCES_EDITOR] = $definition;
$preferences[self::MOBILE_PREFERENCES_FONTSIZE] = $definition;
$preferences[self::MOBILE_PREFERENCES_EXPAND_SECTIONS] = $definition;
if ( $this->config->get( 'MFEnableMobilePreferences' ) ) {
$preferences[ self::MOBILE_PREFERENCES_SPECIAL_PAGES ] = [
'type' => 'check',
'label-message' => 'mobile-frontend-special-pages-pref',
'help-message' => 'mobile-frontend-special-pages-pref',
// The following messages are generated here:
// * prefs-mobile
'section' => self::MOBILE_PREFERENCES_SECTION
];
}
}
/**
* CentralAuthLoginRedirectData hook handler
* Saves mobile host so that the CentralAuth wiki could redirect back properly
*
* @see CentralAuthHooks::doCentralLoginRedirect in CentralAuth extension
* @param \MediaWiki\Extension\CentralAuth\User\CentralAuthUser $centralUser
* @param array &$data Redirect data
*/
public static function onCentralAuthLoginRedirectData( $centralUser, &$data ) {
/** @var MobileContext $context */
$context = MediaWikiServices::getInstance()->getService( 'MobileFrontend.Context' );
$server = $context->getConfig()->get( 'Server' );
if ( $context->shouldDisplayMobileView() ) {
$data['mobileServer'] = $context->getMobileUrl( $server );
}
}
/**
* CentralAuthSilentLoginRedirect hook handler
* Points redirects from CentralAuth wiki to mobile domain if user has logged in from it
* @see SpecialCentralLogin in CentralAuth extension
* @param \MediaWiki\Extension\CentralAuth\User\CentralAuthUser $centralUser
* @param string &$url to redirect to
* @param array $info token information
*/
public static function onCentralAuthSilentLoginRedirect( $centralUser, &$url, $info ) {
if ( isset( $info['mobileServer'] ) ) {
$urlUtils = MediaWikiServices::getInstance()->getUrlUtils();
$mobileUrlParsed = $urlUtils->parse( $info['mobileServer'] );
$urlParsed = $urlUtils->parse( $url );
$urlParsed['host'] = $mobileUrlParsed['host'] ?? '';
$url = UrlUtils::assemble( $urlParsed );
}
}
/**
* Sets a tagline for a given page that can be displayed by the skin.
*
* @param OutputPage $outputPage
* @param string $desc
*/
private static function setTagline( OutputPage $outputPage, $desc ) {
$outputPage->setProperty( 'wgMFDescription', $desc );
}
/**
* Finds the wikidata tagline associated with the page
*
* @param ParserOutput $po
* @param callable $fallbackWikibaseDescriptionFunc A fallback to provide Wikibase description.
* Function takes wikibase_item as a first and only argument
* @return ?string the tagline as a string, or else null if none is found
*/
public static function findTagline( ParserOutput $po, $fallbackWikibaseDescriptionFunc ) {
$desc = $po->getPageProperty( 'wikibase-shortdesc' );
$item = $po->getPageProperty( 'wikibase_item' );
if ( $desc === null && $item && $fallbackWikibaseDescriptionFunc ) {
return $fallbackWikibaseDescriptionFunc( $item );
}
return $desc;
}
/**
* OutputPageParserOutput hook handler
* @see https://www.mediawiki.org/wiki/Manual:Hooks/OutputPageParserOutput
*
* @param OutputPage $outputPage the OutputPage object to which wikitext is added
* @param ParserOutput $po
*/
public function onOutputPageParserOutput( $outputPage, $po ): void {
$title = $outputPage->getTitle();
$descriptionsEnabled = !$title->isMainPage() &&
$title->getNamespace() === NS_MAIN &&
$this->featuresManager->isFeatureAvailableForCurrentUser(
'MFEnableWikidataDescriptions'
) && $this->mobileContext->shouldShowWikibaseDescriptions( 'tagline', $this->config );
// Only set the tagline if the feature has been enabled and the article is in the main namespace
if ( $this->mobileContext->shouldDisplayMobileView() && $descriptionsEnabled ) {
$desc = self::findTagline( $po, static function ( $item ) {
return ExtMobileFrontend::getWikibaseDescription( $item );
} );
if ( $desc ) {
self::setTagline( $outputPage, $desc );
}
}
}
/**
* ArticleParserOptions hook handler
* @see https://www.mediawiki.org/wiki/Manual:Hooks/ArticleParserOptions
*
* @param Article $article
* @param ParserOptions $parserOptions
*/
public function onArticleParserOptions( Article $article, ParserOptions $parserOptions ) {
// while the parser is actively being migrated, we rely on the ParserMigration extension for using Parsoid
if ( ExtensionRegistry::getInstance()->isLoaded( 'ParserMigration' ) ) {
$context = $this->mobileContext;
$oracle = MediaWikiServices::getInstance()->getService( 'ParserMigration.Oracle' );
$shouldUseParsoid =
$oracle->shouldUseParsoid( $context->getUser(), $context->getRequest(), $article->getTitle() );
// set the collapsible sections parser flag so that section content is wrapped in a div for easier targeting
// only if we're in mobile view and parsoid is enabled
if ( $context->shouldDisplayMobileView() && $shouldUseParsoid ) {
$parserOptions->setCollapsibleSections();
}
}
}
/**
* HTMLFileCache::useFileCache hook handler
* Disables file caching for mobile pageviews
* @see https://www.mediawiki.org/wiki/Manual:Hooks/HTMLFileCache::useFileCache
*
* @param IContextSource $context
* @return bool
*/
public function onHTMLFileCache__useFileCache( $context ) {
return !$this->mobileContext->shouldDisplayMobileView();
}
/**
* LoginFormValidErrorMessages hook handler to promote MF specific error message be valid.
*
* @param array &$messages Array of already added messages
*/
public function onLoginFormValidErrorMessages( array &$messages ) {
$messages = array_merge( $messages,
[
// watchstart sign up CTA
'mobile-frontend-watchlist-signup-action',
// Watchlist and watchstar sign in CTA
'mobile-frontend-watchlist-purpose',
// Edit button sign in CTA
'mobile-frontend-edit-login-action',
// Edit button sign-up CTA
'mobile-frontend-edit-signup-action',
'mobile-frontend-donate-image-login-action',
// default message
'mobile-frontend-generic-login-new',
]
);
}
/**
* Handler for MakeGlobalVariablesScript hook.
* For values that depend on the current page, user or request state.
*
* @see https://www.mediawiki.org/wiki/Manual:Hooks/MakeGlobalVariablesScript
* @param array &$vars Variables to be added into the output
* @param OutputPage $out OutputPage instance calling the hook
*/
public function onMakeGlobalVariablesScript( &$vars, $out ): void {
$services = MediaWikiServices::getInstance();
/** @var \MobileFrontend\Amc\UserMode $userMode */
$userMode = $services->getService( 'MobileFrontend.AMC.UserMode' );
// If the device is a mobile, Remove the category entry.
$context = $this->mobileContext;
if ( $context->shouldDisplayMobileView() ) {
/** @var \MobileFrontend\Amc\Outreach $outreach */
$outreach = $services->getService( 'MobileFrontend.AMC.Outreach' );
unset( $vars['wgCategories'] );
$vars['wgMFMode'] = $context->isBetaGroupMember() ? 'beta' : 'stable';
$vars['wgMFAmc'] = $userMode->isEnabled();
$vars['wgMFAmcOutreachActive'] = $outreach->isCampaignActive();
$vars['wgMFAmcOutreachUserEligible'] = $outreach->isUserEligible();
$vars['wgMFLazyLoadImages'] =
$this->featuresManager->isFeatureAvailableForCurrentUser( 'MFLazyLoadImages' );
$vars['wgMFEditNoticesFeatureConflict'] = $this->hasEditNoticesFeatureConflict(
$this->config, $context->getUser()
);
}
// Needed by mobile.startup and mobile.special.watchlist.scripts.
// Needs to know if in beta mode or not and needs to load for Minerva desktop as well.
// Ideally this would be inside ResourceLoaderFileModuleWithMFConfig but
// sessions are not allowed there.
$vars += $this->getWikibaseStaticConfigVars( $context );
}
/**
* Check if a conflicting edit notices gadget is enabled for the current user
*
* @param Config $config
* @param User $user
* @return bool
*/
private function hasEditNoticesFeatureConflict( Config $config, User $user ): bool {
$gadgetName = $config->get( 'MFEditNoticesConflictingGadgetName' );
if ( !$gadgetName ) {
return false;
}
if ( $this->gadgetRepo ) {
$match = array_search( $gadgetName, $this->gadgetRepo->getGadgetIds(), true );
if ( $match !== false ) {
try {
return $this->gadgetRepo->getGadget( $gadgetName )
->isEnabled( $user );
} catch ( \InvalidArgumentException $e ) {
return false;
}
}
}
return false;
}
/**
* Handler for TitleSquidURLs hook to add copies of the cache purge
* URLs which are transformed according to the wgMobileUrlCallback, so
* that both mobile and non-mobile URL variants get purged.
*
* @see * https://www.mediawiki.org/wiki/Manual:Hooks/TitleSquidURLs
* @param Title $title the article title
* @param array &$urls the set of URLs to purge
*/
public function onTitleSquidURLs( $title, &$urls ) {
foreach ( $urls as $url ) {
$newUrl = $this->mobileContext->getMobileUrl( $url );
if ( $newUrl !== false && $newUrl !== $url ) {
$urls[] = $newUrl;
}
}
}
/**
* Handler for the AuthChangeFormFields hook to add a logo on top of
* the login screen. This is the AuthManager equivalent of changeUserLoginCreateForm.
* @param AuthenticationRequest[] $requests AuthenticationRequest objects array
* @param array $fieldInfo Field description as given by AuthenticationRequest::mergeFieldInfo
* @param array &$formDescriptor A form descriptor suitable for the HTMLForm constructor
* @param string $action One of the AuthManager::ACTION_* constants
*/
public function onAuthChangeFormFields(
$requests, $fieldInfo, &$formDescriptor, $action
) {
$logos = RL\SkinModule::getAvailableLogos( $this->config );
$mfLogo = $logos['icon'] ?? false;
// do nothing in desktop mode
if (
$this->mobileContext->shouldDisplayMobileView() && $mfLogo
&& in_array( $action, [ AuthManager::ACTION_LOGIN, AuthManager::ACTION_CREATE ], true )
) {
$logoHtml = Html::rawElement( 'div', [ 'class' => 'mw-mf-watermark' ],
Html::element( 'img', [ 'src' => $mfLogo, 'alt' => '' ] ) );
$formDescriptor = [
'mfLogo' => [
'type' => 'info',
'default' => $logoHtml,
'raw' => true,
],
] + $formDescriptor;
}
}
/**
* Add the base mobile site URL to the siteinfo API output.
* @param ApiQuerySiteinfo $module
* @param array &$result Api result array
*/
public function onAPIQuerySiteInfoGeneralInfo( $module, &$result ) {
global $wgCanonicalServer;
$result['mobileserver'] = $this->mobileContext->getMobileUrl( $wgCanonicalServer );
}
}