PluginProbe
ActivityPub / 9.1.0
ActivityPub v9.1.0
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 9.1.0, at includes/class-comment.php

1,061 lines 32.5 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 ); // 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 $comment_types = \array_map( 'esc_sql', $comment_types );
456 $placeholders = \implode( ', ', \array_fill( 0, \count( $comment_types ), '%s' ) );
457 // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.NotPrepared
458 $where .= $wpdb->prepare( \sprintf( ' AND comment_type NOT IN (%s)', $placeholders ), ...$comment_types );
459 }
460
461 return $where;
462 }
463
464 /**
465 * Gets the public comment id via the WordPress comments meta.
466 *
467 * @param int $wp_comment_id The internal WordPress comment ID.
468 * @param bool $fallback Whether the code should fall back to `source_url` if `source_id` is not set.
469 *
470 * @return string|null The ActivityPub id/url of the comment.
471 */
472 public static function get_source_id( $wp_comment_id, $fallback = true ) {
473 $comment_meta = \get_comment_meta( $wp_comment_id );
474
475 if ( ! empty( $comment_meta['source_id'][0] ) ) {
476 return $comment_meta['source_id'][0];
477 } elseif ( ! empty( $comment_meta['source_url'][0] ) && $fallback ) {
478 return $comment_meta['source_url'][0];
479 }
480
481 return null;
482 }
483
484 /**
485 * Gets the public comment url via the WordPress comments meta.
486 *
487 * @param int $wp_comment_id The internal WordPress comment ID.
488 * @param bool $fallback Whether the code should fall back to `source_id` if `source_url` is not set.
489 *
490 * @return string|null The ActivityPub id/url of the comment.
491 */
492 public static function get_source_url( $wp_comment_id, $fallback = true ) {
493 $comment_meta = \get_comment_meta( $wp_comment_id );
494
495 if ( ! empty( $comment_meta['source_url'][0] ) ) {
496 return $comment_meta['source_url'][0];
497 } elseif ( ! empty( $comment_meta['source_id'][0] ) && $fallback ) {
498 return $comment_meta['source_id'][0];
499 }
500
501 return null;
502 }
503
504 /**
505 * Link remote comments to source url.
506 *
507 * @param string $comment_link The comment link.
508 * @param object|\WP_Comment $comment The comment object.
509 *
510 * @return string $url
511 */
512 public static function remote_comment_link( $comment_link, $comment ) {
513 if ( ! $comment || \is_admin() || \is_search() ) {
514 return $comment_link;
515 }
516
517 $remote_comment_link = null;
518 if ( 'comment' === $comment->comment_type ) {
519 $remote_comment_link = self::get_source_url( $comment->comment_ID );
520 }
521
522 return $remote_comment_link ?? $comment_link;
523 }
524
525
526 /**
527 * Generates an ActivityPub URI for a comment
528 *
529 * @param \WP_Comment|int $comment A comment object or comment ID.
530 *
531 * @return string ActivityPub URI for comment
532 */
533 public static function generate_id( $comment ) {
534 $comment = \get_comment( $comment );
535
536 // Show external comment ID if it exists.
537 $public_comment_link = self::get_source_id( $comment->comment_ID );
538
539 if ( $public_comment_link ) {
540 return $public_comment_link;
541 }
542
543 // Generate URI based on comment ID.
544 return \add_query_arg( 'c', $comment->comment_ID, \home_url( '/' ) );
545 }
546
547 /**
548 * Check if a post has remote comments
549 *
550 * @param int $post_id The post ID.
551 *
552 * @return bool True if the post has remote comments, false otherwise.
553 */
554 private static function post_has_remote_comments( $post_id ) {
555 $comments = \get_comments(
556 array(
557 'post_id' => $post_id,
558 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
559 'meta_query' => array(
560 'relation' => 'AND',
561 array(
562 'key' => 'protocol',
563 'value' => 'activitypub',
564 'compare' => '=',
565 ),
566 array(
567 'key' => 'source_id',
568 'compare' => 'EXISTS',
569 ),
570 ),
571 )
572 );
573
574 return ! empty( $comments );
575 }
576
577 /**
578 * Get the comment type by activity type.
579 *
580 * @param string $activity_type The activity type.
581 *
582 * @return array|null The comment type.
583 */
584 public static function get_comment_type_by_activity_type( $activity_type ) {
585 $activity_type = \strtolower( $activity_type );
586 $activity_type = \sanitize_key( $activity_type );
587 $comment_types = self::get_comment_types();
588
589 foreach ( $comment_types as $comment_type ) {
590 if ( \in_array( $activity_type, $comment_type['activity_types'], true ) ) {
591 return $comment_type;
592 }
593 }
594
595 return null;
596 }
597
598 /**
599 * Return the registered custom comment types.
600 *
601 * @return array The registered custom comment types
602 */
603 public static function get_comment_types() {
604 global $activitypub_comment_types;
605
606 return (array) $activitypub_comment_types;
607 }
608
609 /**
610 * Is this a registered comment type.
611 *
612 * @param string $slug The slug of the type.
613 *
614 * @return boolean True if registered.
615 */
616 public static function is_registered_comment_type( $slug ) {
617 $slug = \strtolower( $slug );
618 $slug = \sanitize_key( $slug );
619
620 $comment_types = self::get_comment_types();
621
622 return isset( $comment_types[ $slug ] );
623 }
624
625 /**
626 * Return the registered custom comment type slugs.
627 *
628 * @return array The registered custom comment type slugs.
629 */
630 public static function get_comment_type_slugs() {
631 if ( ! \did_action( 'init' ) ) {
632 \_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' );
633
634 return array();
635 }
636
637 return \array_keys( self::get_comment_types() );
638 }
639
640 /**
641 * Get the custom comment type.
642 *
643 * Check if the type is registered, if not, check if it is a custom type.
644 *
645 * It looks for the array key in the registered types and returns the array.
646 * If it is not found, it looks for the type in the custom types and returns the array.
647 *
648 * @param string $type The comment type.
649 *
650 * @return array The comment type.
651 */
652 public static function get_comment_type( $type ) {
653 $type = \strtolower( $type );
654 $type = \sanitize_key( $type );
655
656 $comment_types = self::get_comment_types();
657 $type_array = array();
658
659 // Check array keys.
660 if ( \in_array( $type, \array_keys( $comment_types ), true ) ) {
661 $type_array = $comment_types[ $type ];
662 }
663
664 /**
665 * Filter the comment type.
666 *
667 * @param array $type_array The comment type.
668 */
669 return \apply_filters( "activitypub_comment_type_{$type}", $type_array );
670 }
671
672 /**
673 * Get a comment type attribute.
674 *
675 * @param string $type The comment type.
676 * @param string $attr The attribute to get.
677 *
678 * @return mixed The value of the attribute.
679 */
680 public static function get_comment_type_attr( $type, $attr ) {
681 $type_array = self::get_comment_type( $type );
682
683 if ( $type_array && isset( $type_array[ $attr ] ) ) {
684 $value = $type_array[ $attr ];
685 } else {
686 $value = '';
687 }
688
689 /**
690 * Filter the comment type attribute.
691 *
692 * @param mixed $value The value of the attribute.
693 * @param string $type The comment type.
694 */
695 return \apply_filters( "activitypub_comment_type_{$attr}", $value, $type );
696 }
697
698 /**
699 * Register the comment types used by the ActivityPub plugin.
700 */
701 public static function register_comment_types() {
702 register_comment_type(
703 'repost',
704 array(
705 'label' => \__( 'Reposts', 'activitypub' ),
706 'singular' => \__( 'Repost', 'activitypub' ),
707 '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.',
708 'icon' => '♻️',
709 'class' => 'p-repost',
710 'type' => 'repost',
711 'collection' => 'reposts',
712 'activity_types' => array( 'announce' ),
713 'excerpt' => \html_entity_decode( \__( '&hellip; reposted this!', 'activitypub' ) ),
714 /* translators: %d: Number of reposts */
715 'count_single' => \_x( '%d repost', 'number of reposts', 'activitypub' ),
716 /* translators: %d: Number of reposts */
717 'count_plural' => \_x( '%d reposts', 'number of reposts', 'activitypub' ),
718 )
719 );
720
721 register_comment_type(
722 'like',
723 array(
724 'label' => \__( 'Likes', 'activitypub' ),
725 'singular' => \__( 'Like', 'activitypub' ),
726 'description' => 'A like is a small positive reaction that shows appreciation for a post without sharing it further.',
727 'icon' => '👍',
728 'class' => 'p-like',
729 'type' => 'like',
730 'collection' => 'likes',
731 'activity_types' => array( 'like' ),
732 'excerpt' => \html_entity_decode( \__( '&hellip; liked this!', 'activitypub' ) ),
733 /* translators: %d: Number of likes */
734 'count_single' => \_x( '%d like', 'number of likes', 'activitypub' ),
735 /* translators: %d: Number of likes */
736 'count_plural' => \_x( '%d likes', 'number of likes', 'activitypub' ),
737 )
738 );
739
740 register_comment_type(
741 'quote',
742 array(
743 'label' => \__( 'Quotes', 'activitypub' ),
744 'singular' => \__( 'Quote', 'activitypub' ),
745 '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.',
746 'icon' => '',
747 'class' => 'p-quote',
748 'type' => 'quote',
749 'collection' => 'quotes',
750 'activity_types' => array( 'quote' ),
751 'excerpt' => \html_entity_decode( \__( '&hellip; quoted this!', 'activitypub' ) ),
752 /* translators: %d: Number of quotes */
753 'count_single' => \_x( '%d quote', 'number of quotes', 'activitypub' ),
754 /* translators: %d: Number of quotes */
755 'count_plural' => \_x( '%d quotes', 'number of quotes', 'activitypub' ),
756 )
757 );
758 }
759
760 /**
761 * Show avatars on Activities if set.
762 *
763 * @param array $types List of avatar enabled comment types.
764 *
765 * @return array show avatars on Activities
766 */
767 public static function get_avatar_comment_types( $types ) {
768 $comment_types = self::get_comment_type_slugs();
769 $types = \array_merge( $types, $comment_types );
770
771 return \array_unique( $types );
772 }
773
774 /**
775 * Excludes likes and reposts from comment queries.
776 *
777 * @author Jan Boddez
778 *
779 * @see https://github.com/janboddez/indieblocks/blob/a2d59de358031056a649ee47a1332ce9e39d4ce2/includes/functions.php#L423-L432
780 *
781 * @param \WP_Comment_Query $query Comment count.
782 */
783 public static function comment_query( $query ) {
784 if ( ! $query instanceof \WP_Comment_Query ) {
785 return;
786 }
787
788 // Do not exclude likes and reposts on ActivityPub requests.
789 if ( \defined( 'ACTIVITYPUB_REQUEST' ) && ACTIVITYPUB_REQUEST ) {
790 return;
791 }
792
793 // Do not exclude likes and reposts on REST requests (handled by rest_comment_query).
794 if ( \wp_is_serving_rest_request() ) {
795 return;
796 }
797
798 // Filter post types for admin requests.
799 if ( \is_admin() ) {
800 $query->query_vars['post_type'] = self::get_allowed_comment_post_types();
801 return;
802 }
803
804 // Do not exclude likes and reposts on non-singular pages.
805 if ( ! \is_singular() ) {
806 return;
807 }
808
809 // Do not exclude likes and reposts if the query is for specific types.
810 if ( ! empty( $query->query_vars['type__in'] ) || ! empty( $query->query_vars['type'] ) ) {
811 return;
812 }
813
814 // Do not exclude likes and reposts if the query is already excluding other comment types.
815 if ( ! empty( $query->query_vars['type__not_in'] ) ) {
816 return;
817 }
818
819 // Exclude likes and reposts by the ActivityPub plugin.
820 $query->query_vars['type__not_in'] = self::get_comment_type_slugs();
821 }
822
823 /**
824 * Filters comments in REST API requests.
825 *
826 * Excludes comments on ActivityPub post types and ActivityPub comment
827 * types (likes, reposts) from the REST API.
828 *
829 * @param array $prepared_args Array of arguments for WP_Comment_Query.
830 *
831 * @return array Modified array of arguments.
832 */
833 public static function rest_comment_query( $prepared_args ) {
834 // Exclude comments on ActivityPub post types.
835 $prepared_args['post_type'] = self::get_allowed_comment_post_types();
836
837 // Exclude ActivityPub comment types (likes, reposts) unless explicitly requested.
838 if ( empty( $prepared_args['type'] ) && empty( $prepared_args['type__in'] ) ) {
839 $prepared_args['type__not_in'] = self::get_comment_type_slugs();
840 }
841
842 return $prepared_args;
843 }
844
845 /**
846 * Returns post types that should show comments (excluding hidden post types).
847 *
848 * @return array Array of post type names.
849 */
850 private static function get_allowed_comment_post_types() {
851 $hide_for = self::hide_for();
852
853 if ( empty( $hide_for ) ) {
854 return \get_post_types_by_support( 'comments' );
855 }
856
857 return \array_diff( \get_post_types_by_support( 'comments' ), $hide_for );
858 }
859
860 /**
861 * Filter the comment status before it is set.
862 *
863 * @param int|string|\WP_Error $approved The approved comment status.
864 * @param array $comment_data The comment data.
865 *
866 * @return int|string|\WP_Error The approval status. 1, 0, 'spam', 'trash', or WP_Error.
867 */
868 public static function pre_comment_approved( $approved, $comment_data ) {
869 /*
870 * Only return early for already-approved comments, trash, or errors.
871 * Don't short-circuit on 'spam' - we may want to override Akismet.
872 * Respect 'trash' since it comes from the WordPress disallowed list.
873 */
874 if ( 1 === $approved || '1' === $approved || 'trash' === $approved || \is_wp_error( $approved ) ) {
875 return $approved;
876 }
877
878 // Maybe auto-approve likes and reposts.
879 if (
880 \in_array( $comment_data['comment_type'], self::get_comment_type_slugs(), true ) &&
881 '1' === \get_option( 'activitypub_auto_approve_reactions' )
882 ) {
883 return 1;
884 }
885
886 /*
887 * Always auto-approve comments on remote posts (ap_post) since
888 * they are not visible in the WP admin comment moderation screen.
889 */
890 $post_id = $comment_data['comment_post_ID'];
891 $post = \get_post( $post_id );
892
893 if ( $post && \in_array( $post->post_type, self::hide_for(), true ) ) {
894 return 1;
895 }
896
897 if ( '1' !== \get_option( 'comment_previously_approved' ) ) {
898 return $approved;
899 }
900
901 if (
902 empty( $comment_data['comment_meta']['protocol'] ) ||
903 'activitypub' !== $comment_data['comment_meta']['protocol']
904 ) {
905 return $approved;
906 }
907
908 global $wpdb;
909
910 $author = $comment_data['comment_author'];
911 $author_url = $comment_data['comment_author_url'];
912 // phpcs:ignore
913 $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 ) );
914
915 if ( 1 === (int) $ok_to_comment ) {
916 return 1;
917 }
918
919 return $approved;
920 }
921
922 /**
923 * Update comment counts when interaction settings are disabled.
924 *
925 * Triggers a recount when likes or reposts are disabled to ensure accurate comment counts.
926 *
927 * @param mixed $old_value The old option value.
928 * @param mixed $value The new option value.
929 */
930 public static function maybe_update_comment_counts( $old_value, $value ) {
931 if ( '1' === $old_value && '1' !== $value ) {
932 Migration::update_comment_counts();
933 }
934 }
935
936 /**
937 * Filters the comment count to exclude ActivityPub comment types.
938 *
939 * @param int|null $new_count The new comment count. Default null.
940 * @param int $old_count The old comment count.
941 * @param int $post_id Post ID.
942 *
943 * @return int|null The updated comment count, or null to use the default query.
944 */
945 public static function pre_wp_update_comment_count_now( $new_count, $old_count, $post_id ) {
946 if ( null === $new_count ) {
947 $excluded_types = \array_filter( self::get_comment_type_slugs(), array( self::class, 'is_comment_type_enabled' ) );
948
949 if ( ! empty( $excluded_types ) ) {
950 /*
951 * Include 'note' type when Gutenberg's filter is registered, so a
952 * single query excludes both ActivityPub and Gutenberg types.
953 */
954 if ( \has_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count' ) ) {
955 $excluded_types[] = 'note';
956 }
957
958 /**
959 * Filters the comment types excluded from the comment count.
960 *
961 * Runs at priority 5 on `pre_wp_update_comment_count_now` so that
962 * a single query can exclude types from multiple plugins. Other
963 * plugins can hook here to add their own comment types.
964 *
965 * @since 8.0.0
966 *
967 * @param string[] $excluded_types The comment type slugs to exclude.
968 * @param int $post_id The post ID.
969 */
970 $excluded_types = \apply_filters( 'activitypub_excluded_comment_types', $excluded_types, $post_id );
971 $excluded_types = \array_unique( \array_filter( $excluded_types ) );
972
973 global $wpdb;
974
975 // phpcs:ignore WordPress.DB
976 $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 ) );
977 }
978 }
979
980 return $new_count;
981 }
982
983 /**
984 * Check if a comment type is enabled.
985 *
986 * @param string $comment_type The comment type.
987 * @return bool True if the comment type is enabled.
988 */
989 public static function is_comment_type_enabled( $comment_type ) {
990 return '1' === \get_option( "activitypub_allow_{$comment_type}s", '1' );
991 }
992
993 /**
994 * Get post types to hide comments for in admin.
995 *
996 * These are non-public post types whose comments should not appear
997 * in the main comments list in the WordPress admin.
998 *
999 * @return string[] Array of post type names to hide comments for.
1000 */
1001 public static function hide_for() {
1002 $post_types = array( Remote_Posts::POST_TYPE );
1003
1004 /**
1005 * Filters the list of post types to hide comments for.
1006 *
1007 * @param string[] $post_types Array of post type names to hide comments for.
1008 */
1009 return \apply_filters( 'activitypub_hide_comments_for', $post_types );
1010 }
1011
1012 /**
1013 * Render emoji in comment author name.
1014 *
1015 * Replaces emoji shortcodes with img tags on the get_comment_author filter.
1016 * Emoji data is retrieved from the linked remote actor.
1017 *
1018 * @param string $author The comment author name.
1019 * @param string $comment_id The comment ID as a numeric string.
1020 *
1021 * @return string The comment author name with rendered emoji.
1022 */
1023 public static function render_emoji( $author, $comment_id ) {
1024 $remote_actor_id = \get_comment_meta( $comment_id, '_activitypub_remote_actor_id', true );
1025
1026 if ( empty( $remote_actor_id ) ) {
1027 return $author;
1028 }
1029
1030 $emoji_data = \get_post_meta( $remote_actor_id, '_activitypub_emoji', true );
1031
1032 if ( empty( $emoji_data ) ) {
1033 return $author;
1034 }
1035
1036 return Emoji::replace_from_json( $author, $emoji_data );
1037 }
1038
1039 /**
1040 * Selectively unescape emoji images in comment author.
1041 *
1042 * This runs at priority 20 after WordPress's esc_html() filter on comment_author.
1043 *
1044 * @param string $author The comment author name (already escaped by WordPress).
1045 *
1046 * @return string The comment author name with emoji images unescaped.
1047 */
1048 public static function unescape_emoji( $author ) {
1049 // Only attempt to unescape if there are emoji images present in the escaped string.
1050 if ( false === \strpos( $author, 'class=&quot;emoji&quot;' ) ) {
1051 return $author;
1052 }
1053
1054 // Decode entities so we can selectively restore emoji <img> tags.
1055 $decoded = \html_entity_decode( $author, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
1056
1057 // Use strict KSES validation to only allow valid emoji img tags.
1058 return \wp_kses( $decoded, Emoji::get_kses_allowed_html() );
1059 }
1060 }
1061