getService( 'DiscussionTools.CommentParser' ); } /** * Add discussion tools to some HTML * * @param string &$text Parser text output (modified by reference) * @param ParserOutput $pout ParserOutput object for metadata, e.g. parser limit report * @param Parser $parser */ public static function addDiscussionTools( string &$text, ParserOutput $pout, Parser $parser ): void { $title = $parser->getTitle(); $start = microtime( true ); $requestId = null; try { [ 'html' => $text, 'tocInfo' => $tocInfo ] = static::addDiscussionToolsInternal( $text, $title ); // Enhance the table of contents in supporting skins (vector-2022) // Only do the work if the HTML would be shown. It looks like we can only check this // by checking whether the HTML for the normal TOC has been generated. Code in // OutputPage::addParserOutputMetadata does the same. if ( $pout->getTOCHTML() ) { // If the TOC HTML has been generated, then the parser cache is already split by user // language (because of the "Contents" header in the TOC), so we can render text in user // language as well. If that behavior in core changes, then we'll have to change this to // happen in a post-processing step (like all other transformations) to avoid splitting it. $lang = $parser->getOptions()->getUserLangObj(); $sections = $pout->getSections(); foreach ( $sections as &$item ) { $key = str_replace( '_', ' ', $item['anchor'] ); // Unset if we did not format this section as a topic container if ( isset( $tocInfo[$key] ) ) { $count = $lang->formatNum( $tocInfo[$key]['commentCount'] ); $commentCount = wfMessage( 'discussiontools-topicheader-commentcount', $count )->inLanguage( $lang )->text(); $summary = Html::element( 'span', [ 'class' => 'ext-discussiontools-init-sidebar-meta' ], wfMessage( 'parentheses', $commentCount )->inLanguage( $lang )->text() ); // This also shows up in API action=parse&prop=sections output. $item['html-summary'] = $summary; } } $pout->setSections( $sections ); } } catch ( Throwable $e ) { // Catch errors, so that they don't cause the entire page to not display. // Log it and report the request ID to make it easier to find in the logs. MWExceptionHandler::logException( $e ); $requestId = WebRequest::getRequestId(); } $duration = microtime( true ) - $start; $stats = MediaWikiServices::getInstance()->getStatsdDataFactory(); $stats->timing( 'discussiontools.addReplyLinks', $duration * 1000 ); // How long this method took, in seconds $pout->setLimitReportData( 'discussiontools-limitreport-timeusage', sprintf( '%.3f', $duration ) ); if ( $requestId ) { // Request ID where errors were logged (only if an error occurred) $pout->setLimitReportData( 'discussiontools-limitreport-errorreqid', $requestId ); } } /** * Add a topic container around a heading element * * @param Element $headingElement Heading element * @param ContentHeadingItem|null $headingItem Heading item * @param array|null &$tocInfo TOC info */ protected static function addTopicContainer( Element $headingElement, ?ContentHeadingItem $headingItem = null, &$tocInfo = null ) { $doc = $headingElement->ownerDocument; DOMCompat::getClassList( $headingElement )->add( 'ext-discussiontools-init-section' ); if ( !$headingItem ) { return; } $headingNameEscaped = htmlspecialchars( $headingItem->getName(), ENT_NOQUOTES ); // Replaced in ::postprocessTopicSubscription() as the text depends on user state if ( $headingItem->isSubscribable() ) { $subscribeLink = $doc->createComment( '__DTSUBSCRIBELINK__' . $headingNameEscaped ); $headingElement->insertBefore( $subscribeLink, $headingElement->firstChild ); $subscribeButton = $doc->createComment( '__DTSUBSCRIBEBUTTONDESKTOP__' . $headingNameEscaped ); $headingElement->insertBefore( $subscribeButton, $headingElement->firstChild ); } // Visual enhancements: topic containers $summary = $headingItem->getThreadSummary(); if ( $summary['commentCount'] ) { $latestReplyJSON = static::getJsonForCommentMarker( $summary['latestReply'] ); $latestReply = $doc->createComment( // Timestamp output varies by user timezone, so is formatted later '__DTLATESTCOMMENTTHREAD__' . htmlspecialchars( $latestReplyJSON, ENT_NOQUOTES ) . '__' ); $commentCount = $doc->createComment( '__DTCOMMENTCOUNT__' . $summary['commentCount'] . '__' ); $authorCount = $doc->createComment( '__DTAUTHORCOUNT__' . count( $summary['authors'] ) . '__' ); // Topic subscriptions $metadata = $doc->createElement( 'div' ); $metadata->setAttribute( 'class', 'ext-discussiontools-init-section-metadata' ); $metadata->appendChild( $latestReply ); $metadata->appendChild( $commentCount ); $metadata->appendChild( $authorCount ); $actions = $doc->createElement( 'div' ); $actions->setAttribute( 'class', 'ext-discussiontools-init-section-actions' ); if ( $headingItem->isSubscribable() ) { $subscribeButton = $doc->createComment( '__DTSUBSCRIBEBUTTONMOBILE__' . $headingNameEscaped ); $actions->appendChild( $subscribeButton ); } $bar = $doc->createElement( 'div' ); $bar->setAttribute( 'class', 'ext-discussiontools-init-section-bar' ); $bar->appendChild( $metadata ); $bar->appendChild( $actions ); $ellipsisButton = $doc->createComment( '__DTELLIPSISBUTTON__' ); $headingElement->appendChild( $ellipsisButton ); $headingElement->appendChild( $bar ); } $tocInfo[ $headingItem->getLinkableTitle() ] = $summary; } /** * Add discussion tools to some HTML * * @param string $html HTML * @param Title $title * @return array HTML with discussion tools and TOC info */ protected static function addDiscussionToolsInternal( string $html, Title $title ): array { // The output of this method can end up in the HTTP cache (Varnish). Avoid changing it; // and when doing so, ensure that frontend code can handle both the old and new outputs. // See controller#init in JS. $doc = DOMUtils::parseHTML( $html ); $container = DOMCompat::getBody( $doc ); $threadItemSet = static::getParser()->parse( $container, $title->getTitleValue() ); $threadItems = $threadItemSet->getThreadItems(); $tocInfo = []; $newestComment = null; $newestCommentJSON = null; // Iterate in reverse order, because adding the range markers for a thread item // can invalidate the ranges of subsequent thread items (T298096) foreach ( array_reverse( $threadItems ) as $threadItem ) { // TODO: Consider not attaching JSON data to the DOM. // Create a dummy node to attach data to. if ( $threadItem instanceof ContentHeadingItem && $threadItem->isPlaceholderHeading() ) { $node = $doc->createElement( 'span' ); $container->insertBefore( $node, $container->firstChild ); $threadItem->setRange( new ImmutableRange( $node, 0, $node, 0 ) ); } // Add start and end markers to range $id = $threadItem->getId(); $range = $threadItem->getRange(); $startMarker = $doc->createElement( 'span' ); $startMarker->setAttribute( 'data-mw-comment-start', '' ); $startMarker->setAttribute( 'id', $id ); $endMarker = $doc->createElement( 'span' ); $endMarker->setAttribute( 'data-mw-comment-end', $id ); // Extend the range if the start or end is inside an element which can't have element children. // (There may be other problematic elements... but this seems like a good start.) while ( CommentUtils::cantHaveElementChildren( $range->startContainer ) ) { $range = $range->setStart( $range->startContainer->parentNode, CommentUtils::childIndexOf( $range->startContainer ) ); } while ( CommentUtils::cantHaveElementChildren( $range->endContainer ) ) { $range = $range->setEnd( $range->endContainer->parentNode, CommentUtils::childIndexOf( $range->endContainer ) + 1 ); } $range->setStart( $range->endContainer, $range->endOffset )->insertNode( $endMarker ); $range->insertNode( $startMarker ); $itemData = $threadItem->jsonSerialize(); $itemJSON = json_encode( $itemData ); if ( $threadItem instanceof ContentHeadingItem ) { // , or in Parsoid HTML $headline = $threadItem->getRange()->endContainer; Assert::precondition( $headline instanceof Element, 'HeadingItem refers to an element node' ); $headline->setAttribute( 'data-mw-comment', $itemJSON ); if ( $threadItem->getHeadingLevel() === 2 ) { $headingElement = CommentUtils::closestElement( $headline, [ 'h2' ] ); if ( $headingElement ) { static::addTopicContainer( $headingElement, $threadItem, $tocInfo ); } } } elseif ( $threadItem instanceof ContentCommentItem ) { $replyButtons = $doc->createElement( 'span' ); $replyButtons->setAttribute( 'class', 'ext-discussiontools-init-replylink-buttons' ); $replyButtons->setAttribute( 'data-mw-comment', $itemJSON ); $replyButtons->appendChild( $doc->createComment( '__DTREPLYBUTTONSCONTENT__' ) ); if ( !$newestComment || $threadItem->getTimestamp() > $newestComment->getTimestamp() ) { $newestComment = $threadItem; // Needs to calculated before DOM modifications change ranges $newestCommentJSON = static::getJsonForCommentMarker( $threadItem, true ); } CommentModifier::addReplyLink( $threadItem, $replyButtons ); } } if ( $newestCommentJSON ) { $newestCommentMarker = $doc->createComment( '__DTLATESTCOMMENTPAGE__' . htmlspecialchars( $newestCommentJSON, ENT_NOQUOTES ) . '__' ); $container->appendChild( $newestCommentMarker ); } // Enhance other

's which aren't part of a thread $headings = DOMCompat::querySelectorAll( $container, 'h2' ); foreach ( $headings as $headingElement ) { static::addTopicContainer( $headingElement ); } if ( count( $threadItems ) === 0 ) { $container->appendChild( $doc->createComment( '__DTEMPTYTALKPAGE__' ) ); } // Like DOMCompat::getInnerHTML(), but disable 'smartQuote' for compatibility with // ParserOutput::EDITSECTION_REGEX matching 'mw:editsection' tags (T274709) $html = XMLSerializer::serialize( $container, [ 'innerXML' => true, 'smartQuote' => false ] )['html']; return [ 'html' => $html, 'tocInfo' => $tocInfo ]; } /** * Replace placeholders for all interactive tools with nothing. This is intended for cases where * interaction is unexpected, e.g. reply links while previewing an edit. * * @param string $text * @return string */ public static function removeInteractiveTools( string $text ) { $text = strtr( $text, [ '' => '', '' => '', '' => '', '' => '', '' => '', '' => '', ] ); $text = preg_replace( '//', '', $text ); $text = preg_replace( '//', '', $text ); // (DESKTOP|MOBILE)? can be made unconditional once the un-suffixed buttons have cleared from the cache $text = preg_replace( '//', '', $text ); // To be removed once the old version has cleared from the cache $text = preg_replace( '//', '', $text ); return $text; } /** * Replace placeholders for topic subscription buttons with the real thing. * * @param string $text * @param Language $lang * @param SubscriptionStore $subscriptionStore * @param UserIdentity $user * @param bool $isMobile * @return string */ public static function postprocessTopicSubscription( string $text, Language $lang, SubscriptionStore $subscriptionStore, UserIdentity $user, bool $isMobile ): string { $doc = DOMCompat::newDocument( true ); $matches = []; preg_match_all( '//', $text, $matches ); $itemNames = array_map( static function ( string $itemName, string $link ): string { return $link ? htmlspecialchars_decode( $itemName ) : $itemName; }, $matches[2], $matches[1] ); // TODO: Remove (LINK)? from regex once parser cache has expired (a few weeks): // preg_match_all( '//', $text, $matches ); // $itemNames = array_map( // static function ( string $itemName ): string { // return htmlspecialchars_decode( $itemName ); // }, // $matches[1] // ); $items = $subscriptionStore->getSubscriptionItemsForUser( $user, $itemNames ); $itemsByName = []; foreach ( $items as $item ) { $itemsByName[ $item->getItemName() ] = $item; } $text = preg_replace_callback( '//', static function ( $matches ) use ( $doc, $itemsByName, $lang ) { // TODO: Remove (LINK)? from regex $itemName = $matches[1] ? htmlspecialchars_decode( $matches[2] ) : $matches[2]; $isSubscribed = isset( $itemsByName[ $itemName ] ) && !$itemsByName[ $itemName ]->isMuted(); $subscribedState = isset( $itemsByName[ $itemName ] ) ? $itemsByName[ $itemName ]->getState() : null; $subscribe = $doc->createElement( 'span' ); $subscribe->setAttribute( 'class', 'ext-discussiontools-init-section-subscribe mw-editsection-like' ); $subscribeLink = $doc->createElement( 'a' ); // Set empty 'href' to avoid a:not([href]) selector in MobileFrontend $subscribeLink->setAttribute( 'href', '' ); $subscribeLink->setAttribute( 'class', 'ext-discussiontools-init-section-subscribe-link' ); $subscribeLink->setAttribute( 'role', 'button' ); $subscribeLink->setAttribute( 'tabindex', '0' ); $subscribeLink->setAttribute( 'title', wfMessage( $isSubscribed ? 'discussiontools-topicsubscription-button-unsubscribe-tooltip' : 'discussiontools-topicsubscription-button-subscribe-tooltip' )->inLanguage( $lang )->text() ); $subscribeLink->nodeValue = wfMessage( $isSubscribed ? 'discussiontools-topicsubscription-button-unsubscribe' : 'discussiontools-topicsubscription-button-subscribe' )->inLanguage( $lang )->text(); if ( $subscribedState !== null ) { $subscribeLink->setAttribute( 'data-mw-subscribed', (string)$subscribedState ); } $bracket = $doc->createElement( 'span' ); $bracket->setAttribute( 'class', 'ext-discussiontools-init-section-subscribe-bracket' ); $bracketOpen = $bracket->cloneNode( false ); $bracketOpen->nodeValue = '['; $bracketClose = $bracket->cloneNode( false ); $bracketClose->nodeValue = ']'; $subscribe->appendChild( $bracketOpen ); $subscribe->appendChild( $subscribeLink ); $subscribe->appendChild( $bracketClose ); return DOMCompat::getOuterHTML( $subscribe ); }, $text ); $text = preg_replace_callback( // (DESKTOP|MOBILE)? can be made unconditional once the un-suffixed buttons have cleared from the cache '//', static function ( $matches ) use ( $doc, $itemsByName, $lang, $isMobile ) { if ( $matches[1] ) { $buttonIsMobile = $matches[1] === 'MOBILE'; if ( $buttonIsMobile !== $isMobile ) { return ''; } } $itemName = htmlspecialchars_decode( $matches[2] ); $isSubscribed = isset( $itemsByName[ $itemName ] ) && !$itemsByName[ $itemName ]->isMuted(); $subscribedState = isset( $itemsByName[ $itemName ] ) ? $itemsByName[ $itemName ]->getState() : null; $subscribe = new \OOUI\ButtonWidget( [ 'classes' => [ 'ext-discussiontools-init-section-subscribeButton' ], 'framed' => false, 'icon' => $isSubscribed ? 'bell' : 'bellOutline', 'flags' => [ 'progressive' ], 'label' => wfMessage( $isSubscribed ? 'discussiontools-topicsubscription-button-unsubscribe-label' : 'discussiontools-topicsubscription-button-subscribe-label' )->inLanguage( $lang )->text(), 'title' => wfMessage( $isSubscribed ? 'discussiontools-topicsubscription-button-unsubscribe-tooltip' : 'discussiontools-topicsubscription-button-subscribe-tooltip' )->inLanguage( $lang )->text(), 'infusable' => true, ] ); if ( $subscribedState !== null ) { $subscribe->setAttributes( [ 'data-mw-subscribed' => (string)$subscribedState ] ); } return $subscribe->toString(); }, $text ); return $text; } /** * Replace placeholders for reply links with the real thing. * * @param string $text * @param Language $lang * @param bool $isMobile * @return string */ public static function postprocessReplyTool( string $text, Language $lang, bool $isMobile ): string { $doc = DOMCompat::newDocument( true ); $replyLinkText = wfMessage( 'discussiontools-replylink' )->inLanguage( $lang )->escaped(); $replyButtonText = wfMessage( 'discussiontools-replybutton' )->inLanguage( $lang )->escaped(); // Remove __DTREPLYBUTTONS__ once it has cleared from the cache $text = preg_replace_callback( '/|/', static function ( $matches ) use ( $doc, $replyLinkText, $replyButtonText, $isMobile ) { $itemJSON = $matches[1] ?? null; $replyLinkButtons = $doc->createElement( 'span' ); if ( $itemJSON ) { $replyLinkButtons->setAttribute( 'class', 'ext-discussiontools-init-replylink-buttons' ); $replyLinkButtons->setAttribute( 'data-mw-comment', $itemJSON ); } // Reply $replyLink = $doc->createElement( 'a' ); $replyLink->setAttribute( 'class', 'ext-discussiontools-init-replylink-reply' ); $replyLink->setAttribute( 'role', 'button' ); $replyLink->setAttribute( 'tabindex', '0' ); // Set empty 'href' to avoid a:not([href]) selector in MobileFrontend $replyLink->setAttribute( 'href', '' ); $replyLink->textContent = $replyLinkText; $bracket = $doc->createElement( 'span' ); $bracket->setAttribute( 'class', 'ext-discussiontools-init-replylink-bracket' ); $bracketOpen = $bracket->cloneNode( false ); $bracketClose = $bracket->cloneNode( false ); $bracketOpen->textContent = '['; $bracketClose->textContent = ']'; // Visual enhancements button $replyLinkButton = new \OOUI\ButtonWidget( [ 'classes' => [ 'ext-discussiontools-init-replybutton' ], 'framed' => false, 'label' => $replyButtonText, 'icon' => $isMobile ? 'share' : null, 'flags' => [ 'progressive' ], 'infusable' => true, ] ); DOMCompat::setInnerHTML( $replyLinkButtons, $replyLinkButton->toString() ); $replyLinkButtons->appendChild( $bracketOpen ); $replyLinkButtons->appendChild( $replyLink ); $replyLinkButtons->appendChild( $bracketClose ); return $itemJSON ? DOMCompat::getOuterHTML( $replyLinkButtons ) : DOMCompat::getInnerHTML( $replyLinkButtons ); }, $text ); // Old style replacements for content still in parser cache $text = strtr( $text, [ '' => $replyLinkText, '' => '[', '' => ']', ] ); return $text; } /** * Create a meta item label * * @param string $className * @param string|\OOUI\HtmlSnippet $label Label * @return \OOUI\Tag */ private static function metaLabel( string $className, $label ): \OOUI\Tag { return ( new \OOUI\Tag( 'span' ) ) ->addClasses( [ 'ext-discussiontools-init-section-metaitem', $className ] ) ->appendContent( $label ); } /** * Get JSON for a commentItem that can be inserted into a comment marker * * @param ContentCommentItem $commentItem Comment item * @param bool $includeTopicAndAuthor Include metadata about topic and author * @return string */ private static function getJsonForCommentMarker( ContentCommentItem $commentItem, bool $includeTopicAndAuthor = false ): string { $JSON = [ 'id' => $commentItem->getId(), 'timestamp' => $commentItem->getTimestampString() ]; if ( $includeTopicAndAuthor ) { $JSON['author'] = $commentItem->getAuthor(); $heading = $commentItem->getSubscribableHeading(); if ( $heading ) { $JSON['heading'] = $heading->jsonSerialize(); $JSON['heading']['text'] = $heading->getText(); $JSON['heading']['linkableTitle'] = $heading->getLinkableTitle(); } } return json_encode( $JSON ); } /** * Get a relative timestamp from a signature timestamp. * * Signature timestamps don't have seconds-level accuracy, so any * time difference of less than 120 seconds is treated as being * posted "just now". * * @param MWTimestamp $timestamp * @param Language $lang * @param UserIdentity $user * @return string */ public static function getSignatureRelativeTime( MWTimestamp $timestamp, Language $lang, UserIdentity $user ): string { if ( time() - intval( $timestamp->getTimestamp() ) < 120 ) { $timestamp = new MWTimestamp(); } return $lang->getHumanTimestamp( $timestamp, null, $user ); } /** * Post-process visual enhancements features (topic containers) * * @param string $text * @param Language $lang * @param UserIdentity $user * @param bool $isMobile * @return string */ public static function postprocessVisualEnhancements( string $text, Language $lang, UserIdentity $user, bool $isMobile ): string { $text = preg_replace_callback( '//', static function ( $matches ) use ( $lang, $user ) { $itemData = json_decode( htmlspecialchars_decode( $matches[1] ), true ); if ( $itemData && $itemData['timestamp'] && $itemData['id'] ) { $relativeTime = static::getSignatureRelativeTime( new MWTimestamp( $itemData['timestamp'] ), $lang, $user ); $commentLink = Html::element( 'a', [ 'href' => '#' . Sanitizer::escapeIdForLink( $itemData['id'] ) ], $relativeTime ); $label = wfMessage( 'discussiontools-topicheader-latestcomment' ) ->rawParams( $commentLink ) ->inLanguage( $lang )->escaped(); return CommentFormatter::metaLabel( 'ext-discussiontools-init-section-timestampLabel', new \OOUI\HtmlSnippet( $label ) ); } }, $text ); $text = preg_replace_callback( '//', static function ( $matches ) use ( $lang, $user ) { $count = $lang->formatNum( $matches[1] ); $label = wfMessage( 'discussiontools-topicheader-commentcount', $count )->inLanguage( $lang )->text(); return CommentFormatter::metaLabel( 'ext-discussiontools-init-section-commentCountLabel', $label ); }, $text ); $text = preg_replace_callback( '//', static function ( $matches ) use ( $lang, $user ) { $count = $lang->formatNum( $matches[1] ); $label = wfMessage( 'discussiontools-topicheader-authorcount', $count )->inLanguage( $lang )->text(); return CommentFormatter::metaLabel( 'ext-discussiontools-init-section-authorCountLabel', $label ); }, $text ); if ( $isMobile ) { $text = preg_replace_callback( '//', static function ( $matches ) { $ellipsis = new ButtonMenuSelectWidget( [ 'classes' => [ 'ext-discussiontools-init-section-ellipsisButton' ], 'framed' => false, 'icon' => 'ellipsis', 'infusable' => true, ] ); return $ellipsis->toString(); }, $text ); } else { $text = preg_replace( '//', '', $text ); } return $text; } /** * Post-process visual enhancements features for page subtitle * * @param string $text * @param Language $lang * @param UserIdentity $user * @return ?string */ public static function postprocessVisualEnhancementsSubtitle( string $text, Language $lang, UserIdentity $user ): ?string { preg_match( '//', $text, $matches ); if ( count( $matches ) ) { $itemData = json_decode( htmlspecialchars_decode( $matches[1] ), true ); if ( $itemData && $itemData['timestamp'] && $itemData['id'] ) { $relativeTime = static::getSignatureRelativeTime( new MWTimestamp( $itemData['timestamp'] ), $lang, $user ); $commentLink = Html::element( 'a', [ 'href' => '#' . Sanitizer::escapeIdForLink( $itemData['id'] ) ], $relativeTime ); if ( isset( $itemData['heading'] ) ) { $headingLink = Html::element( 'a', [ 'href' => '#' . Sanitizer::escapeIdForLink( $itemData['heading']['linkableTitle'] ) ], $itemData['heading']['text'] ); $label = wfMessage( 'discussiontools-pageframe-latestcomment' ) ->rawParams( $commentLink ) ->params( $itemData['author'] ) ->rawParams( $headingLink ) ->inLanguage( $lang )->escaped(); } else { $label = wfMessage( 'discussiontools-pageframe-latestcomment-notopic' ) ->rawParams( $commentLink ) ->params( $itemData['author'] ) ->inLanguage( $lang )->escaped(); } return Html::rawElement( 'div', [ 'class' => 'ext-discussiontools-init-pageframe-latestcomment' ], $label ); } } return null; } /** * Check if the talk page had no comments or headings. * * @param string $text * @return bool */ public static function isEmptyTalkPage( string $text ): bool { return strpos( $text, '' ) !== false; } /** * Append content to an empty talk page * * @param string $text * @param string $content * @return string */ public static function appendToEmptyTalkPage( string $text, string $content ): string { return str_replace( '', $content, $text ); } }