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