PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
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 trunk, at includes/class-comment.php

1,081 lines 33.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\Remote_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, 2 ); // 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 /*
303 * Do not federate brand-new comments on a post that is not federated itself
304 * (e.g. a private post, a post switched to local visibility, or a non-ActivityPub
305 * post type). This prevents leaking replies on content the post type's read rules
306 * would otherwise protect. Comments that were already sent are allowed through so
307 * their Update and Delete activities can still federate (and tear down remote copies).
308 */
309 if ( ! self::was_sent( $comment ) && ! is_post_federated( $comment->comment_post_ID ) ) {
310 return false;
311 }
312
313 // It is a comment to the post and can be federated.
314 if ( empty( $comment->comment_parent ) ) {
315 return true;
316 }
317
318 // Check if parent comment is federated.
319 $parent_comment = \get_comment( $comment->comment_parent );
320
321 return ! self::is_local( $parent_comment );
322 }
323
324 /**
325 * Examine a comment ID and look up an existing comment it represents.
326 *
327 * @since 9.1.0 Added the `$args` parameter.
328 *
329 * @param string $id ActivityPub object ID (usually a URL) to check.
330 * @param array $args Optional. Additional WP_Comment_Query arguments. Pass `array( 'status' => 'any' )`
331 * to also match comments in spam or trash, which the default status excludes.
332 *
333 * @return \WP_Comment|false Comment object, or false on failure.
334 */
335 public static function object_id_to_comment( $id, $args = array() ) {
336 $args = \wp_parse_args(
337 $args,
338 array(
339 'number' => 1,
340 'orderby' => 'comment_date',
341 'order' => 'DESC',
342 )
343 );
344
345 // Force the lookup key and full comment objects, so callers cannot break the return contract.
346 $args['fields'] = 'all';
347 $args['meta_key'] = 'source_id'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
348 $args['meta_value'] = $id; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
349
350 $comment_query = new \WP_Comment_Query( $args );
351
352 if ( ! $comment_query->comments ) {
353 return false;
354 }
355
356 return $comment_query->comments[0];
357 }
358
359 /**
360 * Verify if URL is a local comment, or if it is a previously received
361 * remote comment (For threading comments locally).
362 *
363 * @param string $url The URL to check.
364 *
365 * @return string|null Comment ID or null if not found.
366 */
367 public static function url_to_commentid( $url ) {
368 if ( ! $url || ! \filter_var( $url, \FILTER_VALIDATE_URL ) ) {
369 return null;
370 }
371
372 // Check for local comment.
373 if ( is_same_domain( $url ) ) {
374 $query = \wp_parse_url( $url, \PHP_URL_QUERY );
375
376 if ( $query ) {
377 \parse_str( $query, $params );
378
379 if ( ! empty( $params['c'] ) ) {
380 $comment = \get_comment( $params['c'] );
381
382 if ( $comment ) {
383 return $comment->comment_ID;
384 }
385 }
386 }
387 }
388
389 $args = array(
390 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
391 'meta_query' => array(
392 'relation' => 'OR',
393 array(
394 'key' => 'source_url',
395 'value' => $url,
396 ),
397 array(
398 'key' => 'source_id',
399 'value' => $url,
400 ),
401 ),
402 );
403
404 $query = new \WP_Comment_Query();
405 $comments = $query->query( $args );
406
407 if ( $comments && \is_array( $comments ) ) {
408 return $comments[0]->comment_ID;
409 }
410
411 return null;
412 }
413
414 /**
415 * Filters the CSS classes to add an ActivityPub class.
416 *
417 * @param string[] $classes An array of comment classes.
418 * @param string[] $css_class An array of additional classes added to the list.
419 * @param string $comment_id The comment ID as a numeric string.
420 *
421 * @return string[] An array of classes.
422 */
423 public static function comment_class( $classes, $css_class, $comment_id ) {
424 // Check if ActivityPub comment.
425 if ( 'activitypub' === \get_comment_meta( $comment_id, 'protocol', true ) ) {
426 $classes[] = 'activitypub-comment';
427 }
428
429 return $classes;
430 }
431
432 /**
433 * Makes the comment feed filterable by comment type.
434 *
435 * Also excludes ActivityPub comment types from the feed when no type is specified.
436 *
437 * @param string $where The `WHERE` clause for the comment feed query.
438 *
439 * @return string The modified `WHERE` clause.
440 */
441 public static function comment_feed_where( $where ) {
442 global $wpdb;
443
444 $comment_type = \get_query_var( 'type' );
445
446 if ( 'all' === $comment_type ) {
447 return $where;
448 }
449
450 $comment_types = self::get_comment_type_slugs();
451
452 if ( \in_array( $comment_type, $comment_types, true ) ) {
453 $where .= $wpdb->prepare( ' AND comment_type = %s', $comment_type );
454 } else {
455 $placeholders = \implode( ', ', \array_fill( 0, \count( $comment_types ), '%s' ) );
456 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.NotPrepared
457 $where .= $wpdb->prepare( \sprintf( ' AND comment_type NOT IN (%s)', $placeholders ), ...$comment_types );
458 }
459
460 return $where;
461 }
462
463 /**
464 * Gets the public comment id via the WordPress comments meta.
465 *
466 * @param int $wp_comment_id The internal WordPress comment ID.
467 * @param bool $fallback Whether the code should fall back to `source_url` if `source_id` is not set.
468 *
469 * @return string|null The ActivityPub id/url of the comment.
470 */
471 public static function get_source_id( $wp_comment_id, $fallback = true ) {
472 $comment_meta = \get_comment_meta( $wp_comment_id );
473
474 if ( ! empty( $comment_meta['source_id'][0] ) ) {
475 return $comment_meta['source_id'][0];
476 } elseif ( ! empty( $comment_meta['source_url'][0] ) && $fallback ) {
477 return $comment_meta['source_url'][0];
478 }
479
480 return null;
481 }
482
483 /**
484 * Gets the public comment url via the WordPress comments meta.
485 *
486 * @param int $wp_comment_id The internal WordPress comment ID.
487 * @param bool $fallback Whether the code should fall back to `source_id` if `source_url` is not set.
488 *
489 * @return string|null The ActivityPub id/url of the comment.
490 */
491 public static function get_source_url( $wp_comment_id, $fallback = true ) {
492 $comment_meta = \get_comment_meta( $wp_comment_id );
493
494 if ( ! empty( $comment_meta['source_url'][0] ) ) {
495 return $comment_meta['source_url'][0];
496 } elseif ( ! empty( $comment_meta['source_id'][0] ) && $fallback ) {
497 return $comment_meta['source_id'][0];
498 }
499
500 return null;
501 }
502
503 /**
504 * Link remote comments to source url.
505 *
506 * @param string $comment_link The comment link.
507 * @param object|\WP_Comment $comment The comment object.
508 *
509 * @return string $url
510 */
511 public static function remote_comment_link( $comment_link, $comment ) {
512 if ( ! $comment || \is_admin() || \is_search() ) {
513 return $comment_link;
514 }
515
516 $remote_comment_link = null;
517 if ( 'comment' === $comment->comment_type ) {
518 $remote_comment_link = self::get_source_url( $comment->comment_ID );
519 }
520
521 return $remote_comment_link ?? $comment_link;
522 }
523
524
525 /**
526 * Generates an ActivityPub URI for a comment
527 *
528 * @param \WP_Comment|int $comment A comment object or comment ID.
529 *
530 * @return string ActivityPub URI for comment
531 */
532 public static function generate_id( $comment ) {
533 $comment = \get_comment( $comment );
534
535 // Show external comment ID if it exists.
536 $public_comment_link = self::get_source_id( $comment->comment_ID );
537
538 if ( $public_comment_link ) {
539 return $public_comment_link;
540 }
541
542 // Generate URI based on comment ID.
543 return \add_query_arg( 'c', $comment->comment_ID, \home_url( '/' ) );
544 }
545
546 /**
547 * Check if a post has remote comments
548 *
549 * @param int $post_id The post ID.
550 *
551 * @return bool True if the post has remote comments, false otherwise.
552 */
553 private static function post_has_remote_comments( $post_id ) {
554 $comments = \get_comments(
555 array(
556 'post_id' => $post_id,
557 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
558 'meta_query' => array(
559 'relation' => 'AND',
560 array(
561 'key' => 'protocol',
562 'value' => 'activitypub',
563 'compare' => '=',
564 ),
565 array(
566 'key' => 'source_id',
567 'compare' => 'EXISTS',
568 ),
569 ),
570 )
571 );
572
573 return ! empty( $comments );
574 }
575
576 /**
577 * Get the comment type by activity type.
578 *
579 * @param string $activity_type The activity type.
580 *
581 * @return array|null The comment type.
582 */
583 public static function get_comment_type_by_activity_type( $activity_type ) {
584 $activity_type = \strtolower( $activity_type );
585 $activity_type = \sanitize_key( $activity_type );
586 $comment_types = self::get_comment_types();
587
588 foreach ( $comment_types as $comment_type ) {
589 if ( \in_array( $activity_type, $comment_type['activity_types'], true ) ) {
590 return $comment_type;
591 }
592 }
593
594 return null;
595 }
596
597 /**
598 * Return the registered custom comment types.
599 *
600 * @return array The registered custom comment types
601 */
602 public static function get_comment_types() {
603 global $activitypub_comment_types;
604
605 return (array) $activitypub_comment_types;
606 }
607
608 /**
609 * Is this a registered comment type.
610 *
611 * @param string $slug The slug of the type.
612 *
613 * @return boolean True if registered.
614 */
615 public static function is_registered_comment_type( $slug ) {
616 $slug = \strtolower( $slug );
617 $slug = \sanitize_key( $slug );
618
619 $comment_types = self::get_comment_types();
620
621 return isset( $comment_types[ $slug ] );
622 }
623
624 /**
625 * Return the registered custom comment type slugs.
626 *
627 * @return array The registered custom comment type slugs.
628 */
629 public static function get_comment_type_slugs() {
630 if ( ! \did_action( 'init' ) ) {
631 \_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' );
632
633 return array();
634 }
635
636 return \array_keys( self::get_comment_types() );
637 }
638
639 /**
640 * Get the custom comment type.
641 *
642 * Check if the type is registered, if not, check if it is a custom type.
643 *
644 * It looks for the array key in the registered types and returns the array.
645 * If it is not found, it looks for the type in the custom types and returns the array.
646 *
647 * @param string $type The comment type.
648 *
649 * @return array The comment type.
650 */
651 public static function get_comment_type( $type ) {
652 $type = \strtolower( $type );
653 $type = \sanitize_key( $type );
654
655 $comment_types = self::get_comment_types();
656 $type_array = array();
657
658 // Check array keys.
659 if ( \in_array( $type, \array_keys( $comment_types ), true ) ) {
660 $type_array = $comment_types[ $type ];
661 }
662
663 /**
664 * Filter the comment type.
665 *
666 * @param array $type_array The comment type.
667 */
668 return \apply_filters( "activitypub_comment_type_{$type}", $type_array );
669 }
670
671 /**
672 * Get a comment type attribute.
673 *
674 * @param string $type The comment type.
675 * @param string $attr The attribute to get.
676 *
677 * @return mixed The value of the attribute.
678 */
679 public static function get_comment_type_attr( $type, $attr ) {
680 $type_array = self::get_comment_type( $type );
681
682 if ( $type_array && isset( $type_array[ $attr ] ) ) {
683 $value = $type_array[ $attr ];
684 } else {
685 $value = '';
686 }
687
688 /**
689 * Filter the comment type attribute.
690 *
691 * @param mixed $value The value of the attribute.
692 * @param string $type The comment type.
693 */
694 return \apply_filters( "activitypub_comment_type_{$attr}", $value, $type );
695 }
696
697 /**
698 * Register the comment types used by the ActivityPub plugin.
699 */
700 public static function register_comment_types() {
701 register_comment_type(
702 'repost',
703 array(
704 'label' => \__( 'Reposts', 'activitypub' ),
705 'singular' => \__( 'Repost', 'activitypub' ),
706 '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.',
707 'icon' => '♻️',
708 'class' => 'p-repost',
709 'type' => 'repost',
710 'collection' => 'reposts',
711 'activity_types' => array( 'announce' ),
712 'excerpt' => \html_entity_decode( \__( '&hellip; reposted this!', 'activitypub' ) ),
713 /* translators: %d: Number of reposts */
714 'count_single' => \_x( '%d repost', 'number of reposts', 'activitypub' ),
715 /* translators: %d: Number of reposts */
716 'count_plural' => \_x( '%d reposts', 'number of reposts', 'activitypub' ),
717 )
718 );
719
720 register_comment_type(
721 'like',
722 array(
723 'label' => \__( 'Likes', 'activitypub' ),
724 'singular' => \__( 'Like', 'activitypub' ),
725 'description' => 'A like is a small positive reaction that shows appreciation for a post without sharing it further.',
726 'icon' => '👍',
727 'class' => 'p-like',
728 'type' => 'like',
729 'collection' => 'likes',
730 'activity_types' => array( 'like' ),
731 'excerpt' => \html_entity_decode( \__( '&hellip; liked this!', 'activitypub' ) ),
732 /* translators: %d: Number of likes */
733 'count_single' => \_x( '%d like', 'number of likes', 'activitypub' ),
734 /* translators: %d: Number of likes */
735 'count_plural' => \_x( '%d likes', 'number of likes', 'activitypub' ),
736 )
737 );
738
739 register_comment_type(
740 'quote',
741 array(
742 'label' => \__( 'Quotes', 'activitypub' ),
743 'singular' => \__( 'Quote', 'activitypub' ),
744 '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.',
745 'icon' => '',
746 'class' => 'p-quote',
747 'type' => 'quote',
748 'collection' => 'quotes',
749 'activity_types' => array( 'quote' ),
750 'excerpt' => \html_entity_decode( \__( '&hellip; quoted this!', 'activitypub' ) ),
751 /* translators: %d: Number of quotes */
752 'count_single' => \_x( '%d quote', 'number of quotes', 'activitypub' ),
753 /* translators: %d: Number of quotes */
754 'count_plural' => \_x( '%d quotes', 'number of quotes', 'activitypub' ),
755 )
756 );
757 }
758
759 /**
760 * Show avatars on Activities if set.
761 *
762 * @param array $types List of avatar enabled comment types.
763 *
764 * @return array show avatars on Activities
765 */
766 public static function get_avatar_comment_types( $types ) {
767 $comment_types = self::get_comment_type_slugs();
768 $types = \array_merge( $types, $comment_types );
769
770 return \array_unique( $types );
771 }
772
773 /**
774 * Excludes likes and reposts from comment queries.
775 *
776 * @author Jan Boddez
777 *
778 * @see https://github.com/janboddez/indieblocks/blob/a2d59de358031056a649ee47a1332ce9e39d4ce2/includes/functions.php#L423-L432
779 *
780 * @param \WP_Comment_Query $query Comment count.
781 */
782 public static function comment_query( $query ) {
783 if ( ! $query instanceof \WP_Comment_Query ) {
784 return;
785 }
786
787 // Do not exclude likes and reposts on ActivityPub requests.
788 if ( \defined( 'ACTIVITYPUB_REQUEST' ) && ACTIVITYPUB_REQUEST ) {
789 return;
790 }
791
792 // Do not exclude likes and reposts on REST requests (handled by rest_comment_query).
793 if ( \wp_is_serving_rest_request() ) {
794 return;
795 }
796
797 // Filter post types for admin requests.
798 if ( \is_admin() ) {
799 $query->query_vars['post_type'] = self::get_allowed_comment_post_types();
800 return;
801 }
802
803 // Do not exclude likes and reposts on non-singular pages.
804 if ( ! \is_singular() ) {
805 return;
806 }
807
808 // Do not exclude likes and reposts if the query is for specific types.
809 if ( ! empty( $query->query_vars['type__in'] ) || ! empty( $query->query_vars['type'] ) ) {
810 return;
811 }
812
813 // Do not exclude likes and reposts if the query is already excluding other comment types.
814 if ( ! empty( $query->query_vars['type__not_in'] ) ) {
815 return;
816 }
817
818 // Exclude likes and reposts by the ActivityPub plugin.
819 $query->query_vars['type__not_in'] = self::get_comment_type_slugs();
820 }
821
822 /**
823 * Filters comments in REST API requests.
824 *
825 * Excludes comments on ActivityPub post types and ActivityPub comment
826 * types (likes, reposts) from the REST API.
827 *
828 * @param array $prepared_args Array of arguments for WP_Comment_Query.
829 *
830 * @return array Modified array of arguments.
831 */
832 public static function rest_comment_query( $prepared_args ) {
833 // Exclude comments on ActivityPub post types.
834 $prepared_args['post_type'] = self::get_allowed_comment_post_types();
835
836 // Exclude ActivityPub comment types (likes, reposts) unless explicitly requested.
837 if ( empty( $prepared_args['type'] ) && empty( $prepared_args['type__in'] ) ) {
838 $prepared_args['type__not_in'] = self::get_comment_type_slugs();
839 }
840
841 return $prepared_args;
842 }
843
844 /**
845 * Returns post types that should show comments (excluding hidden post types).
846 *
847 * @return array Array of post type names.
848 */
849 private static function get_allowed_comment_post_types() {
850 $hide_for = self::hide_for();
851
852 if ( empty( $hide_for ) ) {
853 return \get_post_types_by_support( 'comments' );
854 }
855
856 return \array_diff( \get_post_types_by_support( 'comments' ), $hide_for );
857 }
858
859 /**
860 * Filter the comment status before it is set.
861 *
862 * @param int|string|\WP_Error $approved The approved comment status.
863 * @param array $comment_data The comment data.
864 *
865 * @return int|string|\WP_Error The approval status. 1, 0, 'spam', 'trash', or WP_Error.
866 */
867 public static function pre_comment_approved( $approved, $comment_data ) {
868 /*
869 * Only return early for already-approved comments, trash, or errors.
870 * Don't short-circuit on 'spam' - we may want to override Akismet.
871 * Respect 'trash' since it comes from the WordPress disallowed list.
872 */
873 if ( 1 === $approved || '1' === $approved || 'trash' === $approved || \is_wp_error( $approved ) ) {
874 return $approved;
875 }
876
877 // Maybe auto-approve likes and reposts.
878 if (
879 \in_array( $comment_data['comment_type'], self::get_comment_type_slugs(), true ) &&
880 '1' === \get_option( 'activitypub_auto_approve_reactions' )
881 ) {
882 return 1;
883 }
884
885 /*
886 * Always auto-approve comments on remote posts (ap_post) since
887 * they are not visible in the WP admin comment moderation screen.
888 */
889 $post_id = $comment_data['comment_post_ID'];
890 $post = \get_post( $post_id );
891
892 if ( $post && \in_array( $post->post_type, self::hide_for(), true ) ) {
893 return 1;
894 }
895
896 if ( '1' !== \get_option( 'comment_previously_approved' ) ) {
897 return $approved;
898 }
899
900 if (
901 empty( $comment_data['comment_meta']['protocol'] ) ||
902 'activitypub' !== $comment_data['comment_meta']['protocol']
903 ) {
904 return $approved;
905 }
906
907 global $wpdb;
908
909 $author = $comment_data['comment_author'];
910 $author_url = $comment_data['comment_author_url'];
911 // phpcs:ignore
912 $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 ) );
913
914 if ( 1 === (int) $ok_to_comment ) {
915 return 1;
916 }
917
918 return $approved;
919 }
920
921 /**
922 * Update comment counts when interaction settings are disabled.
923 *
924 * Triggers a recount when likes or reposts are disabled to ensure accurate comment counts.
925 *
926 * @param mixed $old_value The old option value.
927 * @param mixed $value The new option value.
928 */
929 public static function maybe_update_comment_counts( $old_value, $value ) {
930 if ( '1' === $old_value && '1' !== $value ) {
931 Migration::update_comment_counts();
932 }
933 }
934
935 /**
936 * Filters the comment count to exclude ActivityPub comment types.
937 *
938 * @param int|null $new_count The new comment count. Default null.
939 * @param int $old_count The old comment count.
940 * @param int $post_id Post ID.
941 *
942 * @return int|null The updated comment count, or null to use the default query.
943 */
944 public static function pre_wp_update_comment_count_now( $new_count, $old_count, $post_id ) {
945 if ( null === $new_count ) {
946 $excluded_types = \array_filter( self::get_comment_type_slugs(), array( self::class, 'is_comment_type_enabled' ) );
947
948 if ( ! empty( $excluded_types ) ) {
949 /*
950 * Include 'note' type when Gutenberg's filter is registered, so a
951 * single query excludes both ActivityPub and Gutenberg types.
952 */
953 if ( \has_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count' ) ) {
954 $excluded_types[] = 'note';
955 }
956
957 /**
958 * Filters the comment types excluded from the comment count.
959 *
960 * Runs at priority 5 on `pre_wp_update_comment_count_now` so that
961 * a single query can exclude types from multiple plugins. Other
962 * plugins can hook here to add their own comment types.
963 *
964 * @since 8.0.0
965 *
966 * @param string[] $excluded_types The comment type slugs to exclude.
967 * @param int $post_id The post ID.
968 */
969 $excluded_types = \apply_filters( 'activitypub_excluded_comment_types', $excluded_types, $post_id );
970 $excluded_types = \array_unique( \array_filter( $excluded_types ) );
971
972 global $wpdb;
973
974 // phpcs:ignore WordPress.DB
975 $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 ) );
976 }
977 }
978
979 return $new_count;
980 }
981
982 /**
983 * Check if a comment type is enabled.
984 *
985 * @param string $comment_type The comment type.
986 * @return bool True if the comment type is enabled.
987 */
988 public static function is_comment_type_enabled( $comment_type ) {
989 return '1' === \get_option( "activitypub_allow_{$comment_type}s", '1' );
990 }
991
992 /**
993 * Get post types to hide comments for in admin.
994 *
995 * These are non-public post types whose comments should not appear
996 * in the main comments list in the WordPress admin.
997 *
998 * @return string[] Array of post type names to hide comments for.
999 */
1000 public static function hide_for() {
1001 $post_types = array( Remote_Posts::POST_TYPE );
1002
1003 /**
1004 * Filters the list of post types to hide comments for.
1005 *
1006 * @param string[] $post_types Array of post type names to hide comments for.
1007 */
1008 return \apply_filters( 'activitypub_hide_comments_for', $post_types );
1009 }
1010
1011 /**
1012 * Render emoji in comment author name.
1013 *
1014 * Replaces emoji shortcodes with img tags on the get_comment_author filter.
1015 * Emoji data is retrieved from the linked remote actor.
1016 *
1017 * @param string $author The comment author name.
1018 * @param string $comment_id The comment ID as a numeric string.
1019 *
1020 * @return string The comment author name with rendered emoji.
1021 */
1022 public static function render_emoji( $author, $comment_id ) {
1023 $remote_actor_id = \get_comment_meta( $comment_id, '_activitypub_remote_actor_id', true );
1024
1025 if ( empty( $remote_actor_id ) ) {
1026 return $author;
1027 }
1028
1029 $emoji_data = \get_post_meta( $remote_actor_id, '_activitypub_emoji', true );
1030
1031 if ( empty( $emoji_data ) ) {
1032 return $author;
1033 }
1034
1035 return Emoji::replace_from_json( $author, $emoji_data );
1036 }
1037
1038 /**
1039 * Selectively unescape emoji images in comment author.
1040 *
1041 * This runs at priority 20 after WordPress's esc_html() filter on comment_author.
1042 *
1043 * @since 9.3.0 Added the `$comment_id` parameter.
1044 *
1045 * @param string $author The comment author name (already escaped by WordPress).
1046 * @param int|string $comment_id Optional. The comment ID, as a numeric string from core. Default 0.
1047 *
1048 * @return string The comment author name with emoji images unescaped.
1049 */
1050 public static function unescape_emoji( $author, $comment_id = 0 ) {
1051 /*
1052 * Core always passes the comment ID, but plugins and themes re-apply this filter
1053 * with the name alone. Fall back to the comment in scope so a one-argument caller
1054 * does not leave the emoji img sitting there as escaped text.
1055 */
1056 if ( ! $comment_id ) {
1057 $comment_id = \get_comment_ID();
1058 }
1059
1060 /*
1061 * Only ActivityPub comments can carry emoji, since render_emoji() is what puts the
1062 * img tags there in the first place. Scope this the same way, so an author name
1063 * written by anything else is never decoded -- the substring check below is not a
1064 * reliable signal on its own, and this filter runs on every comment on the site.
1065 */
1066 if ( false === \strpos( $author, 'class=&quot;emoji&quot;' ) ) {
1067 return $author;
1068 }
1069
1070 if ( ! \get_comment_meta( $comment_id, '_activitypub_remote_actor_id', true ) ) {
1071 return $author;
1072 }
1073
1074 // Decode entities so we can selectively restore emoji <img> tags.
1075 $decoded = \html_entity_decode( $author, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
1076
1077 // Use strict KSES validation to only allow valid emoji img tags.
1078 return \wp_kses( $decoded, Emoji::get_kses_allowed_html() );
1079 }
1080 }
1081