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