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

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