Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.98% covered (warning)
82.98%
429 / 517
40.91% covered (danger)
40.91%
9 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentFormatter
82.98% covered (warning)
82.98%
429 / 517
40.91% covered (danger)
40.91%
9 / 22
155.32
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
65.00% covered (warning)
65.00%
13 / 20
0.00% covered (danger)
0.00%
0 / 1
3.39
 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.57% covered (success)
94.57%
87 / 92
0.00% covered (danger)
0.00%
0 / 1
27.12
 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
 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 Language;
6use MediaWiki\Config\ConfigException;
7use MediaWiki\Context\IContextSource;
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\ParserOutput;
17use MediaWiki\Parser\Sanitizer;
18use MediaWiki\Request\WebRequest;
19use MediaWiki\Title\Title;
20use MediaWiki\User\UserIdentity;
21use MediaWiki\Utils\MWTimestamp;
22use MWExceptionHandler;
23use Throwable;
24use Wikimedia\Parsoid\DOM\Document;
25use Wikimedia\Parsoid\DOM\Element;
26use Wikimedia\Parsoid\Utils\DOMCompat;
27use Wikimedia\Parsoid\Utils\DOMUtils;
28use Wikimedia\Parsoid\Wt2Html\XMLSerializer;
29use Wikimedia\Timestamp\TimestampException;
30
31class CommentFormatter {
32    // List of features which, when enabled, cause the comment formatter to run
33    public const USE_WITH_FEATURES = [
34        HookUtils::REPLYTOOL,
35        HookUtils::TOPICSUBSCRIPTION,
36        HookUtils::VISUALENHANCEMENTS
37    ];
38
39    /**
40     * Get a comment parser object for a DOM element
41     *
42     * This method exists so it can mocked in tests.
43     */
44    protected static function getParser(): CommentParser {
45        return MediaWikiServices::getInstance()->getService( 'DiscussionTools.CommentParser' );
46    }
47
48    protected static function getHookRunner(): HookRunner {
49        return new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
50    }
51
52    /**
53     * Add discussion tools to some HTML
54     *
55     * @param string &$text Parser text output (modified by reference)
56     * @param ParserOutput $pout ParserOutput object for metadata, e.g. parser limit report
57     * @param Title $title
58     */
59    public static function addDiscussionTools( string &$text, ParserOutput $pout, Title $title ): void {
60        $start = microtime( true );
61        $requestId = null;
62
63        try {
64            $text = static::addDiscussionToolsInternal( $text, $pout, $title );
65
66        } catch ( Throwable $e ) {
67            // Catch errors, so that they don't cause the entire page to not display.
68            // Log it and report the request ID to make it easier to find in the logs.
69            MWExceptionHandler::logException( $e );
70            $requestId = WebRequest::getRequestId();
71        }
72
73        $duration = microtime( true ) - $start;
74
75        MediaWikiServices::getInstance()->getStatsFactory()
76            ->getTiming( 'discussiontools_addreplylinks_seconds' )
77            ->copyToStatsdAt( 'discussiontools.addReplyLinks' )
78            ->observe( $duration * 1000 );
79
80        // How long this method took, in seconds
81        $pout->setLimitReportData(
82            // The following messages can be generated upstream
83            // * discussiontools-limitreport-timeusage-value
84            // * discussiontools-limitreport-timeusage-value-text
85            // * discussiontools-limitreport-timeusage-value-html
86            'discussiontools-limitreport-timeusage',
87            sprintf( '%.3f', $duration )
88        );
89        if ( $requestId ) {
90            // Request ID where errors were logged (only if an error occurred)
91            $pout->setLimitReportData(
92                'discussiontools-limitreport-errorreqid',
93                $requestId
94            );
95        }
96    }
97
98    /**
99     * Add a wrapper, topic container, and subscribe link around a heading element
100     *
101     * @param Element $headingElement Heading element
102     * @param ContentHeadingItem|null $headingItem Heading item
103     * @param array|null &$tocInfo TOC info
104     * @return Element Wrapper element (either found or newly added)
105     */
106    protected static function handleHeading(
107        Element $headingElement,
108        ?ContentHeadingItem $headingItem = null,
109        ?array &$tocInfo = null
110    ): Element {
111        $doc = $headingElement->ownerDocument;
112        $wrapperNode = $headingElement->parentNode;
113        if ( !(
114            $wrapperNode instanceof Element &&
115            DOMCompat::getClassList( $wrapperNode )->contains( 'mw-heading' )
116        ) ) {
117            // Do not add the wrapper if the heading has attributes generated from wikitext (T353489).
118            // Only allow reserved attributes (e.g. 'data-mw', which can't be used in wikitext, but which
119            // are used internally by our own code and by Parsoid) and the 'id' attribute used by Parsoid.
120            foreach ( $headingElement->attributes as $attr ) {
121                if ( $attr->name !== 'id' && !Sanitizer::isReservedDataAttribute( $attr->name ) ) {
122                    return $headingElement;
123                }
124            }
125
126            $wrapperNode = $doc->createElement( 'div' );
127            $headingElement->parentNode->insertBefore( $wrapperNode, $headingElement );
128            $wrapperNode->appendChild( $headingElement );
129        }
130
131        if ( !$headingItem ) {
132            return $wrapperNode;
133        }
134
135        $uneditable = DOMCompat::querySelector( $wrapperNode, 'mw\\:editsection' ) === null;
136        $headingItem->setUneditableSection( $uneditable );
137        self::addOverflowMenuButton( $headingItem, $doc, $wrapperNode );
138
139        $latestReplyItem = $headingItem->getLatestReply();
140
141        $bar = null;
142        if ( $latestReplyItem ) {
143            $bar = $doc->createElement( 'div' );
144            $bar->setAttribute(
145                'class',
146                'ext-discussiontools-init-section-bar'
147            );
148        }
149
150        self::addTopicContainer(
151            $wrapperNode, $latestReplyItem, $doc, $headingItem, $bar, $tocInfo
152        );
153
154        self::addSubscribeLink(
155            $headingItem, $doc, $wrapperNode, $latestReplyItem, $bar
156        );
157
158        if ( $latestReplyItem ) {
159            // The check for if ( $latestReplyItem ) prevents $bar from being null
160            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
161            $wrapperNode->appendChild( $bar );
162        }
163
164        return $wrapperNode;
165    }
166
167    /**
168     * Add a topic container around a heading element.
169     *
170     * A topic container is the information displayed when the "Show discusion activity" user
171     * preference is selected. This displays information such as the latest comment time, number
172     * of comments, and number of editors in the discussion.
173     */
174    protected static function addTopicContainer(
175        Element $wrapperNode,
176        ?ContentCommentItem $latestReplyItem,
177        Document $doc,
178        ContentHeadingItem $headingItem,
179        ?Element $bar,
180        array &$tocInfo
181    ) {
182        if ( !DOMCompat::getClassList( $wrapperNode )->contains( 'mw-heading' ) ) {
183            DOMCompat::getClassList( $wrapperNode )->add( 'mw-heading' );
184            DOMCompat::getClassList( $wrapperNode )->add( 'mw-heading2' );
185        }
186        DOMCompat::getClassList( $wrapperNode )->add( 'ext-discussiontools-init-section' );
187
188        if ( !$latestReplyItem ) {
189            return;
190        }
191
192        $latestReplyJSON = json_encode( static::getJsonArrayForCommentMarker( $latestReplyItem ) );
193        $latestReply = $doc->createComment(
194            // Timestamp output varies by user timezone, so is formatted later
195            '__DTLATESTCOMMENTTHREAD__' . htmlspecialchars( $latestReplyJSON, ENT_NOQUOTES ) . '__'
196        );
197
198        $commentCount = $doc->createComment(
199            '__DTCOMMENTCOUNT__' . $headingItem->getCommentCount() . '__'
200        );
201
202        $authorCount = $doc->createComment(
203            '__DTAUTHORCOUNT__' . count( $headingItem->getAuthorsBelow() ) . '__'
204        );
205
206        $metadata = $doc->createElement( 'div' );
207        $metadata->setAttribute(
208            'class',
209            'ext-discussiontools-init-section-metadata'
210        );
211        $metadata->appendChild( $latestReply );
212        $metadata->appendChild( $commentCount );
213        $metadata->appendChild( $authorCount );
214        $bar->appendChild( $metadata );
215
216        $tocInfo[ $headingItem->getLinkableTitle() ] = [
217            'commentCount' => $headingItem->getCommentCount(),
218        ];
219    }
220
221    /**
222     * Add a subscribe/unsubscribe link to the right of a heading element
223     */
224    protected static function addSubscribeLink(
225        ContentHeadingItem $headingItem,
226        Document $doc,
227        Element $wrapperNode,
228        ?ContentCommentItem $latestReplyItem,
229        ?Element $bar
230    ) {
231        $headingJSONEscaped = htmlspecialchars(
232            json_encode( static::getJsonForHeadingMarker( $headingItem ) )
233        );
234
235        // Replaced in ::postprocessTopicSubscription() as the text depends on user state
236        if ( $headingItem->isSubscribable() ) {
237            $subscribeButton = $doc->createComment( '__DTSUBSCRIBEBUTTONDESKTOP__' . $headingJSONEscaped );
238            $wrapperNode->insertBefore( $subscribeButton, $wrapperNode->firstChild );
239        }
240
241        if ( !$latestReplyItem ) {
242            return;
243        }
244
245        $actions = $doc->createElement( 'div' );
246        $actions->setAttribute(
247            'class',
248            'ext-discussiontools-init-section-actions'
249        );
250        if ( $headingItem->isSubscribable() ) {
251            $subscribeButton = $doc->createComment( '__DTSUBSCRIBEBUTTONMOBILE__' . $headingJSONEscaped );
252            $actions->appendChild( $subscribeButton );
253        }
254        $bar->appendChild( $actions );
255    }
256
257    /**
258     * Add discussion tools to some HTML
259     *
260     * @param string $html HTML
261     * @param ParserOutput $pout
262     * @param Title $title
263     * @return string HTML with discussion tools
264     */
265    protected static function addDiscussionToolsInternal( string $html, ParserOutput $pout, Title $title ): string {
266        // The output of this method can end up in the HTTP cache (Varnish). Avoid changing it;
267        // and when doing so, ensure that frontend code can handle both the old and new outputs.
268        // See controller#init in JS.
269
270        $doc = DOMUtils::parseHTML( $html );
271        $container = DOMCompat::getBody( $doc );
272
273        $threadItemSet = static::getParser()->parse( $container, $title->getTitleValue() );
274        $threadItems = $threadItemSet->getThreadItems();
275
276        $tocInfo = [];
277
278        $newestComment = null;
279        $newestCommentData = null;
280
281        $url = $title->getCanonicalURL();
282        $dtConfig = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'discussiontools' );
283        $enablePermalinksFrontend = $dtConfig->get( 'DiscussionToolsEnablePermalinksFrontend' );
284
285        // Iterate in reverse order, because adding the range markers for a thread item
286        // can invalidate the ranges of subsequent thread items (T298096)
287        foreach ( array_reverse( $threadItems ) as $threadItem ) {
288            // Create a dummy node to attach data to.
289            if ( $threadItem instanceof ContentHeadingItem && $threadItem->isPlaceholderHeading() ) {
290                $node = $doc->createElement( 'span' );
291                $container->insertBefore( $node, $container->firstChild );
292                $threadItem->setRange( new ImmutableRange( $node, 0, $node, 0 ) );
293            }
294
295            // Add start and end markers to range
296            $id = $threadItem->getId();
297            $range = $threadItem->getRange();
298            $startMarker = $doc->createElement( 'span' );
299            $startMarker->setAttribute( 'data-mw-comment-start', '' );
300            $startMarker->setAttribute( 'id', $id );
301            $endMarker = $doc->createElement( 'span' );
302            $endMarker->setAttribute( 'data-mw-comment-end', $id );
303
304            // Extend the range if the start or end is inside an element which can't have element children.
305            // (There may be other problematic elements... but this seems like a good start.)
306            while ( CommentUtils::cantHaveElementChildren( $range->startContainer ) ) {
307                $range = $range->setStart(
308                    $range->startContainer->parentNode,
309                    CommentUtils::childIndexOf( $range->startContainer )
310                );
311            }
312            while ( CommentUtils::cantHaveElementChildren( $range->endContainer ) ) {
313                $range = $range->setEnd(
314                    $range->endContainer->parentNode,
315                    CommentUtils::childIndexOf( $range->endContainer ) + 1
316                );
317            }
318
319            $range->setStart( $range->endContainer, $range->endOffset )->insertNode( $endMarker );
320            // Start marker is added after reply link to keep reverse DOM order
321
322            if ( $threadItem instanceof ContentHeadingItem ) {
323                $headline = $threadItem->getHeadlineNode();
324                $headline->setAttribute( 'data-mw-thread-id', $threadItem->getId() );
325                if ( $threadItem->getHeadingLevel() === 2 ) {
326                    // Hack for tests (T363031), $headline should already be a <h2>
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     * Check if the talk page has content above the first heading, in the lede section.
922     */
923    public static function hasLedeContent( ParserOutput $pout ): bool {
924        return $pout->getExtensionData( 'DiscussionTools-hasLedeContent' ) === true;
925    }
926
927    /**
928     * Check if the talk page has comments above the first heading, in the lede section.
929     */
930    public static function hasCommentsInLedeContent( ParserOutput $pout ): bool {
931        return $pout->getExtensionData( 'DiscussionTools-hasCommentsInLedeContent' ) === true;
932    }
933
934    /**
935     * Check if the language requires an icon for the reply button
936     *
937     * @param Language $userLang Language
938     * @return bool
939     */
940    public static function isLanguageRequiringReplyIcon( Language $userLang ): bool {
941        $services = MediaWikiServices::getInstance();
942
943        $dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' );
944        $languages = $dtConfig->get( 'DiscussionTools_visualenhancements_reply_icon_languages' );
945
946        if ( array_is_list( $languages ) ) {
947            // Detect legacy list format
948            throw new ConfigException(
949                'DiscussionTools_visualenhancements_reply_icon_languages must be an associative array'
950            );
951        }
952
953        // User language matched exactly and is explicitly set to true or false
954        if ( isset( $languages[ $userLang->getCode() ] ) ) {
955            return (bool)$languages[ $userLang->getCode() ];
956        }
957
958        // Check fallback languages
959        $fallbackLanguages = $userLang->getFallbackLanguages();
960        foreach ( $fallbackLanguages as $fallbackLanguage ) {
961            if ( isset( $languages[ $fallbackLanguage ] ) ) {
962                return (bool)$languages[ $fallbackLanguage ];
963            }
964        }
965
966        // Language not listed, default is to show no icon
967        return false;
968    }
969
970}