Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.47% covered (warning)
82.47%
428 / 519
39.13% covered (danger)
39.13%
9 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentFormatter
82.47% covered (warning)
82.47%
428 / 519
39.13% covered (danger)
39.13%
9 / 23
162.30
0.00% covered (danger)
0.00%
0 / 1
 getParser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHookRunner
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addDiscussionTools
61.11% covered (warning)
61.11%
11 / 18
0.00% covered (danger)
0.00%
0 / 1
3.53
 handleHeading
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
9
 addTopicContainer
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
3
 addSubscribeLink
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 addDiscussionToolsInternal
94.62% covered (success)
94.62%
88 / 93
0.00% covered (danger)
0.00%
0 / 1
27.11
 addOverflowMenuButton
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 removeInteractiveTools
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 postprocessTopicSubscription
92.22% covered (success)
92.22%
83 / 90
0.00% covered (danger)
0.00%
0 / 1
15.11
 postprocessReplyTool
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
4
 metaLabel
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getJsonArrayForCommentMarker
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getJsonForHeadingMarker
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getSignatureRelativeTime
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 postprocessVisualEnhancements
97.96% covered (success)
97.96%
96 / 98
0.00% covered (danger)
0.00%
0 / 1
8
 postprocessVisualEnhancementsSubtitle
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
30
 postprocessTableOfContents
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 isEmptyTalkPage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 appendToEmptyTalkPage
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 hasLedeContent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasCommentsInLedeContent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isLanguageRequiringReplyIcon
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
5.25
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools;
4
5use IContextSource;
6use Language;
7use MediaWiki\Config\ConfigException;
8use MediaWiki\Extension\DiscussionTools\Hooks\HookRunner;
9use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
10use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem;
11use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentHeadingItem;
12use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentThreadItem;
13use MediaWiki\Extension\DiscussionTools\ThreadItem\ThreadItem;
14use MediaWiki\Html\Html;
15use MediaWiki\MediaWikiServices;
16use MediaWiki\Parser\Sanitizer;
17use MediaWiki\Request\WebRequest;
18use MediaWiki\Title\Title;
19use MediaWiki\User\UserIdentity;
20use MediaWiki\Utils\MWTimestamp;
21use MWExceptionHandler;
22use ParserOutput;
23use Throwable;
24use Wikimedia\Assert\Assert;
25use Wikimedia\Parsoid\DOM\Document;
26use Wikimedia\Parsoid\DOM\Element;
27use Wikimedia\Parsoid\Utils\DOMCompat;
28use Wikimedia\Parsoid\Utils\DOMUtils;
29use Wikimedia\Parsoid\Wt2Html\XMLSerializer;
30use Wikimedia\Timestamp\TimestampException;
31
32class CommentFormatter {
33    // List of features which, when enabled, cause the comment formatter to run
34    public const USE_WITH_FEATURES = [
35        HookUtils::REPLYTOOL,
36        HookUtils::TOPICSUBSCRIPTION,
37        HookUtils::VISUALENHANCEMENTS
38    ];
39
40    /**
41     * Get a comment parser object for a DOM element
42     *
43     * This method exists so it can mocked in tests.
44     */
45    protected static function getParser(): CommentParser {
46        return MediaWikiServices::getInstance()->getService( 'DiscussionTools.CommentParser' );
47    }
48
49    protected static function getHookRunner(): HookRunner {
50        return new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
51    }
52
53    /**
54     * Add discussion tools to some HTML
55     *
56     * @param string &$text Parser text output (modified by reference)
57     * @param ParserOutput $pout ParserOutput object for metadata, e.g. parser limit report
58     * @param Title $title
59     */
60    public static function addDiscussionTools( string &$text, ParserOutput $pout, Title $title ): void {
61        $start = microtime( true );
62        $requestId = null;
63
64        try {
65            $text = static::addDiscussionToolsInternal( $text, $pout, $title );
66
67        } catch ( Throwable $e ) {
68            // Catch errors, so that they don't cause the entire page to not display.
69            // Log it and report the request ID to make it easier to find in the logs.
70            MWExceptionHandler::logException( $e );
71            $requestId = WebRequest::getRequestId();
72        }
73
74        $duration = microtime( true ) - $start;
75
76        $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
77        $stats->timing( 'discussiontools.addReplyLinks', $duration * 1000 );
78
79        // How long this method took, in seconds
80        $pout->setLimitReportData(
81            // The following messages can be generated upstream
82            // * discussiontools-limitreport-timeusage-value
83            // * discussiontools-limitreport-timeusage-value-text
84            // * discussiontools-limitreport-timeusage-value-html
85            'discussiontools-limitreport-timeusage',
86            sprintf( '%.3f', $duration )
87        );
88        if ( $requestId ) {
89            // Request ID where errors were logged (only if an error occurred)
90            $pout->setLimitReportData(
91                'discussiontools-limitreport-errorreqid',
92                $requestId
93            );
94        }
95    }
96
97    /**
98     * Add a wrapper, topic container, and subscribe link around a heading element
99     *
100     * @param Element $headingElement Heading element
101     * @param ContentHeadingItem|null $headingItem Heading item
102     * @param array|null &$tocInfo TOC info
103     * @return Element Wrapper element (either found or newly added)
104     */
105    protected static function handleHeading(
106        Element $headingElement,
107        ?ContentHeadingItem $headingItem = null,
108        ?array &$tocInfo = null
109    ): Element {
110        $doc = $headingElement->ownerDocument;
111        $wrapperNode = $headingElement->parentNode;
112        if ( !(
113            $wrapperNode instanceof Element &&
114            DOMCompat::getClassList( $wrapperNode )->contains( 'mw-heading' )
115        ) ) {
116            // Do not add the wrapper if the heading has attributes generated from wikitext (T353489).
117            // Only allow reserved attributes (e.g. 'data-mw', which can't be used in wikitext, but which
118            // are used internally by our own code and by Parsoid) and the 'id' attribute used by Parsoid.
119            foreach ( $headingElement->attributes as $attr ) {
120                if ( $attr->name !== 'id' && !Sanitizer::isReservedDataAttribute( $attr->name ) ) {
121                    return $headingElement;
122                }
123            }
124
125            $wrapperNode = $doc->createElement( 'div' );
126            $headingElement->parentNode->insertBefore( $wrapperNode, $headingElement );
127            $wrapperNode->appendChild( $headingElement );
128        }
129
130        if ( !$headingItem ) {
131            return $wrapperNode;
132        }
133
134        $uneditable = DOMCompat::querySelector( $wrapperNode, 'mw\\:editsection' ) === null;
135        $headingItem->setUneditableSection( $uneditable );
136        self::addOverflowMenuButton( $headingItem, $doc, $wrapperNode );
137
138        $latestReplyItem = $headingItem->getLatestReply();
139
140        $bar = null;
141        if ( $latestReplyItem ) {
142            $bar = $doc->createElement( 'div' );
143            $bar->setAttribute(
144                'class',
145                'ext-discussiontools-init-section-bar'
146            );
147        }
148
149        self::addTopicContainer(
150            $wrapperNode, $latestReplyItem, $doc, $headingItem, $bar, $tocInfo
151        );
152
153        self::addSubscribeLink(
154            $headingItem, $doc, $wrapperNode, $latestReplyItem, $bar
155        );
156
157        if ( $latestReplyItem ) {
158            // The check for if ( $latestReplyItem ) prevents $bar from being null
159            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
160            $wrapperNode->appendChild( $bar );
161        }
162
163        return $wrapperNode;
164    }
165
166    /**
167     * Add a topic container around a heading element.
168     *
169     * A topic container is the information displayed when the "Show discusion activity" user
170     * preference is selected. This displays information such as the latest comment time, number
171     * of comments, and number of editors in the discussion.
172     */
173    protected static function addTopicContainer(
174        Element $wrapperNode,
175        ?ContentCommentItem $latestReplyItem,
176        Document $doc,
177        ContentHeadingItem $headingItem,
178        ?Element $bar,
179        array &$tocInfo
180    ) {
181        if ( !DOMCompat::getClassList( $wrapperNode )->contains( 'mw-heading' ) ) {
182            DOMCompat::getClassList( $wrapperNode )->add( 'mw-heading' );
183            DOMCompat::getClassList( $wrapperNode )->add( 'mw-heading2' );
184        }
185        DOMCompat::getClassList( $wrapperNode )->add( 'ext-discussiontools-init-section' );
186
187        if ( !$latestReplyItem ) {
188            return;
189        }
190
191        $latestReplyJSON = json_encode( static::getJsonArrayForCommentMarker( $latestReplyItem ) );
192        $latestReply = $doc->createComment(
193            // Timestamp output varies by user timezone, so is formatted later
194            '__DTLATESTCOMMENTTHREAD__' . htmlspecialchars( $latestReplyJSON, ENT_NOQUOTES ) . '__'
195        );
196
197        $commentCount = $doc->createComment(
198            '__DTCOMMENTCOUNT__' . $headingItem->getCommentCount() . '__'
199        );
200
201        $authorCount = $doc->createComment(
202            '__DTAUTHORCOUNT__' . count( $headingItem->getAuthorsBelow() ) . '__'
203        );
204
205        $metadata = $doc->createElement( 'div' );
206        $metadata->setAttribute(
207            'class',
208            'ext-discussiontools-init-section-metadata'
209        );
210        $metadata->appendChild( $latestReply );
211        $metadata->appendChild( $commentCount );
212        $metadata->appendChild( $authorCount );
213        $bar->appendChild( $metadata );
214
215        $tocInfo[ $headingItem->getLinkableTitle() ] = [
216            'commentCount' => $headingItem->getCommentCount(),
217        ];
218    }
219
220    /**
221     * Add a subscribe/unsubscribe link to the right of a heading element
222     */
223    protected static function addSubscribeLink(
224        ContentHeadingItem $headingItem,
225        Document $doc,
226        Element $wrapperNode,
227        ?ContentCommentItem $latestReplyItem,
228        ?Element $bar
229    ) {
230        $headingJSONEscaped = htmlspecialchars(
231            json_encode( static::getJsonForHeadingMarker( $headingItem ) )
232        );
233
234        // Replaced in ::postprocessTopicSubscription() as the text depends on user state
235        if ( $headingItem->isSubscribable() ) {
236            $subscribeButton = $doc->createComment( '__DTSUBSCRIBEBUTTONDESKTOP__' . $headingJSONEscaped );
237            $wrapperNode->insertBefore( $subscribeButton, $wrapperNode->firstChild );
238        }
239
240        if ( !$latestReplyItem ) {
241            return;
242        }
243
244        $actions = $doc->createElement( 'div' );
245        $actions->setAttribute(
246            'class',
247            'ext-discussiontools-init-section-actions'
248        );
249        if ( $headingItem->isSubscribable() ) {
250            $subscribeButton = $doc->createComment( '__DTSUBSCRIBEBUTTONMOBILE__' . $headingJSONEscaped );
251            $actions->appendChild( $subscribeButton );
252        }
253        $bar->appendChild( $actions );
254    }
255
256    /**
257     * Add discussion tools to some HTML
258     *
259     * @param string $html HTML
260     * @param ParserOutput $pout
261     * @param Title $title
262     * @return string HTML with discussion tools
263     */
264    protected static function addDiscussionToolsInternal( string $html, ParserOutput $pout, Title $title ): string {
265        // The output of this method can end up in the HTTP cache (Varnish). Avoid changing it;
266        // and when doing so, ensure that frontend code can handle both the old and new outputs.
267        // See controller#init in JS.
268
269        $doc = DOMUtils::parseHTML( $html );
270        $container = DOMCompat::getBody( $doc );
271
272        $threadItemSet = static::getParser()->parse( $container, $title->getTitleValue() );
273        $threadItems = $threadItemSet->getThreadItems();
274
275        $tocInfo = [];
276
277        $newestComment = null;
278        $newestCommentData = null;
279
280        $url = $title->getCanonicalURL();
281        $dtConfig = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'discussiontools' );
282        $enablePermalinksFrontend = $dtConfig->get( 'DiscussionToolsEnablePermalinksFrontend' );
283
284        // Iterate in reverse order, because adding the range markers for a thread item
285        // can invalidate the ranges of subsequent thread items (T298096)
286        foreach ( array_reverse( $threadItems ) as $threadItem ) {
287            // Create a dummy node to attach data to.
288            if ( $threadItem instanceof ContentHeadingItem && $threadItem->isPlaceholderHeading() ) {
289                $node = $doc->createElement( 'span' );
290                $container->insertBefore( $node, $container->firstChild );
291                $threadItem->setRange( new ImmutableRange( $node, 0, $node, 0 ) );
292            }
293
294            // Add start and end markers to range
295            $id = $threadItem->getId();
296            $range = $threadItem->getRange();
297            $startMarker = $doc->createElement( 'span' );
298            $startMarker->setAttribute( 'data-mw-comment-start', '' );
299            $startMarker->setAttribute( 'id', $id );
300            $endMarker = $doc->createElement( 'span' );
301            $endMarker->setAttribute( 'data-mw-comment-end', $id );
302
303            // Extend the range if the start or end is inside an element which can't have element children.
304            // (There may be other problematic elements... but this seems like a good start.)
305            while ( CommentUtils::cantHaveElementChildren( $range->startContainer ) ) {
306                $range = $range->setStart(
307                    $range->startContainer->parentNode,
308                    CommentUtils::childIndexOf( $range->startContainer )
309                );
310            }
311            while ( CommentUtils::cantHaveElementChildren( $range->endContainer ) ) {
312                $range = $range->setEnd(
313                    $range->endContainer->parentNode,
314                    CommentUtils::childIndexOf( $range->endContainer ) + 1
315                );
316            }
317
318            $range->setStart( $range->endContainer, $range->endOffset )->insertNode( $endMarker );
319            // Start marker is added after reply link to keep reverse DOM order
320
321            if ( $threadItem instanceof ContentHeadingItem ) {
322                // <span class="mw-headline" â€¦>, or <hN â€¦> in Parsoid HTML
323                $headline = $threadItem->getRange()->endContainer;
324                Assert::precondition( $headline instanceof Element, 'HeadingItem refers to an element node' );
325                $headline->setAttribute( 'data-mw-thread-id', $threadItem->getId() );
326                if ( $threadItem->getHeadingLevel() === 2 ) {
327                    $headingElement = CommentUtils::closestElement( $headline, [ 'h2' ] );
328
329                    if ( $headingElement ) {
330                        static::handleHeading( $headingElement, $threadItem, $tocInfo );
331                    }
332                }
333            } elseif ( $threadItem instanceof ContentCommentItem ) {
334                $replyButtons = $doc->createElement( 'span' );
335                $replyButtons->setAttribute( 'class', 'ext-discussiontools-init-replylink-buttons' );
336                $replyButtons->setAttribute( 'data-mw-thread-id', $threadItem->getId() );
337                $replyButtons->appendChild( $doc->createComment( '__DTREPLYBUTTONSCONTENT__' ) );
338
339                if ( !$newestComment || $threadItem->getTimestamp() > $newestComment->getTimestamp() ) {
340                    $newestComment = $threadItem;
341                    // Needs to calculated before DOM modifications change ranges
342                    $newestCommentData = static::getJsonArrayForCommentMarker( $threadItem, true );
343                }
344
345                CommentModifier::addReplyLink( $threadItem, $replyButtons );
346
347                if ( $enablePermalinksFrontend ) {
348                    $timestampRanges = $threadItem->getTimestampRanges();
349                    $lastTimestamp = end( $timestampRanges );
350                    $existingLink = CommentUtils::closestElement( $lastTimestamp->startContainer, [ 'a' ] ) ??
351                        CommentUtils::closestElement( $lastTimestamp->endContainer, [ 'a' ] );
352
353                    if ( !$existingLink ) {
354                        $link = $doc->createElement( 'a' );
355                        $link->setAttribute( 'href', $url . '#' . Sanitizer::escapeIdForLink( $threadItem->getId() ) );
356                        $link->setAttribute( 'class', 'ext-discussiontools-init-timestamplink' );
357                        $lastTimestamp->surroundContents( $link );
358                    }
359                }
360                self::addOverflowMenuButton( $threadItem, $doc, $replyButtons );
361            }
362
363            $range->insertNode( $startMarker );
364        }
365
366        $pout->setExtensionData( 'DiscussionTools-tocInfo', $tocInfo );
367
368        if ( $newestCommentData ) {
369            $pout->setExtensionData( 'DiscussionTools-newestComment', $newestCommentData );
370        }
371
372        $startOfSections = DOMCompat::querySelector( $container, 'meta[property="mw:PageProp/toc"]' );
373
374        // Enhance other <h2>'s which aren't part of a thread
375        $headings = DOMCompat::querySelectorAll( $container, 'h2' );
376        foreach ( $headings as $headingElement ) {
377            $wrapper = $headingElement->parentNode;
378            if ( $wrapper instanceof Element && DOMCompat::getClassList( $wrapper )->contains( 'toctitle' ) ) {
379                continue;
380            }
381            $headingElement = static::handleHeading( $headingElement );
382            if ( !$startOfSections ) {
383                $startOfSections = $headingElement;
384            }
385        }
386
387        if (
388            // Page has no headings but some content
389            ( !$startOfSections && $container->childNodes->length ) ||
390            // Page has content before the first heading / TOC
391            ( $startOfSections && $startOfSections->previousSibling !== null )
392        ) {
393            $pout->setExtensionData( 'DiscussionTools-hasLedeContent', true );
394        }
395        if (
396            // Placeholder heading indicates that there are comments in the lede section (T324139).
397            // We can't really separate them from the lede content.
398            isset( $threadItems[0] ) &&
399            $threadItems[0] instanceof ContentHeadingItem &&
400            $threadItems[0]->isPlaceholderHeading()
401        ) {
402            $pout->setExtensionData( 'DiscussionTools-hasCommentsInLedeContent', true );
403            MediaWikiServices::getInstance()->getTrackingCategories()
404                // The following messages are generated upstream:
405                // * discussiontools-comments-before-first-heading-category-desc
406                ->addTrackingCategory( $pout, 'discussiontools-comments-before-first-heading-category', $title );
407        }
408
409        if ( count( $threadItems ) === 0 ) {
410            $pout->setExtensionData( 'DiscussionTools-isEmptyTalkPage', true );
411        }
412
413        $threadsJSON = array_map( static function ( ContentThreadItem $item ) {
414            return $item->jsonSerialize( true );
415        }, $threadItemSet->getThreadsStructured() );
416
417        // Temporary hack to deal with T351461#9358034: this should be a
418        // call to `setJsConfigVar` but Parsoid is currently reprocessing
419        // content from extensions.
420        $pout->addJsConfigVars( 'wgDiscussionToolsPageThreads', $threadsJSON );
421
422        // Like DOMCompat::getInnerHTML(), but disable 'smartQuote' for compatibility with
423        // ParserOutput::EDITSECTION_REGEX matching 'mw:editsection' tags (T274709)
424        $html = XMLSerializer::serialize( $container, [ 'innerXML' => true, 'smartQuote' => false ] )['html'];
425
426        return $html;
427    }
428
429    /**
430     * Add an overflow menu button to an element.
431     *
432     * @param ThreadItem $threadItem The heading or comment item
433     * @param Document $document Retrieved by parsing page HTML
434     * @param Element $element The element to add the overflow menu button to
435     * @return void
436     */
437    protected static function addOverflowMenuButton(
438        ThreadItem $threadItem, Document $document, Element $element
439    ): void {
440        $overflowMenuDataJSON = json_encode( [ 'threadItem' => $threadItem ] );
441
442        $overflowMenuButton = $document->createComment(
443            '__DTELLIPSISBUTTON__' . htmlspecialchars( $overflowMenuDataJSON, ENT_NOQUOTES )
444        );
445        $element->appendChild( $overflowMenuButton );
446    }
447
448    /**
449     * Replace placeholders for all interactive tools with nothing. This is intended for cases where
450     * interaction is unexpected, e.g. reply links while previewing an edit.
451     */
452    public static function removeInteractiveTools( string $text ): string {
453        $text = strtr( $text, [
454            '<!--__DTREPLYBUTTONSCONTENT__-->' => '',
455        ] );
456
457        $text = preg_replace( '/<!--__DTELLIPSISBUTTON__(.*?)-->/', '', $text );
458        $text = preg_replace( '/<!--__DTSUBSCRIBEBUTTON(DESKTOP|MOBILE)__(.*?)-->/', '', $text );
459
460        return $text;
461    }
462
463    /**
464     * Replace placeholders for topic subscription buttons with the real thing.
465     */
466    public static function postprocessTopicSubscription(
467        string $text, IContextSource $contextSource,
468        SubscriptionStore $subscriptionStore, bool $isMobile, bool $useButtons
469    ): string {
470        $doc = DOMCompat::newDocument( true );
471
472        $matches = [];
473        $itemDataByName = [];
474        preg_match_all( '/<!--__DTSUBSCRIBEBUTTONDESKTOP__(.*?)-->/', $text, $matches );
475        foreach ( $matches[1] as $itemData ) {
476            $itemDataByName[ $itemData ] = json_decode( htmlspecialchars_decode( $itemData ), true );
477        }
478        $itemNames = array_column( $itemDataByName, 'name' );
479
480        $user = $contextSource->getUser();
481        $items = $subscriptionStore->getSubscriptionItemsForUser(
482            $user,
483            $itemNames
484        );
485        $itemsByName = [];
486        foreach ( $items as $item ) {
487            $itemsByName[ $item->getItemName() ] = $item;
488        }
489
490        $lang = $contextSource->getLanguage();
491        $title = $contextSource->getTitle();
492        $text = preg_replace_callback(
493            '/<!--__(DTSUBSCRIBEBUTTON(?:DESKTOP|MOBILE))__(.*?)-->/',
494            static function ( $matches ) use (
495                $doc, $itemsByName, $itemDataByName, $lang, $title, $isMobile, $useButtons
496            ) {
497                $buttonIsMobile = $matches[1] === 'DTSUBSCRIBEBUTTONMOBILE';
498
499                $itemData = $itemDataByName[ $matches[2] ];
500                '@phan-var array $itemData';
501                $itemName = $itemData['name'];
502
503                $isSubscribed = isset( $itemsByName[ $itemName ] ) && !$itemsByName[ $itemName ]->isMuted();
504                $subscribedState = isset( $itemsByName[ $itemName ] ) ? $itemsByName[ $itemName ]->getState() : null;
505
506                $href = $title->getLinkURL( [
507                    'action' => $isSubscribed ? 'dtunsubscribe' : 'dtsubscribe',
508                    'commentname' => $itemName,
509                    'section' => $itemData['linkableTitle'],
510                ] );
511
512                if ( $buttonIsMobile !== $isMobile ) {
513                    return '';
514                }
515
516                if ( !$useButtons ) {
517                    $subscribe = $doc->createElement( 'span' );
518                    $subscribe->setAttribute(
519                        'class',
520                        'ext-discussiontools-init-section-subscribe mw-editsection-like'
521                    );
522
523                    $subscribeLink = $doc->createElement( 'a' );
524                    $subscribeLink->setAttribute( 'href', $href );
525                    $subscribeLink->setAttribute( 'class', 'ext-discussiontools-init-section-subscribe-link' );
526                    $subscribeLink->setAttribute( 'role', 'button' );
527                    $subscribeLink->setAttribute( 'tabindex', '0' );
528                    $subscribeLink->setAttribute( 'title', wfMessage(
529                        $isSubscribed ?
530                            'discussiontools-topicsubscription-button-unsubscribe-tooltip' :
531                            'discussiontools-topicsubscription-button-subscribe-tooltip'
532                    )->inLanguage( $lang )->text() );
533                    $subscribeLink->nodeValue = wfMessage(
534                        $isSubscribed ?
535                            'discussiontools-topicsubscription-button-unsubscribe' :
536                            'discussiontools-topicsubscription-button-subscribe'
537                    )->inLanguage( $lang )->text();
538
539                    if ( $subscribedState !== null ) {
540                        $subscribeLink->setAttribute( 'data-mw-subscribed', (string)$subscribedState );
541                    }
542
543                    $bracket = $doc->createElement( 'span' );
544                    $bracket->setAttribute( 'class', 'ext-discussiontools-init-section-subscribe-bracket' );
545                    $bracketOpen = $bracket->cloneNode( false );
546                    $bracketOpen->nodeValue = '[';
547                    $bracketClose = $bracket->cloneNode( false );
548                    $bracketClose->nodeValue = ']';
549
550                    $subscribe->appendChild( $bracketOpen );
551                    $subscribe->appendChild( $subscribeLink );
552                    $subscribe->appendChild( $bracketClose );
553
554                    return DOMCompat::getOuterHTML( $subscribe );
555                } else {
556                    $subscribe = new \OOUI\ButtonWidget( [
557                        'classes' => [ 'ext-discussiontools-init-section-subscribeButton' ],
558                        'framed' => false,
559                        'icon' => $isSubscribed ? 'bell' : 'bellOutline',
560                        'flags' => [ 'progressive' ],
561                        'href' => $href,
562                        'label' => wfMessage( $isSubscribed ?
563                            'discussiontools-topicsubscription-button-unsubscribe-label' :
564                            'discussiontools-topicsubscription-button-subscribe-label'
565                        )->inLanguage( $lang )->text(),
566                        'title' => wfMessage( $isSubscribed ?
567                            'discussiontools-topicsubscription-button-unsubscribe-tooltip' :
568                            'discussiontools-topicsubscription-button-subscribe-tooltip'
569                        )->inLanguage( $lang )->text(),
570                        'infusable' => true,
571                    ] );
572
573                    if ( $subscribedState !== null ) {
574                        $subscribe->setAttributes( [ 'data-mw-subscribed' => (string)$subscribedState ] );
575                    }
576
577                    return $subscribe->toString();
578                }
579            },
580            $text
581        );
582
583        return $text;
584    }
585
586    /**
587     * Replace placeholders for reply links with the real thing.
588     */
589    public static function postprocessReplyTool(
590        string $text, IContextSource $contextSource, bool $isMobile, bool $useButtons
591    ): string {
592        $doc = DOMCompat::newDocument( true );
593
594        $lang = $contextSource->getLanguage();
595        $replyLinkText = wfMessage( 'discussiontools-replylink' )->inLanguage( $lang )->escaped();
596        $replyButtonText = wfMessage( 'discussiontools-replybutton' )->inLanguage( $lang )->escaped();
597
598        $text = preg_replace_callback(
599            '/<!--__DTREPLYBUTTONSCONTENT__-->/',
600            static function ( $matches ) use ( $doc, $replyLinkText, $replyButtonText, $isMobile, $useButtons, $lang ) {
601                $replyLinkButtons = $doc->createElement( 'span' );
602
603                if ( $useButtons ) {
604                    // Visual enhancements button
605                    $useIcon = $isMobile || static::isLanguageRequiringReplyIcon( $lang );
606                    $replyLinkButton = new \OOUI\ButtonWidget( [
607                        'classes' => [ 'ext-discussiontools-init-replybutton' ],
608                        'framed' => false,
609                        'label' => $replyButtonText,
610                        'icon' => $useIcon ? 'share' : null,
611                        'flags' => [ 'progressive' ],
612                        'infusable' => true,
613                    ] );
614
615                    DOMCompat::setInnerHTML( $replyLinkButtons, $replyLinkButton->toString() );
616                } else {
617                    // Reply link
618                    $replyLink = $doc->createElement( 'a' );
619                    $replyLink->setAttribute( 'class', 'ext-discussiontools-init-replylink-reply' );
620                    $replyLink->setAttribute( 'role', 'button' );
621                    $replyLink->setAttribute( 'tabindex', '0' );
622                    // Set empty 'href' to avoid a:not([href]) selector in MobileFrontend
623                    $replyLink->setAttribute( 'href', '' );
624                    $replyLink->textContent = $replyLinkText;
625
626                    $bracket = $doc->createElement( 'span' );
627                    $bracket->setAttribute( 'class', 'ext-discussiontools-init-replylink-bracket' );
628                    $bracketOpen = $bracket->cloneNode( false );
629                    $bracketClose = $bracket->cloneNode( false );
630                    $bracketOpen->textContent = '[';
631                    $bracketClose->textContent = ']';
632
633                    $replyLinkButtons->appendChild( $bracketOpen );
634                    $replyLinkButtons->appendChild( $replyLink );
635                    $replyLinkButtons->appendChild( $bracketClose );
636                }
637
638                return DOMCompat::getInnerHTML( $replyLinkButtons );
639            },
640            $text
641        );
642
643        return $text;
644    }
645
646    /**
647     * Create a meta item label
648     *
649     * @param string $className
650     * @param string|\OOUI\HtmlSnippet $label Label
651     * @return \OOUI\Tag
652     */
653    private static function metaLabel( string $className, $label ): \OOUI\Tag {
654        return ( new \OOUI\Tag( 'span' ) )
655            ->addClasses( [ 'ext-discussiontools-init-section-metaitem', $className ] )
656            ->appendContent( $label );
657    }
658
659    /**
660     * Get JSON data for a commentItem that can be inserted into a comment marker
661     *
662     * @param ContentCommentItem $commentItem Comment item
663     * @param bool $includeTopicAndAuthor Include metadata about topic and author
664     * @return array
665     */
666    private static function getJsonArrayForCommentMarker(
667        ContentCommentItem $commentItem,
668        bool $includeTopicAndAuthor = false
669    ): array {
670        $JSON = [
671            'id' => $commentItem->getId(),
672            'timestamp' => $commentItem->getTimestampString()
673        ];
674        if ( $includeTopicAndAuthor ) {
675            $JSON['author'] = $commentItem->getAuthor();
676            $heading = $commentItem->getSubscribableHeading();
677            if ( $heading ) {
678                $JSON['heading'] = static::getJsonForHeadingMarker( $heading );
679            }
680        }
681        return $JSON;
682    }
683
684    private static function getJsonForHeadingMarker( ContentHeadingItem $heading ): array {
685        $JSON = $heading->jsonSerialize();
686        $JSON['text'] = $heading->getText();
687        $JSON['linkableTitle'] = $heading->getLinkableTitle();
688        return $JSON;
689    }
690
691    /**
692     * Get a relative timestamp from a signature timestamp.
693     *
694     * Signature timestamps don't have seconds-level accuracy, so any
695     * time difference of less than 120 seconds is treated as being
696     * posted "just now".
697     */
698    public static function getSignatureRelativeTime(
699        MWTimestamp $timestamp, Language $lang, UserIdentity $user
700    ): string {
701        try {
702            $diff = time() - intval( $timestamp->getTimestamp() );
703        } catch ( TimestampException $ex ) {
704            // Can't happen
705            $diff = 0;
706        }
707        if ( $diff < 120 ) {
708            $timestamp = new MWTimestamp();
709        }
710        return $lang->getHumanTimestamp( $timestamp, null, $user );
711    }
712
713    /**
714     * Post-process visual enhancements features (topic containers)
715     */
716    public static function postprocessVisualEnhancements(
717        string $text, IContextSource $contextSource, bool $isMobile
718    ): string {
719        $lang = $contextSource->getLanguage();
720        $user = $contextSource->getUser();
721        $text = preg_replace_callback(
722            '/<!--__DTLATESTCOMMENTTHREAD__(.*?)__-->/',
723            static function ( $matches ) use ( $lang, $user ) {
724                $itemData = json_decode( htmlspecialchars_decode( $matches[1] ), true );
725                if ( $itemData && $itemData['timestamp'] && $itemData['id'] ) {
726                    $relativeTime = static::getSignatureRelativeTime(
727                        new MWTimestamp( $itemData['timestamp'] ),
728                        $lang,
729                        $user
730                    );
731                    $commentLink = Html::element( 'a', [
732                        'href' => '#' . Sanitizer::escapeIdForLink( $itemData['id'] )
733                    ], $relativeTime );
734
735                    $label = wfMessage( 'discussiontools-topicheader-latestcomment' )
736                        ->rawParams( $commentLink )
737                        ->inLanguage( $lang )->escaped();
738
739                    return CommentFormatter::metaLabel(
740                        'ext-discussiontools-init-section-timestampLabel',
741                        new \OOUI\HtmlSnippet( $label )
742                    );
743                }
744            },
745            $text
746        );
747        $text = preg_replace_callback(
748            '/<!--__DTCOMMENTCOUNT__([0-9]+)__-->/',
749            static function ( $matches ) use ( $lang, $user ) {
750                $count = $lang->formatNum( $matches[1] );
751                $label = wfMessage(
752                    'discussiontools-topicheader-commentcount',
753                    $count
754                )->inLanguage( $lang )->text();
755                return CommentFormatter::metaLabel(
756                    'ext-discussiontools-init-section-commentCountLabel',
757                    $label
758                );
759            },
760            $text
761        );
762        $text = preg_replace_callback(
763            '/<!--__DTAUTHORCOUNT__([0-9]+)__-->/',
764            static function ( $matches ) use ( $lang, $user ) {
765                $count = $lang->formatNum( $matches[1] );
766                $label = wfMessage(
767                    'discussiontools-topicheader-authorcount',
768                    $count
769                )->inLanguage( $lang )->text();
770                return CommentFormatter::metaLabel(
771                    'ext-discussiontools-init-section-authorCountLabel',
772                    $label
773                );
774            },
775            $text
776        );
777        $text = preg_replace_callback(
778            '/<!--__DTELLIPSISBUTTON__(.*?)-->/',
779            static function ( $matches ) use ( $contextSource, $isMobile ) {
780                $overflowMenuData = json_decode( htmlspecialchars_decode( $matches[1] ), true ) ?? [];
781
782                // TODO: Remove the fallback to empty array after the parser cache is updated.
783                $threadItem = $overflowMenuData['threadItem'] ?? [];
784                // TODO: Remove $overflowMenuData['editable'] after caches clear
785                if ( isset( $overflowMenuData['editable'] ) ) {
786                    $threadItem['uneditableSection'] = !$overflowMenuData['editable'];
787                }
788                $threadItemType = $threadItem['type'] ?? null;
789                if ( !$isMobile && $threadItemType === 'heading' ) {
790                    // Displaying the overflow menu next to a topic heading is a bit more
791                    // complicated on desktop, so leaving it out for now.
792                    return '';
793                }
794                $overflowMenuItems = [];
795                $resourceLoaderModules = [];
796
797                self::getHookRunner()->onDiscussionToolsAddOverflowMenuItems(
798                    $overflowMenuItems,
799                    $resourceLoaderModules,
800                    $threadItem,
801                    $contextSource
802                );
803
804                if ( $overflowMenuItems ) {
805                    usort(
806                        $overflowMenuItems,
807                        static function ( OverflowMenuItem $itemA, OverflowMenuItem $itemB ): int {
808                            return $itemB->getWeight() - $itemA->getWeight();
809                        }
810                    );
811
812                    $overflowButton = new ButtonMenuSelectWidget( [
813                        'classes' => [
814                            'ext-discussiontools-init-section-overflowMenuButton'
815                        ],
816                        'framed' => false,
817                        'icon' => 'ellipsis',
818                        'infusable' => true,
819                        'data' => [
820                            'itemConfigs' => $overflowMenuItems,
821                            'resourceLoaderModules' => $resourceLoaderModules
822                        ]
823                    ] );
824                    return $overflowButton->toString();
825                } else {
826                    return '';
827                }
828            },
829            $text
830        );
831
832        return $text;
833    }
834
835    /**
836     * Post-process visual enhancements features for page subtitle
837     */
838    public static function postprocessVisualEnhancementsSubtitle(
839        ParserOutput $pout, IContextSource $contextSource
840    ): ?string {
841        $itemData = $pout->getExtensionData( 'DiscussionTools-newestComment' );
842        if ( $itemData && $itemData['timestamp'] && $itemData['id'] ) {
843            $lang = $contextSource->getLanguage();
844            $user = $contextSource->getUser();
845            $relativeTime = static::getSignatureRelativeTime(
846                new MWTimestamp( $itemData['timestamp'] ),
847                $lang,
848                $user
849            );
850            $commentLink = Html::element( 'a', [
851                'href' => '#' . Sanitizer::escapeIdForLink( $itemData['id'] )
852            ], $relativeTime );
853
854            if ( isset( $itemData['heading'] ) ) {
855                $headingLink = Html::element( 'a', [
856                    'href' => '#' . Sanitizer::escapeIdForLink( $itemData['heading']['linkableTitle'] )
857                ], $itemData['heading']['text'] );
858                $label = wfMessage( 'discussiontools-pageframe-latestcomment' )
859                    ->rawParams( $commentLink )
860                    ->params( $itemData['author'] )
861                    ->rawParams( $headingLink )
862                    ->inLanguage( $lang )->escaped();
863            } else {
864                $label = wfMessage( 'discussiontools-pageframe-latestcomment-notopic' )
865                    ->rawParams( $commentLink )
866                    ->params( $itemData['author'] )
867                    ->inLanguage( $lang )->escaped();
868            }
869
870            return Html::rawElement(
871                'div',
872                [ 'class' => 'ext-discussiontools-init-pageframe-latestcomment' ],
873                $label
874            );
875        }
876        return null;
877    }
878
879    /**
880     * Post-process visual enhancements features for table of contents
881     */
882    public static function postprocessTableOfContents(
883        ParserOutput $pout, IContextSource $contextSource
884    ): void {
885        $tocInfo = $pout->getExtensionData( 'DiscussionTools-tocInfo' );
886
887        if ( $tocInfo && $pout->getTOCData() ) {
888            $sections = $pout->getTOCData()->getSections();
889            foreach ( $sections as $item ) {
890                $key = str_replace( '_', ' ', $item->anchor );
891                // Unset if we did not format this section as a topic container
892                if ( isset( $tocInfo[$key] ) ) {
893                    $lang = $contextSource->getLanguage();
894                    $count = $lang->formatNum( $tocInfo[$key]['commentCount'] );
895                    $commentCount = wfMessage(
896                        'discussiontools-topicheader-commentcount',
897                        $count
898                    )->inLanguage( $lang )->text();
899
900                    $summary = Html::element( 'span', [
901                        'class' => 'ext-discussiontools-init-sidebar-meta'
902                    ], $commentCount );
903                } else {
904                    $summary = '';
905                }
906
907                // This also shows up in API action=parse&prop=sections output.
908                $item->setExtensionData( 'DiscussionTools-html-summary', $summary );
909            }
910        }
911    }
912
913    /**
914     * Check if the talk page had no comments or headings.
915     */
916    public static function isEmptyTalkPage( ParserOutput $pout ): bool {
917        return $pout->getExtensionData( 'DiscussionTools-isEmptyTalkPage' ) === true;
918    }
919
920    /**
921     * Append content to an empty talk page
922     */
923    public static function appendToEmptyTalkPage( ParserOutput $pout, string $content ): void {
924        $text = $pout->getRawText();
925        $text .= $content;
926        $pout->setText( $text );
927    }
928
929    /**
930     * Check if the talk page has content above the first heading, in the lede section.
931     */
932    public static function hasLedeContent( ParserOutput $pout ): bool {
933        return $pout->getExtensionData( 'DiscussionTools-hasLedeContent' ) === true;
934    }
935
936    /**
937     * Check if the talk page has comments above the first heading, in the lede section.
938     */
939    public static function hasCommentsInLedeContent( ParserOutput $pout ): bool {
940        return $pout->getExtensionData( 'DiscussionTools-hasCommentsInLedeContent' ) === true;
941    }
942
943    /**
944     * Check if the language requires an icon for the reply button
945     *
946     * @param Language $userLang Language
947     * @return bool
948     */
949    public static function isLanguageRequiringReplyIcon( Language $userLang ): bool {
950        $services = MediaWikiServices::getInstance();
951
952        $dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' );
953        $languages = $dtConfig->get( 'DiscussionTools_visualenhancements_reply_icon_languages' );
954
955        if ( array_is_list( $languages ) ) {
956            // Detect legacy list format
957            throw new ConfigException(
958                'DiscussionTools_visualenhancements_reply_icon_languages must be an associative array'
959            );
960        }
961
962        // User language matched exactly and is explicitly set to true or false
963        if ( isset( $languages[ $userLang->getCode() ] ) ) {
964            return (bool)$languages[ $userLang->getCode() ];
965        }
966
967        // Check fallback languages
968        $fallbackLanguages = $userLang->getFallbackLanguages();
969        foreach ( $fallbackLanguages as $fallbackLanguage ) {
970            if ( isset( $languages[ $fallbackLanguage ] ) ) {
971                return (bool)$languages[ $fallbackLanguage ];
972            }
973        }
974
975        // Language not listed, default is to show no icon
976        return false;
977    }
978
979}