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