PluginProbe
ActivityPub / 8.0.2
ActivityPub v8.0.2
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / class-comment.php

class-comment.php in ActivityPub 8.0.2, at includes/class-comment.php

1,035 lines 31.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * ActivityPub Comment Class
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 use Activitypub\Collection\Actors;
11 use Activitypub\Collection\Posts;
12
13 /**
14 * ActivityPub Comment Class.
15 *
16 * This class is a helper/utils class that provides a collection of static
17 * methods that are used to handle comments.
18 */
19 class Comment {
20 /**
21 * Initialize the class, registering WordPress hooks.
22 */
23 public static function init() {
24 self::register_comment_types();
25
26 \add_filter( 'map_meta_cap', array( self::class, 'map_meta_cap' ), 10, 4 );
27 \add_filter( 'comment_reply_link', array( self::class, 'comment_reply_link' ), 10, 3 );
28 \add_filter( 'comment_class', array( self::class, 'comment_class' ), 10, 3 );
29 \add_filter( 'comment_feed_where', array( static::class, 'comment_feed_where' ) );
30 \add_filter( 'get_comment_link', array( self::class, 'remote_comment_link' ), 11, 2 );
31 \add_action( 'pre_get_comments', array( static::class, 'comment_query' ) );
32 \add_filter( 'pre_comment_approved', array( static::class, 'pre_comment_approved' ), 11, 2 );
33 \add_filter( 'get_avatar_comment_types', array( static::class, 'get_avatar_comment_types' ), 99 );
34 \add_action( 'update_option_activitypub_allow_likes', array( self::class, 'maybe_update_comment_counts' ), 10, 2 );
35 \add_action( 'update_option_activitypub_allow_reposts', array( self::class, 'maybe_update_comment_counts' ), 10, 2 );
36 \add_filter( 'pre_wp_update_comment_count_now', array( static::class, 'pre_wp_update_comment_count_now' ), 5, 3 );
37 \add_filter( 'get_comment_author', array( static::class, 'render_emoji' ), 10, 2 );
38 \add_filter( 'comment_author', array( static::class, 'unescape_emoji' ), 20 ); // After esc_html().
39 \add_filter( 'rest_comment_query', array( static::class, 'rest_comment_query' ) );
40 \add_filter( 'comment_text', array( static::class, 'render_blocks' ), 5 ); // Before other filters.
41 }
42
43 /**
44 * Render blocks in comment content.
45 *
46 * Comments don't automatically parse blocks like posts do.
47 * This filter applies do_blocks() to render activitypub/emoji
48 * and activitypub/image blocks in comment content.
49 *
50 * @param string $content The comment content.
51 *
52 * @return string The content with blocks rendered.
53 */
54 public static function render_blocks( $content ) {
55 if ( empty( $content ) || ! \str_contains( $content, '<!-- wp:activitypub/' ) ) {
56 return $content;
57 }
58
59 $blocks = \parse_blocks( $content );
60 $output = '';
61
62 foreach ( $blocks as $block ) {
63 if ( ! empty( $block['blockName'] ) && \str_starts_with( $block['blockName'], 'activitypub/' ) ) {
64 $output .= \render_block( $block );
65 } else {
66 $output .= \serialize_block( $block );
67 }
68 }
69
70 return $output;
71 }
72
73 /**
74 * Remove edit capabilities for comments received via ActivityPub.
75 *
76 * @param array $caps Array of capabilities.
77 * @param string $cap Capability name.
78 * @param int $user_id User ID.
79 * @param array $args Array of arguments.
80 *
81 * @return array Modified array of capabilities.
82 */
83 public static function map_meta_cap( $caps, $cap, $user_id, $args ) {
84 if ( 'edit_comment' === $cap && self::was_received( $args[0] ) ) {
85 if ( ! \is_admin() || ( isset( $GLOBALS['current_screen'] ) && 'comment' === $GLOBALS['current_screen']->id ) ) {
86 $caps[] = 'do_not_allow';
87 }
88 }
89
90 return $caps;
91 }
92
93 /**
94 * Filter the comment reply link.
95 *
96 * Handles three cases for replies to fediverse comments:
97 * 1. User can federate → show normal reply link
98 * 2. User is logged in but can't federate → show warning (no reply link)
99 * 3. User is not logged in → show remote reply block
100 *
101 * @param string $link The HTML markup for the comment reply link.
102 * @param array $args An array of arguments overriding the defaults.
103 * @param \WP_Comment $comment The object of the comment being replied.
104 *
105 * @return string The filtered HTML markup for the comment reply link.
106 */
107 public static function comment_reply_link( $link, $args, $comment ) {
108 if ( self::are_comments_allowed( $comment ) ) {
109 return $link;
110 }
111
112 // Logged-in user without ActivityPub capability - show warning instead of reply link.
113 if ( \is_user_logged_in() ) {
114 $author = \esc_html( $comment->comment_author );
115
116 $message = sprintf(
117 /* translators: %s: comment author name */
118 \__( '%s is on the Fediverse. To reply to them, ask your administrator to enable ActivityPub for your account.', 'activitypub' ),
119 $author
120 );
121
122 // Add link to users page if current user can edit users.
123 if ( \current_user_can( 'edit_users' ) ) {
124 $message = sprintf(
125 /* translators: 1: comment author name, 2: URL to the users management page */
126 \__( '%1$s is on the Fediverse. To reply to them, <a href="%2$s">enable ActivityPub for your account</a>.', 'activitypub' ),
127 $author,
128 \esc_url( \admin_url( 'users.php' ) )
129 );
130 }
131
132 $warning = sprintf(
133 '<p class="activitypub-reply-warning"><em>%s</em></p>',
134 \wp_kses( $message, array( 'a' => array( 'href' => array() ) ) )
135 );
136
137 /**
138 * Filters the warning message shown to logged-in users without ActivityPub capability.
139 *
140 * @param string $warning The warning HTML markup.
141 * @param \WP_Comment $comment The comment being replied to.
142 */
143 return \apply_filters( 'activitypub_federation_warning', $warning, $comment );
144 }
145
146 if ( ! \WP_Block_Type_Registry::get_instance()->is_registered( 'activitypub/remote-reply' ) ) {
147 \register_block_type_from_metadata( ACTIVITYPUB_PLUGIN_DIR . 'build/remote-reply' );
148 }
149
150 $attributes = array(
151 'selectedComment' => self::generate_id( $comment ),
152 'commentId' => $comment->comment_ID,
153 );
154
155 $block = \do_blocks( \sprintf( '<!-- wp:activitypub/remote-reply %s /-->', \wp_json_encode( $attributes ) ) );
156
157 /**
158 * Filters the HTML markup for the ActivityPub remote comment reply container.
159 *
160 * @param string $block The HTML markup for the remote reply container.
161 */
162 return \apply_filters( 'activitypub_comment_reply_link', $block );
163 }
164
165 /**
166 * Check if it is allowed to comment to a comment.
167 *
168 * Checks if the comment is local only or if the user can comment federated comments.
169 *
170 * @param mixed $comment Comment object or ID.
171 *
172 * @return boolean True if the user can comment, false otherwise.
173 */
174 public static function are_comments_allowed( $comment ) {
175 $comment = \get_comment( $comment );
176
177 if ( ! self::was_received( $comment ) ) {
178 return true;
179 }
180
181 $current_user = get_current_user_id();
182
183 if ( ! $current_user ) {
184 return false;
185 }
186
187 if ( is_single_user() && \user_can( $current_user, 'activitypub' ) ) {
188 // On a single user site, comments by users with the `activitypub` capability will be federated as the blog user.
189 $current_user = Actors::BLOG_USER_ID;
190 }
191
192 // User is not allowed to federate comments.
193 return user_can_activitypub( $current_user );
194 }
195
196 /**
197 * Check if a comment is federated.
198 *
199 * We consider a comment federated if comment was received via ActivityPub.
200 *
201 * Use this function to check if it is comment that was received via ActivityPub.
202 *
203 * @param mixed $comment Comment object or ID.
204 *
205 * @return boolean True if the comment is federated, false otherwise.
206 */
207 public static function was_received( $comment ) {
208 $comment = \get_comment( $comment );
209
210 if ( ! $comment ) {
211 return false;
212 }
213
214 $protocol = \get_comment_meta( $comment->comment_ID, 'protocol', true );
215
216 if ( 'activitypub' === $protocol ) {
217 return true;
218 }
219
220 return false;
221 }
222
223 /**
224 * Check if a comment was federated.
225 *
226 * This function checks if a comment was federated via ActivityPub.
227 *
228 * @param mixed $comment Comment object or ID.
229 *
230 * @return boolean True if the comment was federated, false otherwise.
231 */
232 public static function was_sent( $comment ) {
233 $comment = \get_comment( $comment );
234
235 if ( ! $comment ) {
236 return false;
237 }
238
239 $status = \get_comment_meta( $comment->comment_ID, 'activitypub_status', true );
240
241 if ( $status ) {
242 return true;
243 }
244
245 return false;
246 }
247
248 /**
249 * Check if a comment is local only.
250 *
251 * This function checks if a comment is local only and was not sent or received via ActivityPub.
252 *
253 * @param mixed $comment Comment object or ID.
254 *
255 * @return boolean True if the comment is local only, false otherwise.
256 */
257 public static function is_local( $comment ) {
258 if ( self::was_sent( $comment ) || self::was_received( $comment ) ) {
259 return false;
260 }
261
262 return true;
263 }
264
265 /**
266 * Check if a comment should be federated.
267 *
268 * We consider a comment should be federated if it is authored by a user that is
269 * not disabled for federation and if it is a reply directly to the post or to a
270 * federated comment.
271 *
272 * Use this function to check if a comment should be federated.
273 *
274 * @param mixed $comment Comment object or ID.
275 *
276 * @return boolean True if the comment should be federated, false otherwise.
277 */
278 public static function should_be_federated( $comment ) {
279 // We should not federate federated comments.
280 if ( self::was_received( $comment ) ) {
281 return false;
282 }
283
284 $comment = \get_comment( $comment );
285 $user_id = $comment->user_id;
286
287 // Comments without user can't be federated.
288 if ( ! $user_id ) {
289 return false;
290 }
291
292 if ( is_single_user() && \user_can( $user_id, 'activitypub' ) ) {
293 // On a single user site, comments by users with the `activitypub` capability will be federated as the blog user.
294 $user_id = Actors::BLOG_USER_ID;
295 }
296
297 // User is not allowed to federate comments.
298 if ( ! user_can_activitypub( $user_id ) ) {
299 return false;
300 }
301
302 // It is a comment to the post and can be federated.
303 if ( empty( $comment->comment_parent ) ) {
304 return true;
305 }
306
307 // Check if parent comment is federated.
308 $parent_comment = \get_comment( $comment->comment_parent );
309
310 return ! self::is_local( $parent_comment );
311 }
312
313 /**
314 * Examine a comment ID and look up an existing comment it represents.
315 *
316 * @param string $id ActivityPub object ID (usually a URL) to check.
317 *
318 * @return \WP_Comment|false Comment object, or false on failure.
319 */
320 public static function object_id_to_comment( $id ) {
321 $comment_query = new \WP_Comment_Query(
322 array(
323 'meta_key' => 'source_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
324 'meta_value' => $id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
325 'orderby' => 'comment_date',
326 'order' => 'DESC',
327 )
328 );
329
330 if ( ! $comment_query->comments ) {
331 return false;
332 }
333
334 return $comment_query->comments[0];
335 }
336
337 /**
338 * Verify if URL is a local comment, or if it is a previously received
339 * remote comment (For threading comments locally).
340 *
341 * @param string $url The URL to check.
342 *
343 * @return string|null Comment ID or null if not found.
344 */
345 public static function url_to_commentid( $url ) {
346 if ( ! $url || ! \filter_var( $url, \FILTER_VALIDATE_URL ) ) {
347 return null;
348 }
349
350 // Check for local comment.
351 if ( is_same_domain( $url ) ) {
352 $query = \wp_parse_url( $url, \PHP_URL_QUERY );
353
354 if ( $query ) {
355 \parse_str( $query, $params );
356
357 if ( ! empty( $params['c'] ) ) {
358 $comment = \get_comment( $params['c'] );
359
360 if ( $comment ) {
361 return $comment->comment_ID;
362 }
363 }
364 }
365 }
366
367 $args = array(
368 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
369 'meta_query' => array(
370 'relation' => 'OR',
371 array(
372 'key' => 'source_url',
373 'value' => $url,
374 ),
375 array(
376 'key' => 'source_id',
377 'value' => $url,
378 ),
379 ),
380 );
381
382 $query = new \WP_Comment_Query();
383 $comments = $query->query( $args );
384
385 if ( $comments && is_array( $comments ) ) {
386 return $comments[0]->comment_ID;
387 }
388
389 return null;
390 }
391
392 /**
393 * Filters the CSS classes to add an ActivityPub class.
394 *
395 * @param string[] $classes An array of comment classes.
396 * @param string[] $css_class An array of additional classes added to the list.
397 * @param string $comment_id The comment ID as a numeric string.
398 *
399 * @return string[] An array of classes.
400 */
401 public static function comment_class( $classes, $css_class, $comment_id ) {
402 // Check if ActivityPub comment.
403 if ( 'activitypub' === get_comment_meta( $comment_id, 'protocol', true ) ) {
404 $classes[] = 'activitypub-comment';
405 }
406
407 return $classes;
408 }
409
410 /**
411 * Makes the comment feed filterable by comment type.
412 *
413 * Also excludes ActivityPub comment types from the feed when no type is specified.
414 *
415 * @param string $where The `WHERE` clause for the comment feed query.
416 *
417 * @return string The modified `WHERE` clause.
418 */
419 public static function comment_feed_where( $where ) {
420 global $wpdb;
421
422 $comment_type = \get_query_var( 'type' );
423
424 if ( 'all' === $comment_type ) {
425 return $where;
426 }
427
428 $comment_types = self::get_comment_type_slugs();
429
430 if ( \in_array( $comment_type, $comment_types, true ) ) {
431 $where .= $wpdb->prepare( ' AND comment_type = %s', $comment_type );
432 } else {
433 $comment_types = \array_map( 'esc_sql', $comment_types );
434 $placeholders = implode( ', ', array_fill( 0, count( $comment_types ), '%s' ) );
435 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.NotPrepared
436 $where .= $wpdb->prepare( sprintf( ' AND comment_type NOT IN (%s)', $placeholders ), ...$comment_types );
437 }
438
439 return $where;
440 }
441
442 /**
443 * Gets the public comment id via the WordPress comments meta.
444 *
445 * @param int $wp_comment_id The internal WordPress comment ID.
446 * @param bool $fallback Whether the code should fall back to `source_url` if `source_id` is not set.
447 *
448 * @return string|null The ActivityPub id/url of the comment.
449 */
450 public static function get_source_id( $wp_comment_id, $fallback = true ) {
451 $comment_meta = \get_comment_meta( $wp_comment_id );
452
453 if ( ! empty( $comment_meta['source_id'][0] ) ) {
454 return $comment_meta['source_id'][0];
455 } elseif ( ! empty( $comment_meta['source_url'][0] ) && $fallback ) {
456 return $comment_meta['source_url'][0];
457 }
458
459 return null;
460 }
461
462 /**
463 * Gets the public comment url via the WordPress comments meta.
464 *
465 * @param int $wp_comment_id The internal WordPress comment ID.
466 * @param bool $fallback Whether the code should fall back to `source_id` if `source_url` is not set.
467 *
468 * @return string|null The ActivityPub id/url of the comment.
469 */
470 public static function get_source_url( $wp_comment_id, $fallback = true ) {
471 $comment_meta = \get_comment_meta( $wp_comment_id );
472
473 if ( ! empty( $comment_meta['source_url'][0] ) ) {
474 return $comment_meta['source_url'][0];
475 } elseif ( ! empty( $comment_meta['source_id'][0] ) && $fallback ) {
476 return $comment_meta['source_id'][0];
477 }
478
479 return null;
480 }
481
482 /**
483 * Link remote comments to source url.
484 *
485 * @param string $comment_link The comment link.
486 * @param object|\WP_Comment $comment The comment object.
487 *
488 * @return string $url
489 */
490 public static function remote_comment_link( $comment_link, $comment ) {
491 if ( ! $comment || \is_admin() || \is_search() ) {
492 return $comment_link;
493 }
494
495 $remote_comment_link = null;
496 if ( 'comment' === $comment->comment_type ) {
497 $remote_comment_link = self::get_source_url( $comment->comment_ID );
498 }
499
500 return $remote_comment_link ?? $comment_link;
501 }
502
503
504 /**
505 * Generates an ActivityPub URI for a comment
506 *
507 * @param \WP_Comment|int $comment A comment object or comment ID.
508 *
509 * @return string ActivityPub URI for comment
510 */
511 public static function generate_id( $comment ) {
512 $comment = \get_comment( $comment );
513
514 // Show external comment ID if it exists.
515 $public_comment_link = self::get_source_id( $comment->comment_ID );
516
517 if ( $public_comment_link ) {
518 return $public_comment_link;
519 }
520
521 // Generate URI based on comment ID.
522 return \add_query_arg( 'c', $comment->comment_ID, \home_url( '/' ) );
523 }
524
525 /**
526 * Check if a post has remote comments
527 *
528 * @param int $post_id The post ID.
529 *
530 * @return bool True if the post has remote comments, false otherwise.
531 */
532 private static function post_has_remote_comments( $post_id ) {
533 $comments = \get_comments(
534 array(
535 'post_id' => $post_id,
536 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
537 'meta_query' => array(
538 'relation' => 'AND',
539 array(
540 'key' => 'protocol',
541 'value' => 'activitypub',
542 'compare' => '=',
543 ),
544 array(
545 'key' => 'source_id',
546 'compare' => 'EXISTS',
547 ),
548 ),
549 )
550 );
551
552 return ! empty( $comments );
553 }
554
555 /**
556 * Get the comment type by activity type.
557 *
558 * @param string $activity_type The activity type.
559 *
560 * @return array|null The comment type.
561 */
562 public static function get_comment_type_by_activity_type( $activity_type ) {
563 $activity_type = \strtolower( $activity_type );
564 $activity_type = \sanitize_key( $activity_type );
565 $comment_types = self::get_comment_types();
566
567 foreach ( $comment_types as $comment_type ) {
568 if ( in_array( $activity_type, $comment_type['activity_types'], true ) ) {
569 return $comment_type;
570 }
571 }
572
573 return null;
574 }
575
576 /**
577 * Return the registered custom comment types.
578 *
579 * @return array The registered custom comment types
580 */
581 public static function get_comment_types() {
582 global $activitypub_comment_types;
583
584 return $activitypub_comment_types;
585 }
586
587 /**
588 * Is this a registered comment type.
589 *
590 * @param string $slug The slug of the type.
591 *
592 * @return boolean True if registered.
593 */
594 public static function is_registered_comment_type( $slug ) {
595 $slug = \strtolower( $slug );
596 $slug = \sanitize_key( $slug );
597
598 $comment_types = self::get_comment_types();
599
600 return isset( $comment_types[ $slug ] );
601 }
602
603 /**
604 * Return the registered custom comment type slugs.
605 *
606 * @return array The registered custom comment type slugs.
607 */
608 public static function get_comment_type_slugs() {
609 if ( ! did_action( 'init' ) ) {
610 _doing_it_wrong( __METHOD__, 'This function should not be called before the init action has run. Comment types are only available after init.', '7.5.0' );
611
612 return array();
613 }
614
615 return array_keys( self::get_comment_types() );
616 }
617
618 /**
619 * Get the custom comment type.
620 *
621 * Check if the type is registered, if not, check if it is a custom type.
622 *
623 * It looks for the array key in the registered types and returns the array.
624 * If it is not found, it looks for the type in the custom types and returns the array.
625 *
626 * @param string $type The comment type.
627 *
628 * @return array The comment type.
629 */
630 public static function get_comment_type( $type ) {
631 $type = strtolower( $type );
632 $type = sanitize_key( $type );
633
634 $comment_types = self::get_comment_types();
635 $type_array = array();
636
637 // Check array keys.
638 if ( in_array( $type, array_keys( $comment_types ), true ) ) {
639 $type_array = $comment_types[ $type ];
640 }
641
642 /**
643 * Filter the comment type.
644 *
645 * @param array $type_array The comment type.
646 */
647 return apply_filters( "activitypub_comment_type_{$type}", $type_array );
648 }
649
650 /**
651 * Get a comment type attribute.
652 *
653 * @param string $type The comment type.
654 * @param string $attr The attribute to get.
655 *
656 * @return mixed The value of the attribute.
657 */
658 public static function get_comment_type_attr( $type, $attr ) {
659 $type_array = self::get_comment_type( $type );
660
661 if ( $type_array && isset( $type_array[ $attr ] ) ) {
662 $value = $type_array[ $attr ];
663 } else {
664 $value = '';
665 }
666
667 /**
668 * Filter the comment type attribute.
669 *
670 * @param mixed $value The value of the attribute.
671 * @param string $type The comment type.
672 */
673 return apply_filters( "activitypub_comment_type_{$attr}", $value, $type );
674 }
675
676 /**
677 * Register the comment types used by the ActivityPub plugin.
678 */
679 public static function register_comment_types() {
680 register_comment_type(
681 'repost',
682 array(
683 'label' => __( 'Reposts', 'activitypub' ),
684 'singular' => __( 'Repost', 'activitypub' ),
685 'description' => 'A repost (or Announce) is when a post appears in the timeline because someone else shared it, while still showing the original author as the source.',
686 'icon' => '♻️',
687 'class' => 'p-repost',
688 'type' => 'repost',
689 'collection' => 'reposts',
690 'activity_types' => array( 'announce' ),
691 'excerpt' => html_entity_decode( \__( '&hellip; reposted this!', 'activitypub' ) ),
692 /* translators: %d: Number of reposts */
693 'count_single' => _x( '%d repost', 'number of reposts', 'activitypub' ),
694 /* translators: %d: Number of reposts */
695 'count_plural' => _x( '%d reposts', 'number of reposts', 'activitypub' ),
696 )
697 );
698
699 register_comment_type(
700 'like',
701 array(
702 'label' => __( 'Likes', 'activitypub' ),
703 'singular' => __( 'Like', 'activitypub' ),
704 'description' => 'A like is a small positive reaction that shows appreciation for a post without sharing it further.',
705 'icon' => '👍',
706 'class' => 'p-like',
707 'type' => 'like',
708 'collection' => 'likes',
709 'activity_types' => array( 'like' ),
710 'excerpt' => html_entity_decode( \__( '&hellip; liked this!', 'activitypub' ) ),
711 /* translators: %d: Number of likes */
712 'count_single' => _x( '%d like', 'number of likes', 'activitypub' ),
713 /* translators: %d: Number of likes */
714 'count_plural' => _x( '%d likes', 'number of likes', 'activitypub' ),
715 )
716 );
717
718 register_comment_type(
719 'quote',
720 array(
721 'label' => __( 'Quotes', 'activitypub' ),
722 'singular' => __( 'Quote', 'activitypub' ),
723 'description' => 'A quote is when a post is shared along with an added comment, so the original post appears together with the sharer&#8217;s own words.',
724 'icon' => '',
725 'class' => 'p-quote',
726 'type' => 'quote',
727 'collection' => 'quotes',
728 'activity_types' => array( 'quote' ),
729 'excerpt' => html_entity_decode( \__( '&hellip; quoted this!', 'activitypub' ) ),
730 /* translators: %d: Number of quotes */
731 'count_single' => _x( '%d quote', 'number of quotes', 'activitypub' ),
732 /* translators: %d: Number of quotes */
733 'count_plural' => _x( '%d quotes', 'number of quotes', 'activitypub' ),
734 )
735 );
736 }
737
738 /**
739 * Show avatars on Activities if set.
740 *
741 * @param array $types List of avatar enabled comment types.
742 *
743 * @return array show avatars on Activities
744 */
745 public static function get_avatar_comment_types( $types ) {
746 $comment_types = self::get_comment_type_slugs();
747 $types = array_merge( $types, $comment_types );
748
749 return array_unique( $types );
750 }
751
752 /**
753 * Excludes likes and reposts from comment queries.
754 *
755 * @author Jan Boddez
756 *
757 * @see https://github.com/janboddez/indieblocks/blob/a2d59de358031056a649ee47a1332ce9e39d4ce2/includes/functions.php#L423-L432
758 *
759 * @param \WP_Comment_Query $query Comment count.
760 */
761 public static function comment_query( $query ) {
762 if ( ! $query instanceof \WP_Comment_Query ) {
763 return;
764 }
765
766 // Do not exclude likes and reposts on ActivityPub requests.
767 if ( defined( 'ACTIVITYPUB_REQUEST' ) && ACTIVITYPUB_REQUEST ) {
768 return;
769 }
770
771 // Do not exclude likes and reposts on REST requests (handled by rest_comment_query).
772 if ( \wp_is_serving_rest_request() ) {
773 return;
774 }
775
776 // Filter post types for admin requests.
777 if ( \is_admin() ) {
778 $query->query_vars['post_type'] = self::get_allowed_comment_post_types();
779 return;
780 }
781
782 // Do not exclude likes and reposts on non-singular pages.
783 if ( ! \is_singular() ) {
784 return;
785 }
786
787 // Do not exclude likes and reposts if the query is for specific types.
788 if ( ! empty( $query->query_vars['type__in'] ) || ! empty( $query->query_vars['type'] ) ) {
789 return;
790 }
791
792 // Do not exclude likes and reposts if the query is already excluding other comment types.
793 if ( ! empty( $query->query_vars['type__not_in'] ) ) {
794 return;
795 }
796
797 // Exclude likes and reposts by the ActivityPub plugin.
798 $query->query_vars['type__not_in'] = self::get_comment_type_slugs();
799 }
800
801 /**
802 * Filters comments in REST API requests.
803 *
804 * Excludes comments on ActivityPub post types and ActivityPub comment
805 * types (likes, reposts) from the REST API.
806 *
807 * @param array $prepared_args Array of arguments for WP_Comment_Query.
808 *
809 * @return array Modified array of arguments.
810 */
811 public static function rest_comment_query( $prepared_args ) {
812 // Exclude comments on ActivityPub post types.
813 $prepared_args['post_type'] = self::get_allowed_comment_post_types();
814
815 // Exclude ActivityPub comment types (likes, reposts) unless explicitly requested.
816 if ( empty( $prepared_args['type'] ) && empty( $prepared_args['type__in'] ) ) {
817 $prepared_args['type__not_in'] = self::get_comment_type_slugs();
818 }
819
820 return $prepared_args;
821 }
822
823 /**
824 * Returns post types that should show comments (excluding hidden post types).
825 *
826 * @return array Array of post type names.
827 */
828 private static function get_allowed_comment_post_types() {
829 $hide_for = self::hide_for();
830
831 if ( empty( $hide_for ) ) {
832 return \get_post_types_by_support( 'comments' );
833 }
834
835 return \array_diff( \get_post_types_by_support( 'comments' ), $hide_for );
836 }
837
838 /**
839 * Filter the comment status before it is set.
840 *
841 * @param int|string|\WP_Error $approved The approved comment status.
842 * @param array $comment_data The comment data.
843 *
844 * @return int|string|\WP_Error The approval status. 1, 0, 'spam', 'trash', or WP_Error.
845 */
846 public static function pre_comment_approved( $approved, $comment_data ) {
847 /*
848 * Only return early for already-approved comments, trash, or errors.
849 * Don't short-circuit on 'spam' - we may want to override Akismet.
850 * Respect 'trash' since it comes from the WordPress disallowed list.
851 */
852 if ( 1 === $approved || '1' === $approved || 'trash' === $approved || \is_wp_error( $approved ) ) {
853 return $approved;
854 }
855
856 // Maybe auto-approve likes and reposts.
857 if (
858 \in_array( $comment_data['comment_type'], self::get_comment_type_slugs(), true ) &&
859 '1' === \get_option( 'activitypub_auto_approve_reactions' )
860 ) {
861 return 1;
862 }
863
864 if ( '1' !== \get_option( 'comment_previously_approved' ) ) {
865 return $approved;
866 }
867
868 if (
869 empty( $comment_data['comment_meta']['protocol'] ) ||
870 'activitypub' !== $comment_data['comment_meta']['protocol']
871 ) {
872 return $approved;
873 }
874
875 global $wpdb;
876
877 $author = $comment_data['comment_author'];
878 $author_url = $comment_data['comment_author_url'];
879 // phpcs:ignore
880 $ok_to_comment = $wpdb->get_var( $wpdb->prepare( "SELECT comment_approved FROM $wpdb->comments WHERE comment_author = %s AND comment_author_url = %s and comment_approved = '1' LIMIT 1", $author, $author_url ) );
881
882 if ( 1 === (int) $ok_to_comment ) {
883 return 1;
884 }
885
886 $post_id = $comment_data['comment_post_ID'];
887 $post = \get_post( $post_id );
888
889 if ( $post && in_array( $post->post_type, self::hide_for(), true ) ) {
890 return 1;
891 }
892
893 return $approved;
894 }
895
896 /**
897 * Update comment counts when interaction settings are disabled.
898 *
899 * Triggers a recount when likes or reposts are disabled to ensure accurate comment counts.
900 *
901 * @param mixed $old_value The old option value.
902 * @param mixed $value The new option value.
903 */
904 public static function maybe_update_comment_counts( $old_value, $value ) {
905 if ( '1' === $old_value && '1' !== $value ) {
906 Migration::update_comment_counts();
907 }
908 }
909
910 /**
911 * Filters the comment count to exclude ActivityPub comment types.
912 *
913 * @param int|null $new_count The new comment count. Default null.
914 * @param int $old_count The old comment count.
915 * @param int $post_id Post ID.
916 *
917 * @return int|null The updated comment count, or null to use the default query.
918 */
919 public static function pre_wp_update_comment_count_now( $new_count, $old_count, $post_id ) {
920 if ( null === $new_count ) {
921 $excluded_types = array_filter( self::get_comment_type_slugs(), array( self::class, 'is_comment_type_enabled' ) );
922
923 if ( ! empty( $excluded_types ) ) {
924 /*
925 * Include 'note' type when Gutenberg's filter is registered, so a
926 * single query excludes both ActivityPub and Gutenberg types.
927 */
928 if ( \has_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count' ) ) {
929 $excluded_types[] = 'note';
930 }
931
932 /**
933 * Filters the comment types excluded from the comment count.
934 *
935 * Runs at priority 5 on `pre_wp_update_comment_count_now` so that
936 * a single query can exclude types from multiple plugins. Other
937 * plugins can hook here to add their own comment types.
938 *
939 * @since 8.0.0
940 *
941 * @param string[] $excluded_types The comment type slugs to exclude.
942 * @param int $post_id The post ID.
943 */
944 $excluded_types = \apply_filters( 'activitypub_excluded_comment_types', $excluded_types, $post_id );
945 $excluded_types = array_unique( array_filter( $excluded_types ) );
946
947 global $wpdb;
948
949 // phpcs:ignore WordPress.DB
950 $new_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type NOT IN ('" . implode( "','", $excluded_types ) . "')", $post_id ) );
951 }
952 }
953
954 return $new_count;
955 }
956
957 /**
958 * Check if a comment type is enabled.
959 *
960 * @param string $comment_type The comment type.
961 * @return bool True if the comment type is enabled.
962 */
963 public static function is_comment_type_enabled( $comment_type ) {
964 return '1' === get_option( "activitypub_allow_{$comment_type}s", '1' );
965 }
966
967 /**
968 * Get post types to hide comments for in admin.
969 *
970 * These are non-public post types whose comments should not appear
971 * in the main comments list in the WordPress admin.
972 *
973 * @return string[] Array of post type names to hide comments for.
974 */
975 public static function hide_for() {
976 $post_types = array( Posts::POST_TYPE );
977
978 /**
979 * Filters the list of post types to hide comments for.
980 *
981 * @param string[] $post_types Array of post type names to hide comments for.
982 */
983 return \apply_filters( 'activitypub_hide_comments_for', $post_types );
984 }
985
986 /**
987 * Render emoji in comment author name.
988 *
989 * Replaces emoji shortcodes with img tags on the get_comment_author filter.
990 * Emoji data is retrieved from the linked remote actor.
991 *
992 * @param string $author The comment author name.
993 * @param string $comment_id The comment ID as a numeric string.
994 *
995 * @return string The comment author name with rendered emoji.
996 */
997 public static function render_emoji( $author, $comment_id ) {
998 $remote_actor_id = \get_comment_meta( $comment_id, '_activitypub_remote_actor_id', true );
999
1000 if ( empty( $remote_actor_id ) ) {
1001 return $author;
1002 }
1003
1004 $emoji_data = \get_post_meta( $remote_actor_id, '_activitypub_emoji', true );
1005
1006 if ( empty( $emoji_data ) ) {
1007 return $author;
1008 }
1009
1010 return Emoji::replace_from_json( $author, $emoji_data );
1011 }
1012
1013 /**
1014 * Selectively unescape emoji images in comment author.
1015 *
1016 * This runs at priority 20 after WordPress's esc_html() filter on comment_author.
1017 *
1018 * @param string $author The comment author name (already escaped by WordPress).
1019 *
1020 * @return string The comment author name with emoji images unescaped.
1021 */
1022 public static function unescape_emoji( $author ) {
1023 // Only attempt to unescape if there are emoji images present in the escaped string.
1024 if ( false === \strpos( $author, 'class=&quot;emoji&quot;' ) ) {
1025 return $author;
1026 }
1027
1028 // Decode entities so we can selectively restore emoji <img> tags.
1029 $decoded = \html_entity_decode( $author, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
1030
1031 // Use strict KSES validation to only allow valid emoji img tags.
1032 return \wp_kses( $decoded, Emoji::get_kses_allowed_html() );
1033 }
1034 }
1035