Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
32.51% covered (danger)
32.51%
92 / 283
9.09% covered (danger)
9.09%
1 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageHooks
32.51% covered (danger)
32.51%
92 / 283
9.09% covered (danger)
9.09%
1 / 11
1948.38
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 isMobile
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 onBeforePageDisplay
0.00% covered (danger)
0.00%
0 / 84
0.00% covered (danger)
0.00%
0 / 1
462
 onOutputPageBeforeHTML
76.09% covered (warning)
76.09%
35 / 46
0.00% covered (danger)
0.00%
0 / 1
13.97
 onOutputPageParserOutput
55.00% covered (warning)
55.00%
11 / 20
0.00% covered (danger)
0.00%
0 / 1
22.03
 onGetActionName
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 onBeforeDisplayNoArticleText
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 getEmptyStateHtml
44.83% covered (danger)
44.83%
26 / 58
0.00% covered (danger)
0.00%
0 / 1
26.79
 onSidebarBeforeOutput
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 onSkinTemplateNavigation__Universal
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 getNewTopicsSubscriptionButton
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
56
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 MediaWiki\Actions\Hook\GetActionNameHook;
13use MediaWiki\Context\IContextSource;
14use MediaWiki\Context\RequestContext;
15use MediaWiki\Extension\DiscussionTools\BatchModifyElements;
16use MediaWiki\Extension\DiscussionTools\CommentFormatter;
17use MediaWiki\Extension\DiscussionTools\CommentUtils;
18use MediaWiki\Extension\DiscussionTools\SubscriptionStore;
19use MediaWiki\Extension\VisualEditor\Hooks as VisualEditorHooks;
20use MediaWiki\Hook\SidebarBeforeOutputHook;
21use MediaWiki\Hook\SkinTemplateNavigation__UniversalHook;
22use MediaWiki\Html\Html;
23use MediaWiki\MediaWikiServices;
24use MediaWiki\Output\Hook\BeforePageDisplayHook;
25use MediaWiki\Output\Hook\OutputPageBeforeHTMLHook;
26use MediaWiki\Output\Hook\OutputPageParserOutputHook;
27use MediaWiki\Output\OutputPage;
28use MediaWiki\Page\Article;
29use MediaWiki\Page\Hook\BeforeDisplayNoArticleTextHook;
30use MediaWiki\Parser\ParserOutput;
31use MediaWiki\Registration\ExtensionRegistry;
32use MediaWiki\Skin\Skin;
33use MediaWiki\Skin\SkinTemplate;
34use MediaWiki\SpecialPage\SpecialPage;
35use MediaWiki\Title\Title;
36use MediaWiki\User\Options\UserOptionsLookup;
37use MediaWiki\User\UserIdentity;
38use MediaWiki\User\UserNameUtils;
39use OOUI\ButtonWidget;
40
41class PageHooks implements
42    BeforeDisplayNoArticleTextHook,
43    BeforePageDisplayHook,
44    GetActionNameHook,
45    OutputPageBeforeHTMLHook,
46    OutputPageParserOutputHook,
47    SidebarBeforeOutputHook,
48    SkinTemplateNavigation__UniversalHook
49{
50
51    private SubscriptionStore $subscriptionStore;
52    private UserNameUtils $userNameUtils;
53    private UserOptionsLookup $userOptionsLookup;
54
55    public function __construct(
56        SubscriptionStore $subscriptionStore,
57        UserNameUtils $userNameUtils,
58        UserOptionsLookup $userOptionsLookup
59    ) {
60        $this->subscriptionStore = $subscriptionStore;
61        $this->userNameUtils = $userNameUtils;
62        $this->userOptionsLookup = $userOptionsLookup;
63    }
64
65    private function isMobile(): bool {
66        if ( ExtensionRegistry::getInstance()->isLoaded( 'MobileFrontend' ) ) {
67            /** @var \MobileContext $mobFrontContext */
68            $mobFrontContext = MediaWikiServices::getInstance()->getService( 'MobileFrontend.Context' );
69            return $mobFrontContext->shouldDisplayMobileView();
70        }
71        return false;
72    }
73
74    /**
75     * Adds DiscussionTools JS to the output.
76     *
77     * This is attached to the MediaWiki 'BeforePageDisplay' hook.
78     *
79     * @param OutputPage $output
80     * @param Skin $skin
81     * @return void This hook must not abort, it must return no value
82     */
83    public function onBeforePageDisplay( $output, $skin ): void {
84        $user = $output->getUser();
85        $req = $output->getRequest();
86        $title = $output->getTitle();
87
88        foreach ( HookUtils::FEATURES as $feature ) {
89            // Add a CSS class for each enabled feature
90            if ( HookUtils::isFeatureEnabledForOutput( $output, $feature ) ) {
91                // The following CSS classes are generated here:
92                // * ext-discussiontools-replytool-enabled
93                // * ext-discussiontools-newtopictool-enabled
94                // * ext-discussiontools-sourcemodetoolbar-enabled
95                // * ext-discussiontools-topicsubscription-enabled
96                // * ext-discussiontools-autotopicsub-enabled
97                // * ext-discussiontools-visualenhancements-enabled
98                // * ext-discussiontools-visualenhancements_reply-enabled
99                // * ext-discussiontools-visualenhancements_pageframe-enabled
100                $output->addBodyClasses( "ext-discussiontools-$feature-enabled" );
101            }
102        }
103
104        $isMobile = $this->isMobile();
105
106        if ( $isMobile && HookUtils::isFeatureEnabledForOutput( $output, HookUtils::VISUALENHANCEMENTS ) ) {
107            $output->addBodyClasses( 'collapsible-headings-collapsed' );
108        }
109
110        // Load style modules if the tools can be available for the title
111        // to selectively hide DT features, depending on the body classes added above.
112        if ( HookUtils::isAvailableForTitle( $title ) ) {
113            $output->addModuleStyles( 'ext.discussionTools.init.styles' );
114        }
115
116        // Load modules if any DT feature is enabled for this user
117        if ( HookUtils::isFeatureEnabledForOutput( $output ) ) {
118            $output->addModules( 'ext.discussionTools.init' );
119
120            $enabledVars = [];
121            foreach ( HookUtils::FEATURES as $feature ) {
122                $enabledVars[$feature] = HookUtils::isFeatureEnabledForOutput( $output, $feature );
123            }
124            $output->addJsConfigVars( 'wgDiscussionToolsFeaturesEnabled', $enabledVars );
125
126            $editor = $this->userOptionsLookup->getOption( $user, 'discussiontools-editmode' );
127            // User has no preferred editor yet
128            // If the user has a preferred editor, this will be evaluated in the client
129            if ( !$editor ) {
130                // Check which editor we would use for articles
131                // VE pref is 'visualeditor'/'wikitext'. Here we describe the mode,
132                // not the editor, so 'visual'/'source'
133                $editor = VisualEditorHooks::getPreferredEditor( $user, $req ) === 'visualeditor' ?
134                    'visual' : 'source';
135                $output->addJsConfigVars(
136                    'wgDiscussionToolsFallbackEditMode',
137                    $editor
138                );
139            }
140        }
141
142        // Replace the action=edit&section=new form with the new topic tool.
143        if ( HookUtils::shouldOpenNewTopicTool( $output->getContext() ) ) {
144            $output->addJsConfigVars( 'wgDiscussionToolsStartNewTopicTool', true );
145
146            // For no-JS compatibility, redirect to the old new section editor if JS is unavailable.
147            // This isn't great, because the user has to load the page twice. But making a page that is
148            // both a view mode and an edit mode seems difficult, so I'm cutting some corners here.
149            // (Code below adapted from VisualEditor.)
150            $params = $output->getRequest()->getValues();
151            $params['dtenable'] = '0';
152            $url = wfScript() . '?' . wfArrayToCgi( $params );
153            $escapedUrl = htmlspecialchars( $url );
154
155            // Redirect if the user has no JS (<noscript>)
156            $output->addHeadItem(
157                'dt-noscript-fallback',
158                "<noscript><meta http-equiv=\"refresh\" content=\"0; url=$escapedUrl\"></noscript>"
159            );
160            // Redirect if the user has no ResourceLoader
161            $output->addScript( Html::inlineScript(
162                "(window.NORLQ=window.NORLQ||[]).push(" .
163                    "function(){" .
164                        "location.href=\"$url\";" .
165                    "}" .
166                ");"
167            ) );
168        }
169
170        if ( $isMobile ) {
171            if (
172                $title->isTalkPage() &&
173                HookUtils::isFeatureEnabledForOutput( $output, HookUtils::REPLYTOOL ) && (
174                    // 'DiscussionTools-ledeButton' property may be already set to true or false.
175                    // Examine the other conditions only if it's unset.
176                    $output->getProperty( 'DiscussionTools-ledeButton' ) ?? (
177                        // Header shown on all talk pages, see Article::showNamespaceHeader
178                        !$output->getContext()->msg( 'talkpageheader' )->isDisabled() &&
179                        // Check if it isn't empty since it may use parser functions to only show itself on some pages
180                        trim( $output->getContext()->msg( 'talkpageheader' )->text() ) !== ''
181                    )
182                )
183            ) {
184                $output->addBodyClasses( 'ext-discussiontools-init-lede-hidden' );
185                $output->enableOOUI();
186                $output->prependHTML(
187                    Html::rawElement( 'div',
188                        [ 'class' => 'ext-discussiontools-init-lede-button-container' ],
189                        ( new ButtonWidget( [
190                            'label' => $output->getContext()->msg( 'discussiontools-ledesection-button' )->text(),
191                            'classes' => [ 'ext-discussiontools-init-lede-button' ],
192                            'framed' => false,
193                            'icon' => 'info',
194                            'infusable' => true,
195                        ] ) )
196                    )
197                );
198            }
199        }
200
201        if ( $output->getSkin()->getSkinName() === 'minerva' ) {
202            if (
203                ( $req->getRawVal( 'action' ) ?? 'view' ) === 'view' &&
204                HookUtils::isFeatureEnabledForOutput( $output, HookUtils::NEWTOPICTOOL ) &&
205                // Only add the button if "New section" tab would be shown in a normal skin.
206                HookUtils::shouldShowNewSectionTab( $output->getContext() )
207            ) {
208                $output->enableOOUI();
209                // For speechBubbleAdd
210                $output->addModuleStyles( 'oojs-ui.styles.icons-alerts' );
211                $output->addBodyClasses( 'ext-discussiontools-init-new-topic-opened' );
212
213                // Minerva doesn't show a new topic button.
214                $output->addHTML( Html::rawElement( 'div',
215                    [ 'class' => 'ext-discussiontools-init-new-topic' ],
216                    ( new ButtonWidget( [
217                        'classes' => [ 'ext-discussiontools-init-new-topic-button' ],
218                        'href' => $title->getLinkURL( [ 'action' => 'edit', 'section' => 'new' ] ),
219                        'icon' => 'speechBubbleAdd',
220                        'label' => $output->getContext()->msg( 'skin-action-addsection' )->text(),
221                        'flags' => [ 'progressive', 'primary' ],
222                        'infusable' => true,
223                    ] ) )
224                        // For compatibility with MobileWebUIActionsTracking logging (T295490)
225                        ->setAttributes( [ 'data-event-name' => 'talkpage.add-topic' ] )
226                ) );
227            }
228
229            if ( HookUtils::isFeatureEnabledForOutput( $output, HookUtils::TOPICSUBSCRIPTION ) ) {
230                $output->addModuleStyles( 'ext.discussionTools.minervaicons' );
231            }
232        }
233    }
234
235    /**
236     * OutputPageBeforeHTML hook handler
237     * @see https://www.mediawiki.org/wiki/Manual:Hooks/OutputPageBeforeHTML
238     *
239     * @param OutputPage $output OutputPage object that corresponds to the page
240     * @param string &$text Text that will be displayed, in HTML
241     * @return bool|void This hook must not abort, it must return true or null.
242     */
243    public function onOutputPageBeforeHTML( $output, &$text ) {
244        // ParserOutputPostCacheTransform hook would be a better place to do this,
245        // so that when the ParserOutput is used directly without using this hook,
246        // we don't leave half-baked interface elements in it (see e.g. T292345, T294168).
247        // But that hook doesn't provide parameters that we need to render correctly
248        // (including the page title, interface language, and current user).
249
250        // This hook can be executed more than once per page view if the page content is composed from
251        // multiple sources!
252
253        $batchModifyElements = new BatchModifyElements();
254
255        // Match the check in ParserHooks::transformHtml which adds the timestamp link placeholders
256        if ( HookUtils::isAvailableForTitle( $output->getTitle() ) ) {
257            CommentFormatter::postprocessTimestampLinks( $text, $batchModifyElements, $output );
258        }
259
260        $isMobile = $this->isMobile();
261        $visualEnhancementsEnabled =
262            HookUtils::isFeatureEnabledForOutput( $output, HookUtils::VISUALENHANCEMENTS );
263        $visualEnhancementsReplyEnabled =
264            HookUtils::isFeatureEnabledForOutput( $output, HookUtils::VISUALENHANCEMENTS_REPLY );
265
266        if ( HookUtils::isFeatureEnabledForOutput( $output, HookUtils::TOPICSUBSCRIPTION ) ) {
267            // Just enable OOUI PHP - the OOUI subscribe button isn't infused unless VISUALENHANCEMENTS are enabled
268            $output->setupOOUI();
269            CommentFormatter::postprocessTopicSubscription(
270                $text, $batchModifyElements, $output, $this->subscriptionStore, $isMobile, $visualEnhancementsEnabled
271            );
272        } else {
273            CommentFormatter::removeTopicSubscription( $batchModifyElements );
274        }
275
276        if ( HookUtils::isFeatureEnabledForOutput( $output, HookUtils::REPLYTOOL ) ) {
277            $output->enableOOUI();
278            CommentFormatter::postprocessReplyTool(
279                $text, $batchModifyElements, $output, $isMobile, $visualEnhancementsReplyEnabled
280            );
281        } else {
282            CommentFormatter::removeReplyTool( $batchModifyElements );
283        }
284
285        if ( $visualEnhancementsEnabled ) {
286            $output->enableOOUI();
287            if ( HookUtils::isFeatureEnabledForOutput( $output, HookUtils::TOPICSUBSCRIPTION ) ) {
288                // Visually enhanced topic subscriptions: bell, bellOutline
289                $output->addModuleStyles( 'oojs-ui.styles.icons-alerts' );
290            }
291            if (
292                $isMobile ||
293                (
294                    $visualEnhancementsReplyEnabled &&
295                    CommentFormatter::isLanguageRequiringReplyIcon( $output->getLanguage() )
296                )
297            ) {
298                // Reply button: share
299                $output->addModuleStyles( 'oojs-ui.styles.icons-content' );
300            }
301            $output->addModuleStyles( [
302                // Overflow menu ('ellipsis' icon)
303                'oojs-ui.styles.icons-interactions',
304            ] );
305            if ( $isMobile ) {
306                $output->addModuleStyles( [
307                    // Edit button in overflow menu ('edit' icon)
308                    'oojs-ui.styles.icons-editing-core',
309                ] );
310            }
311            CommentFormatter::postprocessVisualEnhancements( $text, $batchModifyElements, $output, $isMobile );
312        } else {
313            CommentFormatter::removeVisualEnhancements( $batchModifyElements );
314        }
315
316        $text = $batchModifyElements->apply( $text );
317
318        // Append empty state if the OutputPageParserOutput hook decided that we should.
319        // This depends on the order in which the hooks run. Hopefully it doesn't change.
320        if ( $output->getProperty( 'DiscussionTools-emptyStateHtml' ) ) {
321            // Insert before the last </div> tag, which should belong to <div class="mw-parser-output">
322            $idx = strrpos( $text, '</div>' );
323            $text = substr_replace(
324                $text,
325                $output->getProperty( 'DiscussionTools-emptyStateHtml' ),
326                $idx === false ? strlen( $text ) : $idx,
327                0
328            );
329        }
330    }
331
332    /**
333     * OutputPageParserOutput hook handler
334     * @see https://www.mediawiki.org/wiki/Manual:Hooks/OutputPageParserOutput
335     *
336     * @param OutputPage $output
337     * @param ParserOutput $pout ParserOutput instance being added in $output
338     * @return void This hook must not abort, it must return no value
339     */
340    public function onOutputPageParserOutput( $output, $pout ): void {
341        // ParserOutputPostCacheTransform hook would be a better place to do this,
342        // so that when the ParserOutput is used directly without using this hook,
343        // we don't leave half-baked interface elements in it (see e.g. T292345, T294168).
344        // But that hook doesn't provide parameters that we need to render correctly
345        // (including the page title, interface language, and current user).
346
347        // This hook can be executed more than once per page view if the page content is composed from
348        // multiple sources!
349
350        CommentFormatter::postprocessTableOfContents( $pout, $output );
351
352        if (
353            CommentFormatter::isEmptyTalkPage( $pout ) &&
354            HookUtils::shouldDisplayEmptyState( $output->getContext() )
355        ) {
356            $output->enableOOUI();
357            // This must be appended after the content of the page, which wasn't added to OutputPage yet.
358            // Pass it to the OutputPageBeforeHTML hook, so that it may add it at the right time.
359            // This depends on the order in which the hooks run. Hopefully it doesn't change.
360            $output->setProperty( 'DiscussionTools-emptyStateHtml',
361                $this->getEmptyStateHtml( $output->getContext() ) );
362            $output->addBodyClasses( 'ext-discussiontools-emptystate-shown' );
363        }
364
365        if ( HookUtils::isFeatureEnabledForOutput( $output, HookUtils::VISUALENHANCEMENTS ) ) {
366            $subtitle = CommentFormatter::postprocessVisualEnhancementsSubtitle( $pout, $output );
367
368            if ( $subtitle ) {
369                $output->addSubtitle( $subtitle );
370            }
371        }
372
373        if ( $output->getSkin()->getSkinName() === 'minerva' ) {
374            $title = $output->getTitle();
375
376            if (
377                $title->isTalkPage() &&
378                HookUtils::isFeatureEnabledForOutput( $output, HookUtils::REPLYTOOL )
379            ) {
380                if (
381                    CommentFormatter::hasCommentsInLedeContent( $pout )
382                ) {
383                    // If there are comments in the lede section, we can't really separate them from other lede
384                    // content, so keep the whole section visible.
385                    $output->setProperty( 'DiscussionTools-ledeButton', false );
386
387                } elseif (
388                    CommentFormatter::hasLedeContent( $pout ) &&
389                    $output->getProperty( 'DiscussionTools-ledeButton' ) === null
390                ) {
391                    // If there is lede content and the lede button hasn't been disabled above, enable it.
392                    $output->setProperty( 'DiscussionTools-ledeButton', true );
393                }
394            }
395        }
396    }
397
398    /**
399     * GetActionName hook handler
400     *
401     * @param IContextSource $context Request context
402     * @param string &$action Default action name, reassign to change it
403     * @return void This hook must not abort, it must return no value
404     */
405    public function onGetActionName( IContextSource $context, string &$action ): void {
406        if ( $action === 'edit' && (
407            HookUtils::shouldOpenNewTopicTool( $context ) ||
408            HookUtils::shouldDisplayEmptyState( $context )
409        ) ) {
410            $action = 'view';
411        }
412    }
413
414    /**
415     * BeforeDisplayNoArticleText hook handler
416     * @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforeDisplayNoArticleText
417     *
418     * @param Article $article The (empty) article
419     * @return bool|void This hook can abort
420     */
421    public function onBeforeDisplayNoArticleText( $article ) {
422        // We want to override the empty state for articles on which we would be enabled
423        $context = $article->getContext();
424        if ( !HookUtils::shouldDisplayEmptyState( $context ) ) {
425            // Our empty states are all about using the new topic tool, but
426            // expect to be on a talk page, so fall back if it's not
427            // available or if we're in a non-talk namespace that still has
428            // DT features enabled
429            return true;
430        }
431
432        $output = $context->getOutput();
433        $output->enableOOUI();
434        $output->disableClientCache();
435
436        $html = $this->getEmptyStateHtml( $context );
437
438        $output->addHTML(
439            // This being mw-parser-output is a lie, but makes the reply controller cope much better with everything
440            Html::rawElement( 'div', [ 'class' => 'mw-parser-output noarticletext' ], $html )
441        );
442        $output->addBodyClasses( 'ext-discussiontools-emptystate-shown' );
443
444        return false;
445    }
446
447    /**
448     * Generate HTML markup for the new topic tool's empty state, shown on talk pages that don't exist
449     * or have no topics.
450     *
451     * @param IContextSource $context
452     * @return string HTML
453     */
454    private function getEmptyStateHtml( IContextSource $context ): string {
455        $coreConfig = RequestContext::getMain()->getConfig();
456
457        $descParams = [];
458        $buttonMsg = 'discussiontools-emptystate-button';
459        $title = $context->getTitle();
460        if ( $title->inNamespace( NS_USER_TALK ) && !$title->isSubpage() ) {
461            // This is a user talk page
462            $isIP = $this->userNameUtils->isIP( $title->getText() );
463            $isTemp = $this->userNameUtils->isTemp( $title->getText() );
464            if ( $title->equals( $context->getUser()->getTalkPage() ) ) {
465                // This is your own user talk page
466                if ( $isIP || $isTemp ) {
467                    if ( $isIP ) {
468                        // You're an IP editor, so this is only *sort of* your talk page
469                        $titleMsg = 'discussiontools-emptystate-title-self-anon';
470                        $descMsg = 'discussiontools-emptystate-desc-self-anon';
471                    } else {
472                        // You're a temporary user, so you don't get some of the good stuff
473                        $titleMsg = 'discussiontools-emptystate-title-self-temp';
474                        $descMsg = 'discussiontools-emptystate-desc-self-temp';
475                    }
476                    $query = $context->getRequest()->getValues();
477                    unset( $query['title'] );
478                    $descParams = [
479                        SpecialPage::getTitleFor( 'CreateAccount' )->getFullURL( [
480                            'returnto' => $context->getTitle()->getFullText(),
481                            'returntoquery' => wfArrayToCgi( $query ),
482                        ] ),
483                        SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [
484                            'returnto' => $context->getTitle()->getFullText(),
485                            'returntoquery' => wfArrayToCgi( $query ),
486                        ] ),
487                    ];
488                } else {
489                    // You're logged in, this is very much your talk page
490                    $titleMsg = 'discussiontools-emptystate-title-self';
491                    $descMsg = 'discussiontools-emptystate-desc-self';
492                }
493                $buttonMsg = false;
494            } elseif ( $isIP ) {
495                // This is an IP editor
496                $titleMsg = 'discussiontools-emptystate-title-user-anon';
497                $descMsg = 'discussiontools-emptystate-desc-user-anon';
498            } elseif ( $isTemp ) {
499                // This is a temporary user
500                $titleMsg = 'discussiontools-emptystate-title-user-temp';
501                $descMsg = 'discussiontools-emptystate-desc-user-temp';
502            } else {
503                // This is any other user
504                $titleMsg = 'discussiontools-emptystate-title-user';
505                $descMsg = 'discussiontools-emptystate-desc-user';
506            }
507        } else {
508            // This is any other page on which DT is enabled
509            $titleMsg = 'discussiontools-emptystate-title';
510            $descMsg = 'discussiontools-emptystate-desc';
511        }
512
513        $text =
514            Html::rawElement( 'h3', [],
515                $context->msg( $titleMsg )->parse()
516            ) .
517            Html::rawElement( 'div', [ 'class' => 'plainlinks' ],
518                $context->msg( $descMsg, ...$descParams )->parseAsBlock()
519            );
520
521        if ( $buttonMsg ) {
522            $text .= new ButtonWidget( [
523                'label' => $context->msg( $buttonMsg )->text(),
524                'href' => $title->getLocalURL( 'action=edit&section=new' ),
525                'flags' => [ 'primary', 'progressive' ]
526            ] );
527        }
528
529        $wrapped =
530            Html::rawElement( 'div', [ 'class' => 'ext-discussiontools-emptystate' ],
531                Html::rawElement( 'div', [ 'class' => 'ext-discussiontools-emptystate-text' ], $text ) .
532                Html::element( 'div', [ 'class' => 'ext-discussiontools-emptystate-logo' ] )
533            );
534
535        return $wrapped;
536    }
537
538    /**
539     * @param Skin $skin
540     * @param array &$sidebar
541     */
542    public function onSidebarBeforeOutput( $skin, &$sidebar ): void {
543        $output = $skin->getOutput();
544        if (
545            $skin->getSkinName() === 'minerva' &&
546            HookUtils::isFeatureEnabledForOutput( $output, HookUtils::TOPICSUBSCRIPTION )
547        ) {
548            $button = $this->getNewTopicsSubscriptionButton(
549                $skin->getUser(),
550                $skin->getTitle(),
551                $skin->getContext()
552            );
553            $sidebar['TOOLBOX']['t-page-subscribe'] = [
554                'icon' => $button['icon'],
555                'text' => $button['label'],
556                'href' => $button['href'],
557            ];
558        }
559    }
560
561    /**
562     * @param SkinTemplate $sktemplate
563     * @param array &$links
564     * @return void
565     * @phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
566     */
567    public function onSkinTemplateNavigation__Universal( $sktemplate, &$links ): void {
568        $output = $sktemplate->getOutput();
569        if ( HookUtils::isFeatureEnabledForOutput( $output, HookUtils::TOPICSUBSCRIPTION ) ) {
570            $button = $this->getNewTopicsSubscriptionButton(
571                $sktemplate->getUser(),
572                $sktemplate->getTitle(),
573                $sktemplate->getContext()
574            );
575
576            $links['actions']['dt-page-subscribe'] = [
577                'text' => $button['label'],
578                'title' => $button['tooltip'],
579                'data-mw-subscribed' => $button['isSubscribed'] ? '1' : '0',
580                'href' => $button['href'],
581            ];
582
583            $output->addModules( [ 'ext.discussionTools.init' ] );
584        }
585
586        if (
587            $sktemplate->getSkinName() === 'minerva' &&
588            HookUtils::isFeatureEnabledForOutput( $output, HookUtils::NEWTOPICTOOL )
589        ) {
590            // Remove duplicate add topic button from page actions (T395980)
591            unset( $links[ 'views' ][ 'addsection' ] );
592        }
593    }
594
595    /**
596     * Get data from a new topics subcription button
597     *
598     * @param UserIdentity $user User
599     * @param Title $title Title
600     * @param IContextSource $context Context
601     * @return array Array containing label, tooltip, icon, isSubscribed and href.
602     */
603    private function getNewTopicsSubscriptionButton(
604        UserIdentity $user, Title $title, IContextSource $context
605    ): array {
606        $items = $this->subscriptionStore->getSubscriptionItemsForUser(
607            $user,
608            [ CommentUtils::getNewTopicsSubscriptionId( $title ) ]
609        );
610        $subscriptionItem = count( $items ) ? $items[ 0 ] : null;
611        $isSubscribed = $subscriptionItem && !$subscriptionItem->isMuted();
612
613        return [
614            'label' => $context->msg( $isSubscribed ?
615                'discussiontools-newtopicssubscription-button-unsubscribe-label' :
616                'discussiontools-newtopicssubscription-button-subscribe-label'
617            )->text(),
618            'tooltip' => $context->msg( $isSubscribed ?
619                'discussiontools-newtopicssubscription-button-unsubscribe-tooltip' :
620                'discussiontools-newtopicssubscription-button-subscribe-tooltip'
621            )->text(),
622            'icon' => $isSubscribed ? 'bell' : 'bellOutline',
623            'isSubscribed' => $isSubscribed,
624            'href' => $title->getLinkURL( [
625                'action' => $isSubscribed ? 'dtunsubscribe' : 'dtsubscribe',
626                'commentname' => CommentUtils::getNewTopicsSubscriptionId( $title ),
627            ] ),
628        ];
629    }
630}