diff --git a/src/wp-admin/includes/class-wp-comments-list-table.php b/src/wp-admin/includes/class-wp-comments-list-table.php index 1feaeb3283bcd..78ade23824266 100644 --- a/src/wp-admin/includes/class-wp-comments-list-table.php +++ b/src/wp-admin/includes/class-wp-comments-list-table.php @@ -105,7 +105,7 @@ public function prepare_items() { $comment_type = ''; - if ( ! empty( $_REQUEST['comment_type'] ) && 'note' !== $_REQUEST['comment_type'] ) { + if ( ! empty( $_REQUEST['comment_type'] ) && ! in_array( $_REQUEST['comment_type'], wp_get_internal_comment_types(), true ) ) { $comment_type = $_REQUEST['comment_type']; } @@ -155,7 +155,7 @@ public function prepare_items() { 'number' => $number, 'post_id' => $post_id, 'type' => $comment_type, - 'type__not_in' => array( 'note' ), + 'type__not_in' => wp_get_internal_comment_types(), 'orderby' => $orderby, 'order' => $order, 'post_type' => $post_type, diff --git a/src/wp-admin/includes/comment.php b/src/wp-admin/includes/comment.php index ae5ba9d223350..4732d3fed0590 100644 --- a/src/wp-admin/includes/comment.php +++ b/src/wp-admin/includes/comment.php @@ -158,7 +158,16 @@ function get_pending_comments_num( $post_id ) { $post_id_array = array_map( 'intval', $post_id_array ); $post_id_in = "'" . implode( "', '", $post_id_array ) . "'"; - $pending = $wpdb->get_results( "SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM $wpdb->comments WHERE comment_post_ID IN ( $post_id_in ) AND comment_approved = '0' AND comment_type != 'note' GROUP BY comment_post_ID", ARRAY_A ); + $internal_comment_types = wp_get_internal_comment_types(); + $type_placeholders = implode( ', ', array_fill( 0, count( $internal_comment_types ), '%s' ) ); + $pending = $wpdb->get_results( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM $wpdb->comments WHERE comment_post_ID IN ( $post_id_in ) AND comment_approved = '0' AND comment_type NOT IN ( $type_placeholders ) GROUP BY comment_post_ID", + $internal_comment_types + ), + ARRAY_A + ); if ( $single ) { if ( empty( $pending ) ) { diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 5395997ecd0ef..e58cfdf401b33 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -294,6 +294,29 @@ function get_comment_statuses() { return $status; } +/** + * Retrieves the list of internal comment types. + * + * Internal comment types are used by core features (such as block notes + * and emoji reactions) and are not user-authored discussion comments. + * They should typically be excluded from front-end and admin comment + * listings, counts, and similar contexts that target user discussion. + * + * @since 7.0.0 + * + * @return string[] List of internal comment type slugs. + */ +function wp_get_internal_comment_types(): array { + /** + * Filters the list of internal comment types. + * + * @since 7.0.0 + * + * @param string[] $types List of internal comment type slugs. + */ + return (array) apply_filters( 'wp_internal_comment_types', array( 'note', 'reaction' ) ); +} + /** * Gets the default comment status for a post type. * @@ -2876,7 +2899,14 @@ function wp_update_comment_count_now( $post_id ) { $new = apply_filters( 'pre_wp_update_comment_count_now', null, $old, $post_id ); if ( is_null( $new ) ) { - $new = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type != 'note'", $post_id ) ); + $internal_comment_types = wp_get_internal_comment_types(); + $type_placeholders = implode( ', ', array_fill( 0, count( $internal_comment_types ), '%s' ) ); + $new = (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 ( $type_placeholders )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + array_merge( array( $post_id ), $internal_comment_types ) + ) + ); } else { $new = (int) $new; } diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index 54b78a028d745..966716077b7df 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -4349,10 +4349,12 @@ function is_avatar_comment_type( $comment_type ) { * @since 3.0.0 * * @since 6.9.0 The 'note' comment type was added. + * @since 7.0.0 The 'reaction' comment type was added. * - * @param array $types An array of content types. Default contains 'comment' and 'note'. + * @param array $types An array of content types. Default contains 'comment' and the + * internal comment types returned by wp_get_internal_comment_types(). */ - $allowed_comment_types = apply_filters( 'get_avatar_comment_types', array( 'comment', 'note' ) ); + $allowed_comment_types = apply_filters( 'get_avatar_comment_types', array_merge( array( 'comment' ), wp_get_internal_comment_types() ) ); return in_array( $comment_type, (array) $allowed_comment_types, true ); } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php index f462928847c77..630c7ddca29f1 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php @@ -123,7 +123,7 @@ public function register_routes() { * @return true|WP_Error True if the request has read access, error object otherwise. */ public function get_items_permissions_check( $request ) { - $is_note = 'note' === $request['type']; + $is_note = in_array( $request['type'], wp_get_internal_comment_types(), true ); $is_edit_context = 'edit' === $request['context']; $protected_params = array( 'author', 'author_exclude', 'author_email', 'type', 'status' ); $forbidden_params = array(); @@ -437,8 +437,8 @@ public function get_item_permissions_check( $request ) { return $comment; } - // Re-map edit context capabilities when requesting `note` type. - $edit_cap = 'note' === $comment->comment_type ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); + // Re-map edit context capabilities when requesting `note` or `reaction` type. + $edit_cap = in_array( $comment->comment_type, wp_get_internal_comment_types(), true ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( ...$edit_cap ) ) { return new WP_Error( 'rest_forbidden_context', @@ -497,7 +497,7 @@ public function get_item( $request ) { * @return true|WP_Error True if the request has access to create items, error object otherwise. */ public function create_item_permissions_check( $request ) { - $is_note = ! empty( $request['type'] ) && 'note' === $request['type']; + $is_note = ! empty( $request['type'] ) && in_array( $request['type'], wp_get_internal_comment_types(), true ); if ( ! is_user_logged_in() && $is_note ) { return new WP_Error( @@ -657,7 +657,7 @@ public function create_item( $request ) { } // Do not allow comments to be created with a non-core type. - if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note' ), true ) ) { + if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array_merge( array( 'comment' ), wp_get_internal_comment_types() ), true ) ) { return new WP_Error( 'rest_invalid_comment_type', __( 'Cannot create a comment with that type.' ), @@ -665,6 +665,57 @@ public function create_item( $request ) { ); } + // Validate reaction-specific constraints. + if ( ! empty( $request['type'] ) && 'reaction' === $request['type'] ) { + $valid_emojis = array( 'heart', 'celebration', 'smile', 'eyes', 'rocket' ); + + // Reaction content must be a valid emoji slug. + if ( empty( $request['content'] ) || ! in_array( $request['content'], $valid_emojis, true ) ) { + return new WP_Error( + 'rest_reaction_invalid_emoji', + __( 'Reaction content must be a valid emoji slug.' ), + array( 'status' => 400 ) + ); + } + + // Reaction parent must exist and be a note. + if ( empty( $request['parent'] ) ) { + return new WP_Error( + 'rest_reaction_parent_required', + __( 'Reactions must have a parent note.' ), + array( 'status' => 400 ) + ); + } + + $parent_comment = get_comment( $request['parent'] ); + if ( ! $parent_comment || 'note' !== $parent_comment->comment_type ) { + return new WP_Error( + 'rest_reaction_invalid_parent', + __( 'Reactions can only be added to notes.' ), + array( 'status' => 400 ) + ); + } + + // Enforce uniqueness: one emoji per user per note. + $existing = get_comments( + array( + 'comment_type' => 'reaction', + 'comment_parent' => $request['parent'], + 'user_id' => get_current_user_id(), + 'search' => $request['content'], + 'count' => true, + ) + ); + + if ( $existing > 0 ) { + return new WP_Error( + 'rest_reaction_duplicate', + __( 'You have already added this reaction.' ), + array( 'status' => 409 ) + ); + } + } + $prepared_comment = $this->prepare_item_for_database( $request ); if ( is_wp_error( $prepared_comment ) ) { return $prepared_comment; @@ -743,9 +794,9 @@ public function create_item( $request ) { ); } - // Don't check for duplicates or flooding for notes. + // Don't check for duplicates or flooding for notes or reactions. $prepared_comment['comment_approved'] = - 'note' === $prepared_comment['comment_type'] ? + in_array( $prepared_comment['comment_type'], wp_get_internal_comment_types(), true ) ? '1' : wp_allow_comment( $prepared_comment, true ); @@ -1305,7 +1356,7 @@ protected function prepare_links( $comment ) { } // Embedding children for notes requires `type` and `status` inheritance. - if ( isset( $links['children'] ) && 'note' === $comment->comment_type ) { + if ( isset( $links['children'] ) && in_array( $comment->comment_type, wp_get_internal_comment_types(), true ) ) { $args = array( 'parent' => $comment->comment_ID, 'type' => $comment->comment_type, @@ -1919,7 +1970,7 @@ protected function check_read_post_permission( $post, $request ) { * @return bool Whether the comment can be read. */ protected function check_read_permission( $comment, $request ) { - if ( 'note' !== $comment->comment_type && ! empty( $comment->comment_post_ID ) ) { + if ( ! in_array( $comment->comment_type, wp_get_internal_comment_types(), true ) && ! empty( $comment->comment_post_ID ) ) { $post = get_post( $comment->comment_post_ID ); if ( $post ) { if ( $this->check_read_post_permission( $post, $request ) && 1 === (int) $comment->comment_approved ) { @@ -2034,6 +2085,11 @@ protected function check_is_comment_content_allowed( $prepared_comment ) { return true; } + // Reactions always have content (the emoji slug), so allow them. + if ( isset( $check['comment_type'] ) && 'reaction' === $check['comment_type'] ) { + return true; + } + /* * Do not allow a comment to be created with missing or empty * comment_content. See wp_handle_comment_submission(). diff --git a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php index 9dbb1f244ccf8..34ec540400ab4 100644 --- a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php +++ b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php @@ -78,6 +78,20 @@ public function test_only_approved_regular_comments_are_counted() { 'comment_approved' => 1, ) ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_approved' => 0, + ) + ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_approved' => 1, + ) + ); $this->assertTrue( wp_update_comment_count_now( $post_id ) ); $this->assertSame( '1', get_comments_number( $post_id ) ); diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 8542bcd42af24..dacf272d34bdb 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4228,9 +4228,9 @@ public function test_get_items_type_arg_unauthenticated( $comment_type, $count ) $response = rest_get_server()->dispatch( $request ); // Individual comments using the /comments/ endpoint can be retrieved by - // unauthenticated users - except for the 'note' type which is restricted. + // unauthenticated users - except for the 'note' and 'reaction' types which are restricted. // See https://core.trac.wordpress.org/ticket/44157. - $this->assertSame( 'note' === $comment_type ? 401 : 200, $response->get_status(), 'Individual comment endpoint did not return the expected status' ); + $this->assertSame( in_array( $comment_type, array( 'note', 'reaction' ), true ) ? 401 : 200, $response->get_status(), 'Individual comment endpoint did not return the expected status' ); } } @@ -4245,6 +4245,279 @@ public function data_comment_type_provider() { 'annotation type' => array( 'annotation', 5 ), 'discussion type' => array( 'discussion', 9 ), 'note type' => array( 'note', 3 ), + 'reaction type' => array( 'reaction', 3 ), ); } + + /** + * @ticket 63191 + */ + public function test_create_reaction() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => 'heart', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + + $data = $response->get_data(); + $new_comment = get_comment( $data['id'] ); + $this->assertSame( 'heart', $new_comment->comment_content ); + $this->assertSame( 'reaction', $new_comment->comment_type ); + $this->assertSame( (string) $note_id, $new_comment->comment_parent ); + } + + /** + * @ticket 63191 + */ + public function test_create_reaction_invalid_parent() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'comment', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'Regular comment', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $comment_id, + 'content' => 'heart', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_reaction_invalid_parent', $response, 400 ); + } + + /** + * @ticket 63191 + */ + public function test_create_reaction_no_parent() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'content' => 'heart', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_reaction_parent_required', $response, 400 ); + } + + /** + * @ticket 63191 + */ + public function test_create_reaction_invalid_emoji() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => 'thumbsup', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_reaction_invalid_emoji', $response, 400 ); + } + + /** + * @ticket 63191 + */ + public function test_create_reaction_duplicate() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + // Create first reaction. + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'heart', + ) + ); + + // Attempt duplicate reaction. + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => 'heart', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_reaction_duplicate', $response, 409 ); + } + + /** + * @ticket 63191 + */ + public function test_create_different_reactions_on_same_note() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + // Create first reaction. + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => 'heart', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + + // Create second, different reaction. + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => 'rocket', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + } + + /** + * @ticket 63191 + */ + public function test_create_reaction_requires_login() { + wp_set_current_user( 0 ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => 'heart', + 'type' => 'reaction', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_login_required', $response, 401 ); + } }