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