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