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