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