Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 169
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageHooks
0.00% covered (danger)
0.00%
0 / 169
0.00% covered (danger)
0.00%
0 / 8
2756
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 isMobile
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onBeforePageDisplay
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
552
 onOutputPageBeforeHTML
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
90
 onGetActionName
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
 onBeforeDisplayNoArticleText
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getEmptyStateHtml
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
56
 onTitleGetEditNotices
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * DiscussionTools page hooks
4 *
5 * @file
6 * @ingroup Extensions
7 * @license MIT
8 */
9
10namespace MediaWiki\Extension\DiscussionTools\Hooks;
11
12use Article;
13use Config;
14use ConfigFactory;
15use ExtensionRegistry;
16use Html;
17use IContextSource;
18use MediaWiki\Actions\Hook\GetActionNameHook;
19use MediaWiki\Extension\DiscussionTools\CommentFormatter;
20use MediaWiki\Extension\DiscussionTools\SubscriptionStore;
21use MediaWiki\Extension\VisualEditor\Hooks as VisualEditorHooks;
22use MediaWiki\Hook\BeforePageDisplayHook;
23use MediaWiki\Hook\OutputPageBeforeHTMLHook;
24use MediaWiki\Hook\TitleGetEditNoticesHook;
25use MediaWiki\MediaWikiServices;
26use MediaWiki\Page\Hook\BeforeDisplayNoArticleTextHook;
27use MediaWiki\User\UserNameUtils;
28use MediaWiki\User\UserOptionsLookup;
29use OOUI\ButtonWidget;
30use OOUI\HtmlSnippet;
31use OOUI\MessageWidget;
32use OutputPage;
33use RequestContext;
34use Skin;
35use SpecialPage;
36use Title;
37
38class PageHooks implements
39    BeforeDisplayNoArticleTextHook,
40    BeforePageDisplayHook,
41    GetActionNameHook,
42    OutputPageBeforeHTMLHook,
43    TitleGetEditNoticesHook
44{
45
46    private Config $config;
47    private SubscriptionStore $subscriptionStore;
48    private UserNameUtils $userNameUtils;
49    private UserOptionsLookup $userOptionsLookup;
50
51    public function __construct(
52        ConfigFactory $configFactory,
53        SubscriptionStore $subscriptionStore,
54        UserNameUtils $userNameUtils,
55        UserOptionsLookup $userOptionsLookup
56    ) {
57        $this->config = $configFactory->makeConfig( 'discussiontools' );
58        $this->subscriptionStore = $subscriptionStore;
59        $this->userNameUtils = $userNameUtils;
60        $this->userOptionsLookup = $userOptionsLookup;
61    }
62
63    private function isMobile(): bool {
64        if ( ExtensionRegistry::getInstance()->isLoaded( 'MobileFrontend' ) ) {
65            $mobFrontContext = MediaWikiServices::getInstance()->getService( 'MobileFrontend.Context' );
66            return $mobFrontContext->shouldDisplayMobileView();
67        }
68        return false;
69    }
70
71    /**
72     * Adds DiscussionTools JS to the output.
73     *
74     * This is attached to the MediaWiki 'BeforePageDisplay' hook.
75     *
76     * @param OutputPage $output
77     * @param Skin $skin
78     * @return void This hook must not abort, it must return no value
79     */
80    public function onBeforePageDisplay( $output, $skin ): void {
81        $user = $output->getUser();
82        $req = $output->getRequest();
83        foreach ( HookUtils::FEATURES as $feature ) {
84            // Add a CSS class for each enabled feature
85            if ( HookUtils::isFeatureEnabledForOutput( $output, $feature ) ) {
86                $output->addBodyClasses( "ext-discussiontools-$feature-enabled" );
87            }
88        }
89
90        if ( $this->isMobile() && HookUtils::isFeatureEnabledForOutput( $output, HookUtils::VISUALENHANCEMENTS ) ) {
91            $output->addBodyClasses( 'collapsible-headings-collapsed' );
92        }
93
94        // Load style modules if the tools can be available for the title
95        // to selectively hide DT features, depending on the body classes added above.
96        $availableForTitle = HookUtils::isAvailableForTitle( $output->getTitle() );
97        if ( $availableForTitle ) {
98            $output->addModuleStyles( 'ext.discussionTools.init.styles' );
99        }
100
101        // Load modules if any DT feature is enabled for this user
102        if (
103            HookUtils::isFeatureEnabledForOutput( $output ) ||
104            // If there's an a/b test we need to include the JS for unregistered users just so
105            // we can make sure we store the bucket
106            ( $this->config->get( 'DiscussionToolsABTest' ) && !$user->isRegistered() )
107        ) {
108            $output->addModules( [
109                'ext.discussionTools.init'
110            ] );
111
112            $enabledVars = [];
113            foreach ( HookUtils::FEATURES as $feature ) {
114                $enabledVars[$feature] = HookUtils::isFeatureEnabledForOutput( $output, $feature );
115            }
116            $output->addJsConfigVars( 'wgDiscussionToolsFeaturesEnabled', $enabledVars );
117
118            $editor = $this->userOptionsLookup->getOption( $user, 'discussiontools-editmode' );
119            // User has no preferred editor yet
120            // If the user has a preferred editor, this will be evaluated in the client
121            if ( !$editor ) {
122                // Check which editor we would use for articles
123                // VE pref is 'visualeditor'/'wikitext'. Here we describe the mode,
124                // not the editor, so 'visual'/'source'
125                $editor = VisualEditorHooks::getPreferredEditor( $user, $req ) === 'visualeditor' ?
126                    'visual' : 'source';
127                $output->addJsConfigVars(
128                    'wgDiscussionToolsFallbackEditMode',
129                    $editor
130                );
131            }
132        }
133
134        // This doesn't involve any DB checks, and so we can put it on every
135        // page to make it easy to pick for logging in WikiEditor. If this
136        // becomes not-cheap, move it elsewhere.
137        $abstate = HookUtils::determineUserABTestBucket( $user );
138        if ( $abstate ) {
139            $output->addJsConfigVars(
140                'wgDiscussionToolsABTestBucket',
141                $abstate
142            );
143        }
144
145        // Replace the action=edit&section=new form with the new topic tool.
146        if ( HookUtils::shouldOpenNewTopicTool( $output->getContext() ) ) {
147            $output->addJsConfigVars( 'wgDiscussionToolsStartNewTopicTool', true );
148
149            // For no-JS compatibility, redirect to the old new section editor if JS is unavailable.
150            // This isn't great, because the user has to load the page twice. But making a page that is
151            // both a view mode and an edit mode seems difficult, so I'm cutting some corners here.
152            // (Code below adapted from VisualEditor.)
153            $params = $output->getRequest()->getValues();
154            $params['dtenable'] = '0';
155            $url = wfScript() . '?' . wfArrayToCgi( $params );
156            $escapedUrl = htmlspecialchars( $url );
157
158            // Redirect if the user has no JS (<noscript>)
159            $output->addHeadItem(
160                'dt-noscript-fallback',
161                "<noscript><meta http-equiv=\"refresh\" content=\"0; url=$escapedUrl\"></noscript>"
162            );
163            // Redirect if the user has no ResourceLoader
164            $output->addScript( Html::inlineScript(
165                "(window.NORLQ=window.NORLQ||[]).push(" .
166                    "function(){" .
167                        "location.href=\"$url\";" .
168                    "}" .
169                ");"
170            ) );
171        }
172
173        if ( $output->getSkin()->getSkinName() === 'minerva' ) {
174            $title = $output->getTitle();
175
176            if (
177                HookUtils::isFeatureEnabledForOutput( $output, HookUtils::NEWTOPICTOOL ) &&
178                // Only add the button if "New section" tab would be shown in a normal skin.
179                HookUtils::shouldShowNewSectionTab( $output->getContext() )
180            ) {
181                $output->enableOOUI();
182                $output->addModuleStyles( [
183                    // For speechBubbleAdd
184                    'oojs-ui.styles.icons-alerts',
185                ] );
186                $output->addBodyClasses( 'ext-discussiontools-init-new-topic-opened' );
187
188                // Minerva doesn't show a new topic button by default, unless the MobileFrontend
189                // talk page feature is enabled, but we shouldn't depend on code from there.
190                $output->addHTML( Html::rawElement( 'div',
191                    [ 'class' => 'ext-discussiontools-init-new-topic' ],
192                    ( new ButtonWidget( [
193                        'classes' => [ 'ext-discussiontools-init-new-topic-button' ],
194                        'href' => $title->getLinkURL( [ 'action' => 'edit', 'section' => 'new' ] ),
195                        'icon' => 'speechBubbleAdd',
196                        'label' => $output->getContext()->msg( 'skin-action-addsection' )->text(),
197                        'flags' => [ 'progressive', 'primary' ],
198                        'infusable' => true,
199                    ] ) )
200                        // For compatibility with Minerva click tracking (T295490)
201                        ->setAttributes( [ 'data-event-name' => 'talkpage.add-topic' ] )
202                ) );
203            }
204
205            if (
206                $title->isTalkPage() &&
207                HookUtils::isFeatureEnabledForOutput( $output, HookUtils::REPLYTOOL ) && (
208                    CommentFormatter::hasLedeContent( $output->getHTML() ) || (
209                        // Header shown on all talk pages, see Article::showNamespaceHeader
210                        !$output->getContext()->msg( 'talkpageheader' )->isDisabled() &&
211                        // Check if it isn't empty since it may use parser functions to only show itself on some pages
212                        trim( $output->getContext()->msg( 'talkpageheader' )->text() ) !== ''
213                    )
214                ) &&
215                // If there are comments in the lede section, we can't really separate them from other lede
216                // content, so keep the whole section visible.
217                !CommentFormatter::hasCommentsInLedeContent( $output->getHTML() )
218            ) {
219                $output->addBodyClasses( 'ext-discussiontools-init-lede-hidden' );
220                $output->enableOOUI();
221                $output->prependHTML(
222                    Html::rawElement( 'div',
223                        [ 'class' => 'ext-discussiontools-init-lede-button-container' ],
224                        ( new ButtonWidget( [
225                            'label' => $output->getContext()->msg( 'discussiontools-ledesection-button' )->text(),
226                            'classes' => [ 'ext-discussiontools-init-lede-button' ],
227                            'framed' => false,
228                            'icon' => 'info',
229                            'infusable' => true,
230                        ] ) )
231                    )
232                );
233            }
234        }
235    }
236
237    /**
238     * OutputPageBeforeHTML hook handler
239     * @see https://www.mediawiki.org/wiki/Manual:Hooks/OutputPageBeforeHTML
240     *
241     * @param OutputPage $output OutputPage object that corresponds to the page
242     * @param string &$text Text that will be displayed, in HTML
243     * @return bool|void This hook must not abort, it must return true or null.
244     */
245    public function onOutputPageBeforeHTML( $output, &$text ) {
246        // ParserOutputPostCacheTransform hook would be a better place to do this,
247        // so that when the ParserOutput is used directly without using this hook,
248        // we don't leave half-baked interface elements in it (see e.g. T292345, T294168).
249        // But that hook doesn't provide parameters that we need to render correctly
250        // (including the page title, interface language, and current user).
251
252        // This hook can be executed more than once per page view if the page content is composed from
253        // multiple sources!
254
255        $isMobile = $this->isMobile();
256        $lang = $output->getLanguage();
257
258        if ( HookUtils::isFeatureEnabledForOutput( $output, HookUtils::TOPICSUBSCRIPTION ) ) {
259            // Just enable OOUI PHP - the OOUI subscribe button isn't infused unless VISUALENHANCEMENTS are enabled
260            $output->setupOOUI();
261            $text = CommentFormatter::postprocessTopicSubscription(
262                $text, $lang, $this->subscriptionStore, $output->getUser(), $isMobile
263            );
264        }
265
266        if ( HookUtils::isFeatureEnabledForOutput( $output, HookUtils::REPLYTOOL ) ) {
267            $output->enableOOUI();
268            $text = CommentFormatter::postprocessReplyTool(
269                $text, $lang, $isMobile
270            );
271        }
272
273        if (
274            CommentFormatter::isEmptyTalkPage( $text ) &&
275            HookUtils::shouldDisplayEmptyState( $output->getContext() )
276        ) {
277            $output->enableOOUI();
278            $text = CommentFormatter::appendToEmptyTalkPage(
279                $text, $this->getEmptyStateHtml( $output->getContext() )
280            );
281            $output->addBodyClasses( 'ext-discussiontools-emptystate-shown' );
282        }
283
284        if ( HookUtils::isFeatureEnabledForOutput( $output, HookUtils::VISUALENHANCEMENTS ) ) {
285            $output->enableOOUI();
286            if ( HookUtils::isFeatureEnabledForOutput( $output, HookUtils::TOPICSUBSCRIPTION ) ) {
287                $output->addModuleStyles( [
288                    // Visually enhanced topic subscriptions
289                    // bell, bellOutline
290                    'oojs-ui.styles.icons-alerts',
291                ] );
292            }
293            if ( $isMobile ) {
294                $output->addModuleStyles( [
295                    // Mobile reply button:
296                    // share
297                    'oojs-ui.styles.icons-content',
298                    // Mobile overflow menu:
299                    // ellipsis
300                    'oojs-ui.styles.icons-interactions',
301                    // edit
302                    'oojs-ui.styles.icons-editing-core',
303                ] );
304            }
305            $text = CommentFormatter::postprocessVisualEnhancements(
306                $text, $lang, $output->getUser(), $isMobile
307            );
308
309            $subtitle = CommentFormatter::postprocessVisualEnhancementsSubtitle(
310                $text, $lang, $output->getUser()
311            );
312
313            if ( $subtitle ) {
314                $output->addSubtitle( $subtitle );
315            }
316        }
317
318        return true;
319    }
320
321    /**
322     * GetActionName hook handler
323     *
324     * @param IContextSource $context Request context
325     * @param string &$action Default action name, reassign to change it
326     * @return void This hook must not abort, it must return no value
327     */
328    public function onGetActionName( IContextSource $context, string &$action ): void {
329        if ( $action === 'edit' && (
330            HookUtils::shouldOpenNewTopicTool( $context ) ||
331            HookUtils::shouldDisplayEmptyState( $context )
332        ) ) {
333            $action = 'view';
334        }
335    }
336
337    /**
338     * BeforeDisplayNoArticleText hook handler
339     * @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforeDisplayNoArticleText
340     *
341     * @param Article $article The (empty) article
342     * @return bool|void This hook can abort
343     */
344    public function onBeforeDisplayNoArticleText( $article ) {
345        // We want to override the empty state for articles on which we would be enabled
346        $context = $article->getContext();
347        if ( !HookUtils::shouldDisplayEmptyState( $context ) ) {
348            // Our empty states are all about using the new topic tool, but
349            // expect to be on a talk page, so fall back if it's not
350            // available or if we're in a non-talk namespace that still has
351            // DT features enabled
352            return true;
353        }
354
355        $output = $context->getOutput();
356        $output->enableOOUI();
357        $output->disableClientCache();
358
359        $html = $this->getEmptyStateHtml( $context );
360
361        $output->addHTML(
362            // This being mw-parser-output is a lie, but makes the reply controller cope much better with everything
363            Html::rawElement( 'div', [ 'class' => 'mw-parser-output noarticletext' ], $html )
364        );
365        $output->addBodyClasses( 'ext-discussiontools-emptystate-shown' );
366
367        return false;
368    }
369
370    /**
371     * Generate HTML markup for the new topic tool's empty state, shown on talk pages that don't exist
372     * or have no topics.
373     *
374     * @param IContextSource $context
375     * @return string HTML
376     */
377    private function getEmptyStateHtml( IContextSource $context ): string {
378        $coreConfig = RequestContext::getMain()->getConfig();
379        $iconpath = $coreConfig->get( 'ExtensionAssetsPath' ) . '/DiscussionTools/images';
380
381        $dir = $context->getLanguage()->getDir();
382        $lang = $context->getLanguage()->getHtmlCode();
383
384        $titleMsg = false;
385        $descMsg = false;
386        $descParams = [];
387        $buttonMsg = 'discussiontools-emptystate-button';
388        $title = $context->getTitle();
389        if ( $title->getNamespace() == NS_USER_TALK && !$title->isSubpage() ) {
390            // This is a user talk page
391            $isIP = $this->userNameUtils->isIP( $title->getText() );
392            if ( $title->equals( $context->getUser()->getTalkPage() ) ) {
393                // This is your own user talk page
394                if ( $isIP ) {
395                    // You're an IP editor, so this is only *sort of* your talk page
396                    $titleMsg = 'discussiontools-emptystate-title-self-anon';
397                    $descMsg = 'discussiontools-emptystate-desc-self-anon';
398                    $query = $context->getRequest()->getValues();
399                    unset( $query['title'] );
400                    $descParams = [
401                        SpecialPage::getTitleFor( 'CreateAccount' )->getFullURL( [
402                            'returnto' => $context->getTitle()->getFullText(),
403                            'returntoquery' => wfArrayToCgi( $query ),
404                        ] ),
405                        SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [
406                            'returnto' => $context->getTitle()->getFullText(),
407                            'returntoquery' => wfArrayToCgi( $query ),
408                        ] ),
409                    ];
410                } else {
411                    // You're logged in, this is very much your talk page
412                    $titleMsg = 'discussiontools-emptystate-title-self';
413                    $descMsg = 'discussiontools-emptystate-desc-self';
414                }
415                $buttonMsg = false;
416            } elseif ( $isIP ) {
417                // This is an IP editor
418                $titleMsg = 'discussiontools-emptystate-title-user-anon';
419                $descMsg = 'discussiontools-emptystate-desc-user-anon';
420            } else {
421                // This is any other user
422                $titleMsg = 'discussiontools-emptystate-title-user';
423                $descMsg = 'discussiontools-emptystate-desc-user';
424            }
425        } else {
426            // This is any other page on which DT is enabled
427            $titleMsg = 'discussiontools-emptystate-title';
428            $descMsg = 'discussiontools-emptystate-desc';
429        }
430
431        $text =
432            Html::rawElement( 'h3', [],
433                $context->msg( $titleMsg )->parse()
434            ) .
435            Html::rawElement( 'div', [ 'class' => 'plainlinks' ],
436                $context->msg( $descMsg, $descParams )->parseAsBlock()
437            );
438
439        if ( $buttonMsg ) {
440            $text .= new ButtonWidget( [
441                'label' => $context->msg( $buttonMsg )->text(),
442                'href' => $title->getLocalURL( 'action=edit&section=new' ),
443                'flags' => [ 'primary', 'progressive' ]
444            ] );
445        }
446
447        $wrapped =
448            Html::rawElement( 'div', [ 'class' => 'ext-discussiontools-emptystate' ],
449                Html::rawElement( 'div', [ 'class' => 'ext-discussiontools-emptystate-text' ], $text ) .
450                Html::element( 'img', [
451                    'src' => $iconpath . '/emptystate.svg',
452                    'class' => 'ext-discussiontools-emptystate-logo',
453                    // This is a purely decorative element
454                    'alt' => '',
455                ] )
456            );
457
458        return $wrapped;
459    }
460
461    /**
462     * @param Title $title Title object for the page the edit notices are for
463     * @param int $oldid Revision ID that the edit notices are for (or 0 for latest)
464     * @param array &$notices Array of notices. Keys are i18n message keys, values are
465     *   parseAsBlock()ed messages.
466     * @return bool|void True or no return value to continue or false to abort
467     */
468    public function onTitleGetEditNotices( $title, $oldid, &$notices ) {
469        $context = RequestContext::getMain();
470
471        if (
472            // Hint is active
473            $this->userOptionsLookup->getOption( $context->getUser(), 'discussiontools-newtopictool-hint-shown' ) &&
474            // Turning off the new topic tool also dismisses the hint
475            $this->userOptionsLookup->getOption( $context->getUser(), 'discussiontools-' . HookUtils::NEWTOPICTOOL ) &&
476            // Only show when following the link from the new topic tool, never on normal edit attempts.
477            // This can be called from within ApiVisualEditor, so we can't access most request parameters
478            // for the main request. However, we can access 'editintro', because it's passed to the API.
479            $context->getRequest()->getRawVal( 'editintro' ) === 'mw-dt-topic-hint'
480        ) {
481            $context->getOutput()->enableOOUI();
482
483            $returnUrl = $title->getFullURL( [
484                'action' => 'edit',
485                'section' => 'new',
486                'dtenable' => '1',
487            ] );
488            $prefUrl = SpecialPage::getTitleFor( 'Preferences' )
489                ->createFragmentTarget( 'mw-prefsection-editing-discussion' )->getFullURL();
490
491            $topicHint = new MessageWidget( [
492                'label' => new HtmlSnippet( wfMessage( 'discussiontools-newtopic-legacy-hint-return' )
493                    ->params( $returnUrl, $prefUrl )->parse() ),
494                'icon' => 'article',
495                'classes' => [ 'ext-discussiontools-ui-newTopic-hint-return' ],
496            ] );
497
498            // Add our notice above the built-in ones
499            $notices = [
500                'discussiontools-newtopic-legacy-hint-return' => (string)$topicHint,
501            ] + $notices;
502        }
503    }
504}