From fe192435d594173bcc3c5240e31a8d7ca54ff68c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 18 Feb 2026 21:33:53 +0700 Subject: [PATCH 01/34] Editor: Add emoji reactions as a comment type for Notes. Introduce the `reaction` comment type to support emoji reactions on collaborative Notes, replacing the previous `_wp_note_reactions` meta approach. Changes include: - Add `reaction` to avatar comment types. - Exclude reactions from admin comment lists and comment counts. - Extend the REST API Comments Controller to handle reactions: permissions checks, validation (valid emoji slugs, parent must be a note, one emoji per user per note), auto-approval, and content allowed checks. - Add PHPUnit tests for reaction creation, validation, and counting. - Regenerate API fixtures. Props flavor flavor. See #63191. Co-Authored-By: Claude Opus 4.6 --- .../includes/class-wp-comments-list-table.php | 4 +- src/wp-admin/includes/comment.php | 2 +- src/wp-includes/comment.php | 2 +- src/wp-includes/link-template.php | 5 +- .../class-wp-rest-comments-controller.php | 74 ++++- .../tests/comment/wpUpdateCommentCountNow.php | 14 + .../rest-api/rest-comments-controller.php | 277 +++++++++++++++++- tests/qunit/fixtures/wp-api-generated.js | 154 ++++++++-- 8 files changed, 485 insertions(+), 47 deletions(-) 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 78d6215376569..42b57559154a1 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'], array( 'note', 'reaction' ), 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' => array( 'note', 'reaction' ), '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..1613de523c014 100644 --- a/src/wp-admin/includes/comment.php +++ b/src/wp-admin/includes/comment.php @@ -158,7 +158,7 @@ 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 ); + $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' AND comment_type != 'reaction' GROUP BY comment_post_ID", ARRAY_A ); if ( $single ) { if ( empty( $pending ) ) { diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 70d78ed33c848..424ae502b3b2f 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2875,7 +2875,7 @@ 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 ) ); + $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' AND comment_type != 'reaction'", $post_id ) ); } else { $new = (int) $new; } diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index beafac3226130..bc611850d0387 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -4349,10 +4349,11 @@ 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', 'note', and 'reaction'. */ - $allowed_comment_types = apply_filters( 'get_avatar_comment_types', array( 'comment', 'note' ) ); + $allowed_comment_types = apply_filters( 'get_avatar_comment_types', array( 'comment', 'note', 'reaction' ) ); 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 3f83504f8a3e5..567fc17c527ea 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'], array( 'note', 'reaction' ), 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, array( 'note', 'reaction' ), 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'], array( 'note', 'reaction' ), true ); if ( ! is_user_logged_in() && $is_note ) { return new WP_Error( @@ -649,7 +649,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( 'comment', 'note', 'reaction' ), true ) ) { return new WP_Error( 'rest_invalid_comment_type', __( 'Cannot create a comment with that type.' ), @@ -657,6 +657,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; @@ -735,9 +786,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'], array( 'note', 'reaction' ), true ) ? '1' : wp_allow_comment( $prepared_comment, true ); @@ -1297,7 +1348,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, array( 'note', 'reaction' ), true ) ) { $args = array( 'parent' => $comment->comment_ID, 'type' => $comment->comment_type, @@ -1911,7 +1962,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, array( 'note', 'reaction' ), 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 ) { @@ -2026,6 +2077,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 ); + } } diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 675e53b496673..f0776b58c9ca3 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -22,13 +22,7 @@ mockedApiResponse.Schema = { "wp-block-editor/v1", "wp-abilities/v1" ], - "authentication": { - "application-passwords": { - "endpoints": { - "authorization": "http://example.org/wp-admin/authorize-application.php" - } - } - }, + "authentication": [], "routes": { "/": { "namespace": "", @@ -4879,7 +4873,18 @@ mockedApiResponse.Schema = { "meta": { "description": "Meta fields.", "type": "object", - "properties": [], + "properties": { + "wp_pattern_sync_status": { + "type": "string", + "title": "", + "description": "", + "default": "", + "enum": [ + "partial", + "unsynced" + ] + } + }, "required": false }, "template": { @@ -5088,7 +5093,18 @@ mockedApiResponse.Schema = { "meta": { "description": "Meta fields.", "type": "object", - "properties": [], + "properties": { + "wp_pattern_sync_status": { + "type": "string", + "title": "", + "description": "", + "default": "", + "enum": [ + "partial", + "unsynced" + ] + } + }, "required": false }, "template": { @@ -5452,7 +5468,18 @@ mockedApiResponse.Schema = { "meta": { "description": "Meta fields.", "type": "object", - "properties": [], + "properties": { + "wp_pattern_sync_status": { + "type": "string", + "title": "", + "description": "", + "default": "", + "enum": [ + "partial", + "unsynced" + ] + } + }, "required": false }, "template": { @@ -9835,7 +9862,26 @@ mockedApiResponse.Schema = { "meta": { "description": "Meta fields.", "type": "object", - "properties": [], + "properties": { + "persisted_preferences": { + "type": "object", + "title": "", + "description": "", + "default": [], + "context": [ + "edit" + ], + "properties": { + "_modified": { + "description": "The date and time the preferences were updated.", + "type": "string", + "format": "date-time", + "readonly": false + } + }, + "additionalProperties": true + } + }, "required": false } } @@ -9973,7 +10019,26 @@ mockedApiResponse.Schema = { "meta": { "description": "Meta fields.", "type": "object", - "properties": [], + "properties": { + "persisted_preferences": { + "type": "object", + "title": "", + "description": "", + "default": [], + "context": [ + "edit" + ], + "properties": { + "_modified": { + "description": "The date and time the preferences were updated.", + "type": "string", + "format": "date-time", + "readonly": false + } + }, + "additionalProperties": true + } + }, "required": false } } @@ -10118,7 +10183,26 @@ mockedApiResponse.Schema = { "meta": { "description": "Meta fields.", "type": "object", - "properties": [], + "properties": { + "persisted_preferences": { + "type": "object", + "title": "", + "description": "", + "default": [], + "context": [ + "edit" + ], + "properties": { + "_modified": { + "description": "The date and time the preferences were updated.", + "type": "string", + "format": "date-time", + "readonly": false + } + }, + "additionalProperties": true + } + }, "required": false } } @@ -10572,7 +10656,18 @@ mockedApiResponse.Schema = { "meta": { "description": "Meta fields.", "type": "object", - "properties": [], + "properties": { + "_wp_note_status": { + "type": "string", + "title": "", + "description": "Note resolution status", + "default": "", + "enum": [ + "resolved", + "reopen" + ] + } + }, "required": false } } @@ -10719,7 +10814,18 @@ mockedApiResponse.Schema = { "meta": { "description": "Meta fields.", "type": "object", - "properties": [], + "properties": { + "_wp_note_status": { + "type": "string", + "title": "", + "description": "Note resolution status", + "default": "", + "enum": [ + "resolved", + "reopen" + ] + } + }, "required": false } } @@ -11128,18 +11234,6 @@ mockedApiResponse.Schema = { "closed" ], "required": false - }, - "site_logo": { - "title": "Logo", - "description": "Site logo.", - "type": "integer", - "required": false - }, - "site_icon": { - "title": "Icon", - "description": "Site icon.", - "type": "integer", - "required": false } } } @@ -14391,6 +14485,7 @@ mockedApiResponse.CommentsCollection = [ "96": "https://secure.gravatar.com/avatar/9ca51ced0b389ffbeba3d269c6d824be664c84fa1b35503282abdd302e1f417c?s=96&d=mm&r=g" }, "meta": { + "_wp_note_status": null, "meta_key": "meta_value" }, "_links": { @@ -14445,6 +14540,7 @@ mockedApiResponse.CommentModel = { "96": "https://secure.gravatar.com/avatar/9ca51ced0b389ffbeba3d269c6d824be664c84fa1b35503282abdd302e1f417c?s=96&d=mm&r=g" }, "meta": { + "_wp_note_status": null, "meta_key": "meta_value" } }; @@ -14467,7 +14563,5 @@ mockedApiResponse.settings = { "page_on_front": 0, "page_for_posts": 0, "default_ping_status": "open", - "default_comment_status": "open", - "site_logo": null, - "site_icon": 0 + "default_comment_status": "open" }; From b3b78f86c1c0a7c6b5435b3163f2faad09ed7b0b Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 24 Apr 2026 09:13:24 -0700 Subject: [PATCH 02/34] Tests: Regenerate wp-api-generated.js fixtures after trunk merge. Picks up new 'footnotes' meta registered on post types, plus site_logo and site_icon settings exposed via the REST API. Needed so 'git diff --exit-code' in the PHPUnit CI step passes after merging trunk into the backport branch. --- tests/qunit/fixtures/wp-api-generated.js | 154 +++++------------------ 1 file changed, 30 insertions(+), 124 deletions(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 2d3e87f1f6cac..1623eca0c0f47 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -22,7 +22,13 @@ mockedApiResponse.Schema = { "wp-block-editor/v1", "wp-abilities/v1" ], - "authentication": [], + "authentication": { + "application-passwords": { + "endpoints": { + "authorization": "http://example.org/wp-admin/authorize-application.php" + } + } + }, "routes": { "/": { "namespace": "", @@ -4873,18 +4879,7 @@ mockedApiResponse.Schema = { "meta": { "description": "Meta fields.", "type": "object", - "properties": { - "wp_pattern_sync_status": { - "type": "string", - "title": "", - "description": "", - "default": "", - "enum": [ - "partial", - "unsynced" - ] - } - }, + "properties": [], "required": false }, "template": { @@ -5093,18 +5088,7 @@ mockedApiResponse.Schema = { "meta": { "description": "Meta fields.", "type": "object", - "properties": { - "wp_pattern_sync_status": { - "type": "string", - "title": "", - "description": "", - "default": "", - "enum": [ - "partial", - "unsynced" - ] - } - }, + "properties": [], "required": false }, "template": { @@ -5468,18 +5452,7 @@ mockedApiResponse.Schema = { "meta": { "description": "Meta fields.", "type": "object", - "properties": { - "wp_pattern_sync_status": { - "type": "string", - "title": "", - "description": "", - "default": "", - "enum": [ - "partial", - "unsynced" - ] - } - }, + "properties": [], "required": false }, "template": { @@ -9862,26 +9835,7 @@ mockedApiResponse.Schema = { "meta": { "description": "Meta fields.", "type": "object", - "properties": { - "persisted_preferences": { - "type": "object", - "title": "", - "description": "", - "default": [], - "context": [ - "edit" - ], - "properties": { - "_modified": { - "description": "The date and time the preferences were updated.", - "type": "string", - "format": "date-time", - "readonly": false - } - }, - "additionalProperties": true - } - }, + "properties": [], "required": false } } @@ -10019,26 +9973,7 @@ mockedApiResponse.Schema = { "meta": { "description": "Meta fields.", "type": "object", - "properties": { - "persisted_preferences": { - "type": "object", - "title": "", - "description": "", - "default": [], - "context": [ - "edit" - ], - "properties": { - "_modified": { - "description": "The date and time the preferences were updated.", - "type": "string", - "format": "date-time", - "readonly": false - } - }, - "additionalProperties": true - } - }, + "properties": [], "required": false } } @@ -10183,26 +10118,7 @@ mockedApiResponse.Schema = { "meta": { "description": "Meta fields.", "type": "object", - "properties": { - "persisted_preferences": { - "type": "object", - "title": "", - "description": "", - "default": [], - "context": [ - "edit" - ], - "properties": { - "_modified": { - "description": "The date and time the preferences were updated.", - "type": "string", - "format": "date-time", - "readonly": false - } - }, - "additionalProperties": true - } - }, + "properties": [], "required": false } } @@ -10656,18 +10572,7 @@ mockedApiResponse.Schema = { "meta": { "description": "Meta fields.", "type": "object", - "properties": { - "_wp_note_status": { - "type": "string", - "title": "", - "description": "Note resolution status", - "default": "", - "enum": [ - "resolved", - "reopen" - ] - } - }, + "properties": [], "required": false } } @@ -10814,18 +10719,7 @@ mockedApiResponse.Schema = { "meta": { "description": "Meta fields.", "type": "object", - "properties": { - "_wp_note_status": { - "type": "string", - "title": "", - "description": "Note resolution status", - "default": "", - "enum": [ - "resolved", - "reopen" - ] - } - }, + "properties": [], "required": false } } @@ -11234,6 +11128,18 @@ mockedApiResponse.Schema = { "closed" ], "required": false + }, + "site_logo": { + "title": "Logo", + "description": "Site logo.", + "type": "integer", + "required": false + }, + "site_icon": { + "title": "Icon", + "description": "Site icon.", + "type": "integer", + "required": false } } } @@ -14569,7 +14475,6 @@ mockedApiResponse.CommentsCollection = [ "96": "https://secure.gravatar.com/avatar/9ca51ced0b389ffbeba3d269c6d824be664c84fa1b35503282abdd302e1f417c?s=96&d=mm&r=g" }, "meta": { - "_wp_note_status": null, "meta_key": "meta_value" }, "_links": { @@ -14624,7 +14529,6 @@ mockedApiResponse.CommentModel = { "96": "https://secure.gravatar.com/avatar/9ca51ced0b389ffbeba3d269c6d824be664c84fa1b35503282abdd302e1f417c?s=96&d=mm&r=g" }, "meta": { - "_wp_note_status": null, "meta_key": "meta_value" } }; @@ -14647,5 +14551,7 @@ mockedApiResponse.settings = { "page_on_front": 0, "page_for_posts": 0, "default_ping_status": "open", - "default_comment_status": "open" + "default_comment_status": "open", + "site_logo": null, + "site_icon": 0 }; From ec10e929b6b8617fc646d3efb143abbac779e2ef Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 28 Apr 2026 20:46:15 -0700 Subject: [PATCH 03/34] Comments: Centralize internal comment types via wp_get_internal_comment_types(). Per review feedback on PR #10930, introduce wp_get_internal_comment_types() in src/wp-includes/comment.php as the single source of truth for non-comment comment types ('note', 'reaction'). The new helper is filterable so future internal types can be added without touching every call site. Apply it across the existing 'note'/'reaction' guards: * WP_Comments_List_Table: comment_type filter and type__not_in. * get_pending_comments_num(): exclude internal types from pending counts. * wp_update_comment_count_now(): exclude internal types from approved counts. * WP_REST_Comments_Controller: get_items / permissions / prepare / links / duplicate-flood checks across multiple sites. * is_avatar_comment_type(): default avatar comment types. Props westonruter for the suggestion to centralize this list. --- .../includes/class-wp-comments-list-table.php | 4 +-- src/wp-admin/includes/comment.php | 11 ++++++- src/wp-includes/comment.php | 32 ++++++++++++++++++- src/wp-includes/link-template.php | 5 +-- .../class-wp-rest-comments-controller.php | 14 ++++---- 5 files changed, 53 insertions(+), 13 deletions(-) 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 f6402d5a272f5..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'] ) && ! in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) { + 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', 'reaction' ), + '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 1613de523c014..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' AND comment_type != 'reaction' 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 15e6a8babc6d2..34821cebdd255 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() { + /** + * Filters the list of internal comment types. + * + * @since 7.0.0 + * + * @param string[] $types List of internal comment type slugs. + */ + return 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' AND comment_type != 'reaction'", $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 f1ad1cbb786d3..966716077b7df 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -4351,9 +4351,10 @@ function is_avatar_comment_type( $comment_type ) { * @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', 'note', and 'reaction'. + * @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', 'reaction' ) ); + $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 cbba63cc9aab7..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 = in_array( $request['type'], array( 'note', 'reaction' ), true ); + $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(); @@ -438,7 +438,7 @@ public function get_item_permissions_check( $request ) { } // Re-map edit context capabilities when requesting `note` or `reaction` type. - $edit_cap = in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); + $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'] ) && in_array( $request['type'], array( 'note', 'reaction' ), true ); + $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', 'reaction' ), 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.' ), @@ -796,7 +796,7 @@ public function create_item( $request ) { // Don't check for duplicates or flooding for notes or reactions. $prepared_comment['comment_approved'] = - in_array( $prepared_comment['comment_type'], array( 'note', 'reaction' ), true ) ? + in_array( $prepared_comment['comment_type'], wp_get_internal_comment_types(), true ) ? '1' : wp_allow_comment( $prepared_comment, true ); @@ -1356,7 +1356,7 @@ protected function prepare_links( $comment ) { } // Embedding children for notes requires `type` and `status` inheritance. - if ( isset( $links['children'] ) && in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) ) { + 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, @@ -1970,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 ( ! in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) && ! 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 ) { From 7a83b58d384a832b6ef5441ec81cf82155b7b84b Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Tue, 28 Apr 2026 21:30:51 -0700 Subject: [PATCH 04/34] Apply suggestions from code review Co-authored-by: Weston Ruter --- src/wp-includes/comment.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 34821cebdd255..e58cfdf401b33 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -306,7 +306,7 @@ function get_comment_statuses() { * * @return string[] List of internal comment type slugs. */ -function wp_get_internal_comment_types() { +function wp_get_internal_comment_types(): array { /** * Filters the list of internal comment types. * @@ -314,7 +314,7 @@ function wp_get_internal_comment_types() { * * @param string[] $types List of internal comment type slugs. */ - return apply_filters( 'wp_internal_comment_types', array( 'note', 'reaction' ) ); + return (array) apply_filters( 'wp_internal_comment_types', array( 'note', 'reaction' ) ); } /** From f3d39d095d5186433e01b5b66d8d6a3eaa37ecb5 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 11 May 2026 08:42:59 -0700 Subject: [PATCH 05/34] Comments: Add wp_get_note_reaction_emojis() helper. Expose the curated reaction emoji list (heart, celebration, smile, eyes, rocket) via a filterable helper so REST validation and schema can share a single source. Mirrors gutenberg_get_note_reaction_emojis() from the Gutenberg notes reactions feature. See #63191. --- src/wp-includes/comment.php | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index e58cfdf401b33..f460f5e1d5ee0 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -317,6 +317,63 @@ function wp_get_internal_comment_types(): array { return (array) apply_filters( 'wp_internal_comment_types', array( 'note', 'reaction' ) ); } +/** + * Retrieves the list of curated emoji reactions allowed for note comments. + * + * Each entry is an associative array with: + * - `emoji` (string) The emoji character. + * - `label` (string) A human-readable label. + * - `value` (string) The slug used as the storage key in `comment_content`. + * + * Reactions submitted to the REST API may also use a lowercase + * hex-codepoint sequence (e.g. `1f44d`) to represent emojis outside the + * curated set; see WP_REST_Comments_Controller::create_item(). + * + * @since 7.0.0 + * + * @return array[] List of emoji definitions, each with `emoji`, `label`, + * and `value` keys. + */ +function wp_get_note_reaction_emojis(): array { + $default_emojis = array( + array( + 'emoji' => '❤️', + 'label' => __( 'Heart' ), + 'value' => 'heart', + ), + array( + 'emoji' => '🎉', + 'label' => __( 'Celebration' ), + 'value' => 'celebration', + ), + array( + 'emoji' => '😄', + 'label' => __( 'Smile' ), + 'value' => 'smile', + ), + array( + 'emoji' => '👀', + 'label' => __( 'Eyes' ), + 'value' => 'eyes', + ), + array( + 'emoji' => '🚀', + 'label' => __( 'Rocket' ), + 'value' => 'rocket', + ), + ); + + /** + * Filters the curated list of allowed emojis for note reactions. + * + * @since 7.0.0 + * + * @param array[] $emojis List of emoji definitions. Each item has + * `emoji`, `label`, and `value` keys. + */ + return (array) apply_filters( 'wp_note_reaction_emojis', $default_emojis ); +} + /** * Gets the default comment status for a post type. * From 8fec4c9b44fa39ec19f5bbeae208865f96fe5b5f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 11 May 2026 08:43:08 -0700 Subject: [PATCH 06/34] REST API: Expand reaction support in comments controller. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the reaction comment type handling with the latest Gutenberg notes reactions PR (WordPress/gutenberg#76767): - Accept emoji slugs as either a curated slug (heart, celebration, smile, eyes, rocket) or a lowercase hex-codepoint sequence (e.g. 1f44d for 👍 or 1f468-200d-1f4bb for 👨‍💻). Raw emoji bytes are rejected since the comments table is not guaranteed to be utf8mb4 across installs; clients normalize before submitting. - Scope the uniqueness check to active reactions only, so a user can re-add the same emoji after removing (trashing) it on the same note. - Point note's children link at reaction children, not at notes, so embedded children resolve to the reactions on the note. - Add a read-only reaction_emojis schema property exposing the allowed emoji list, so clients can discover accepted slugs via OPTIONS. - Add a reaction_summary field aggregating per-emoji counts on note responses, with reacted/my_reaction_id for the current user. - Pre-fetch reaction summaries in get_items() to avoid N+1 queries when listing many notes. See #63191. --- .../class-wp-rest-comments-controller.php | 263 ++++++++++++++++-- 1 file changed, 237 insertions(+), 26 deletions(-) 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 630c7ddca29f1..c6e66ba63e343 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 @@ -24,6 +24,17 @@ class WP_REST_Comments_Controller extends WP_REST_Controller { */ protected $meta; + /** + * Pre-fetched reaction summaries keyed by note comment ID. + * + * Populated by get_items() to avoid N+1 queries when listing notes + * with their reaction summaries. Reset after each get_items() call. + * + * @since 7.0.0 + * @var array|null + */ + protected $reaction_summaries = null; + /** * Constructor. * @@ -330,6 +341,28 @@ public function get_items( $request ) { if ( ! $is_head_request ) { $comments = array(); + /* + * When listing notes that include the reaction_summary field, + * pre-fetch all summaries in a single aggregated query to + * avoid an N+1 query in prepare_item_for_response(). + */ + $fields = $this->get_fields_for_response( $request ); + if ( + ! empty( $request['type'] ) && + 'note' === $request['type'] && + rest_is_field_included( 'reaction_summary', $fields ) + ) { + $note_ids = array(); + foreach ( $query_result as $comment ) { + if ( 'note' === $comment->comment_type ) { + $note_ids[] = (int) $comment->comment_ID; + } + } + if ( ! empty( $note_ids ) ) { + $this->prefetch_reaction_summaries( $note_ids ); + } + } + foreach ( $query_result as $comment ) { if ( ! $this->check_read_permission( $comment, $request ) ) { continue; @@ -338,6 +371,8 @@ public function get_items( $request ) { $data = $this->prepare_item_for_response( $comment, $request ); $comments[] = $this->prepare_response_for_collection( $data ); } + + $this->reaction_summaries = null; } $total_comments = (int) $query->found_comments; @@ -667,18 +702,7 @@ 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. + // Reaction parent must be specified. if ( empty( $request['parent'] ) ) { return new WP_Error( 'rest_reaction_parent_required', @@ -687,6 +711,7 @@ public function create_item( $request ) { ); } + // Reaction parent must exist and be a note. $parent_comment = get_comment( $request['parent'] ); if ( ! $parent_comment || 'note' !== $parent_comment->comment_type ) { return new WP_Error( @@ -696,23 +721,57 @@ public function create_item( $request ) { ); } - // Enforce uniqueness: one emoji per user per note. + /* + * Validate the reaction content. Two shapes are accepted: + * + * - A curated slug (e.g. `heart`) from wp_get_note_reaction_emojis(). + * - A lowercase hex-codepoint sequence joined by `-` (e.g. `1f44d` + * for 👍 or `1f468-200d-1f4bb` for 👨‍💻). + * + * Raw emoji bytes are rejected because the comments table is not + * guaranteed to be utf8mb4 across all WordPress installs; clients + * are expected to normalize before submitting. Variation selector + * U+FE0F is dropped on the client so visually-equivalent + * presentations collapse onto a single key. + */ + $valid_slugs = wp_list_pluck( wp_get_note_reaction_emojis(), 'value' ); + $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; + + $is_curated_slug = in_array( $emoji_slug, $valid_slugs, true ); + $is_hex_key = (bool) preg_match( '/^[0-9a-f]{2,6}(-[0-9a-f]{2,6}){0,15}$/', $emoji_slug ); + + if ( '' === $emoji_slug || ( ! $is_curated_slug && ! $is_hex_key ) ) { + return new WP_Error( + 'rest_reaction_invalid_emoji', + __( 'Reaction content must be a valid emoji slug.' ), + array( 'status' => 400 ) + ); + } + + /* + * Enforce uniqueness: one emoji per user per note. + * + * Scope to active (approved) reactions only — trashed reactions + * are invisible to the user and must not block re-adding the + * same emoji. + */ $existing = get_comments( array( - 'comment_type' => 'reaction', - 'comment_parent' => $request['parent'], - 'user_id' => get_current_user_id(), - 'search' => $request['content'], - 'count' => true, + 'parent' => $request['parent'], + 'user_id' => get_current_user_id(), + 'type' => 'reaction', + 'status' => 'approve', ) ); - if ( $existing > 0 ) { - return new WP_Error( - 'rest_reaction_duplicate', - __( 'You have already added this reaction.' ), - array( 'status' => 409 ) - ); + foreach ( $existing as $existing_reaction ) { + if ( wp_strip_all_tags( $existing_reaction->comment_content ) === $emoji_slug ) { + return new WP_Error( + 'rest_reaction_duplicate', + __( 'You have already added this reaction.' ), + array( 'status' => 409 ) + ); + } } } @@ -1263,6 +1322,20 @@ public function prepare_item_for_response( $item, $request ) { $data['meta'] = $this->meta->get_value( $comment->comment_ID, $request ); } + if ( in_array( 'reaction_summary', $fields, true ) && 'note' === $comment->comment_type ) { + $note_id = (int) $comment->comment_ID; + + if ( null !== $this->reaction_summaries && isset( $this->reaction_summaries[ $note_id ] ) ) { + $data['reaction_summary'] = $this->reaction_summaries[ $note_id ]; + } else { + // Single-item path (get_item or single create/update): query individually. + $this->prefetch_reaction_summaries( array( $note_id ) ); + $data['reaction_summary'] = $this->reaction_summaries[ $note_id ] ?? array(); + // Reset so subsequent unrelated calls do not see this entry. + $this->reaction_summaries = null; + } + } + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); @@ -1357,9 +1430,11 @@ protected function prepare_links( $comment ) { // Embedding children for notes requires `type` and `status` inheritance. if ( isset( $links['children'] ) && in_array( $comment->comment_type, wp_get_internal_comment_types(), true ) ) { - $args = array( + // Notes have reaction children; reactions don't have children of their own. + $child_type = 'note' === $comment->comment_type ? 'reaction' : $comment->comment_type; + $args = array( 'parent' => $comment->comment_ID, - 'type' => $comment->comment_type, + 'type' => $child_type, 'status' => 'all', ); @@ -1670,6 +1745,53 @@ public function get_item_schema() { 'readonly' => true, 'default' => 'comment', ), + 'reaction_emojis' => array( + 'description' => __( 'Allowed emoji reactions for notes.' ), + 'type' => 'array', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'emoji' => array( + 'description' => __( 'The emoji character.' ), + 'type' => 'string', + ), + 'label' => array( + 'description' => __( 'A human-readable label for the emoji.' ), + 'type' => 'string', + ), + 'value' => array( + 'description' => __( 'The slug used as the storage key.' ), + 'type' => 'string', + ), + ), + ), + 'default' => wp_get_note_reaction_emojis(), + ), + 'reaction_summary' => array( + 'description' => __( 'Aggregated reaction counts for this note, keyed by emoji slug.' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + 'additionalProperties' => array( + 'type' => 'object', + 'properties' => array( + 'count' => array( + 'description' => __( 'Total number of reactions with this emoji.' ), + 'type' => 'integer', + ), + 'reacted' => array( + 'description' => __( 'Whether the current user reacted with this emoji.' ), + 'type' => 'boolean', + ), + 'my_reaction_id' => array( + 'description' => __( "The current user's reaction comment ID, or 0 if not reacted." ), + 'type' => 'integer', + ), + ), + ), + ), ), ); @@ -1960,6 +2082,95 @@ protected function check_read_post_permission( $post, $request ) { return $result; } + /** + * Pre-fetches reaction summaries for a set of note IDs. + * + * Runs two aggregated queries (one for the per-emoji counts, one for the + * current user's own reactions) and stores the result in + * $this->reaction_summaries, keyed by note comment ID. This lets a + * batched note listing return reaction_summary for many notes without + * issuing a per-note query. + * + * @since 7.0.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param int[] $note_ids Array of note comment IDs. + */ + protected function prefetch_reaction_summaries( $note_ids ) { + global $wpdb; + + $this->reaction_summaries = array(); + + if ( empty( $note_ids ) ) { + return; + } + + $note_ids = array_map( 'intval', $note_ids ); + $current_user_id = get_current_user_id(); + $id_placeholders = implode( ',', array_fill( 0, count( $note_ids ), '%d' ) ); + + // Query 1: aggregated counts per emoji per note. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $counts = $wpdb->get_results( + $wpdb->prepare( + "SELECT comment_parent, comment_content, COUNT(*) AS reaction_count + FROM {$wpdb->comments} + WHERE comment_parent IN ( $id_placeholders ) + AND comment_type = %s + AND comment_approved = %s + GROUP BY comment_parent, comment_content", + ...array_merge( $note_ids, array( 'reaction', '1' ) ) + ) + ); + + // Query 2: the current user's own reaction IDs (only when logged in). + $my_reactions = array(); + if ( $current_user_id ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $user_rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT comment_ID, comment_parent, comment_content + FROM {$wpdb->comments} + WHERE comment_parent IN ( $id_placeholders ) + AND comment_type = %s + AND comment_approved = %s + AND user_id = %d", + ...array_merge( $note_ids, array( 'reaction', '1', $current_user_id ) ) + ) + ); + + if ( $user_rows ) { + foreach ( $user_rows as $row ) { + $key = (int) $row->comment_parent . ':' . wp_strip_all_tags( $row->comment_content ); + $my_reactions[ $key ] = (int) $row->comment_ID; + } + } + } + + // Initialize empty summaries for every requested note ID. + foreach ( $note_ids as $note_id ) { + $this->reaction_summaries[ $note_id ] = array(); + } + + if ( ! $counts ) { + return; + } + + foreach ( $counts as $row ) { + $note_id = (int) $row->comment_parent; + $slug = wp_strip_all_tags( $row->comment_content ); + $key = $note_id . ':' . $slug; + $my_reaction_id = $my_reactions[ $key ] ?? 0; + + $this->reaction_summaries[ $note_id ][ $slug ] = array( + 'count' => (int) $row->reaction_count, + 'reacted' => $my_reaction_id > 0, + 'my_reaction_id' => $my_reaction_id, + ); + } + } + /** * Checks if the comment can be read. * From b6923e7985e57aaf026864be7fc774adaaef4550 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 11 May 2026 08:43:14 -0700 Subject: [PATCH 07/34] REST API: Test expanded reaction support in comments controller. Add tests covering the updated reaction validation, summary, and schema behaviors: - Accept hex-codepoint emoji slugs (e.g. 1f468-200d-1f4bb). - Reject raw emoji bytes. - Allow re-adding a reaction after the previous one is trashed. - reaction_summary aggregates per-emoji counts with the current user's reacted state and reaction ID. - reaction_emojis schema property exposes the curated slug list. - A note's children link targets reactions, not nested notes. Update test_get_item_schema for the two new schema properties, and test_get_note_with_children_link to expect type=reaction for note children. See #63191. --- .../rest-api/rest-comments-controller.php | 262 +++++++++++++++++- 1 file changed, 258 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index dacf272d34bdb..60413db444d80 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -3310,7 +3310,7 @@ public function test_get_item_schema() { $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertCount( 17, $properties ); + $this->assertCount( 19, $properties ); $this->assertArrayHasKey( 'id', $properties ); $this->assertArrayHasKey( 'author', $properties ); $this->assertArrayHasKey( 'author_avatar_urls', $properties ); @@ -3326,6 +3326,8 @@ public function test_get_item_schema() { $this->assertArrayHasKey( 'meta', $properties ); $this->assertArrayHasKey( 'parent', $properties ); $this->assertArrayHasKey( 'post', $properties ); + $this->assertArrayHasKey( 'reaction_emojis', $properties ); + $this->assertArrayHasKey( 'reaction_summary', $properties ); $this->assertArrayHasKey( 'status', $properties ); $this->assertArrayHasKey( 'type', $properties ); @@ -4080,7 +4082,11 @@ public function data_note_status_provider() { /** * Test children link for note comment type. Based on test_get_comment_with_children_link. * + * Notes expose a `children` link that targets their reaction children + * (not nested notes), so embedded children resolve to reactions. + * * @ticket 64152 + * @ticket 63191 */ public function test_get_note_with_children_link() { $parent_comment_id = self::factory()->comment->create( @@ -4099,8 +4105,8 @@ public function test_get_note_with_children_link() { 'comment_parent' => $parent_comment_id, 'comment_post_ID' => self::$post_id, 'user_id' => self::$admin_id, - 'comment_type' => 'note', - 'comment_content' => 'First child note comment', + 'comment_type' => 'reaction', + 'comment_content' => 'heart', ) ); @@ -4131,7 +4137,7 @@ public function test_get_note_with_children_link() { // Verify the href attribute contains the expected status and type parameters. $this->assertStringContainsString( 'status=all', $children[0]['href'] ); - $this->assertStringContainsString( 'type=note', $children[0]['href'] ); + $this->assertStringContainsString( 'type=reaction', $children[0]['href'] ); } /** @@ -4520,4 +4526,252 @@ public function test_create_reaction_requires_login() { $response = rest_get_server()->dispatch( $request ); $this->assertErrorResponse( 'rest_comment_login_required', $response, 401 ); } + + /** + * A hex-codepoint sequence (e.g. `1f44d` for 👍) is accepted as a + * reaction slug, supporting emojis outside the curated set. + * + * @ticket 63191 + */ + public function test_create_reaction_accepts_hex_codepoint_slug() { + 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' => '1f468-200d-1f4bb', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + + $new_comment = get_comment( $response->get_data()['id'] ); + $this->assertSame( '1f468-200d-1f4bb', $new_comment->comment_content ); + } + + /** + * Raw emoji bytes must be rejected — clients are expected to normalize + * to a curated slug or hex-codepoint sequence before submitting. + * + * @ticket 63191 + */ + public function test_create_reaction_rejects_raw_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' => '👍', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_reaction_invalid_emoji', $response, 400 ); + } + + /** + * After trashing a reaction, the same user may re-add the same emoji + * to the same note. Trashed reactions are invisible and must not block + * re-adding. + * + * @ticket 63191 + */ + public function test_create_reaction_after_trashing_previous_one() { + 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', + ) + ); + + // Existing reaction in trash should not block re-adding. + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 'trash', + 'user_id' => self::$editor_id, + 'comment_content' => 'heart', + ) + ); + + $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() ); + } + + /** + * The note response exposes a `reaction_summary` field aggregating + * counts per emoji slug, plus per-user `reacted` and `my_reaction_id`. + * + * @ticket 63191 + */ + public function test_note_response_includes_reaction_summary() { + 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', + ) + ); + + $heart_id = 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', + ) + ); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$subscriber_id, + 'comment_content' => 'rocket', + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'reaction_summary', $data ); + $this->assertArrayHasKey( 'heart', $data['reaction_summary'] ); + $this->assertSame( 1, $data['reaction_summary']['heart']['count'] ); + $this->assertTrue( $data['reaction_summary']['heart']['reacted'] ); + $this->assertSame( $heart_id, $data['reaction_summary']['heart']['my_reaction_id'] ); + + $this->assertArrayHasKey( 'rocket', $data['reaction_summary'] ); + $this->assertSame( 1, $data['reaction_summary']['rocket']['count'] ); + $this->assertFalse( $data['reaction_summary']['rocket']['reacted'] ); + $this->assertSame( 0, $data['reaction_summary']['rocket']['my_reaction_id'] ); + } + + /** + * Comment schema exposes the curated reaction emoji list so clients + * can discover which slugs the server accepts. + * + * @ticket 63191 + */ + public function test_comment_schema_exposes_reaction_emojis() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comments' ); + $response = rest_get_server()->dispatch( $request ); + $schema = $response->get_data()['schema']; + + $this->assertArrayHasKey( 'reaction_emojis', $schema['properties'] ); + $slugs = wp_list_pluck( $schema['properties']['reaction_emojis']['default'], 'value' ); + $this->assertSame( array( 'heart', 'celebration', 'smile', 'eyes', 'rocket' ), $slugs ); + } + + /** + * The `children` link on a note response points at reaction children, + * not at notes — so embedded children resolve to reactions. + * + * @ticket 63191 + */ + public function test_note_children_link_targets_reactions() { + 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 a reaction child so the note exposes a children link. + 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', + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $links = $response->get_links(); + + $this->assertArrayHasKey( 'children', $links ); + $href = $links['children'][0]['href']; + $this->assertStringContainsString( 'type=reaction', $href ); + $this->assertStringNotContainsString( 'type=note', $href ); + } } From 7c53824370ea8cc7ae48f4f05dd238022baf9fdf Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 11 May 2026 08:50:50 -0700 Subject: [PATCH 08/34] Coding Standards: Fix alignment and PHPCS ignores in reaction support. - Fix double-space alignment warning in test file (line 4702). - Extend phpcs:ignore directives in prefetch_reaction_summaries() to cover WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, acknowledging PHPCS cannot count placeholders through the spread operator. See #63191. --- .../rest-api/endpoints/class-wp-rest-comments-controller.php | 4 ++-- tests/phpunit/tests/rest-api/rest-comments-controller.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 c6e66ba63e343..9952e319e2023 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 @@ -2111,7 +2111,7 @@ protected function prefetch_reaction_summaries( $note_ids ) { $id_placeholders = implode( ',', array_fill( 0, count( $note_ids ), '%d' ) ); // Query 1: aggregated counts per emoji per note. - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber $counts = $wpdb->get_results( $wpdb->prepare( "SELECT comment_parent, comment_content, COUNT(*) AS reaction_count @@ -2127,7 +2127,7 @@ protected function prefetch_reaction_summaries( $note_ids ) { // Query 2: the current user's own reaction IDs (only when logged in). $my_reactions = array(); if ( $current_user_id ) { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber $user_rows = $wpdb->get_results( $wpdb->prepare( "SELECT comment_ID, comment_parent, comment_content diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 60413db444d80..9ad0b4b3297c3 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4699,7 +4699,7 @@ public function test_note_response_includes_reaction_summary() { ) ); - $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ); $request->set_param( 'context', 'edit' ); $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); From f3b5af251be165d029e85287fa12634293d39ea3 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 15 May 2026 11:20:12 -0700 Subject: [PATCH 09/34] Editor: Address review feedback on note reactions backport. Apply review feedback from #10930: - Bump `@since 7.0.0` to `@since 7.1.0` on PR-introduced docblocks in comment.php, link-template.php, and class-wp-rest-comments-controller.php. - Remove the `wp_internal_comment_types` filter: as an internal helper, the list does not need to be filterable. - Apply `wp_get_internal_comment_types()` in `WP_Comment_Query` so all internal types (not just `note`) are excluded by default. - Exclude internal comment types from the `get_lastcommentmodified()` SQL queries so notes and reactions no longer affect the last modified date. - Move `wp_get_note_reaction_emojis()` into `WP_REST_Comments_Controller::get_note_reaction_emojis()` as a protected static method while the icon strategy is still in flux. - Simplify the reaction summary prefetch loop in `WP_REST_Comments_Controller::get_items()` with `wp_list_pluck`. --- src/wp-includes/class-wp-comment-query.php | 17 ++-- src/wp-includes/comment.php | 88 ++++--------------- src/wp-includes/link-template.php | 2 +- .../class-wp-rest-comments-controller.php | 64 +++++++++++--- 4 files changed, 83 insertions(+), 88 deletions(-) diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index cfabfd7e6b964..a7efe99cb5ed1 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -771,13 +771,16 @@ protected function get_comment_ids() { 'NOT IN' => (array) $this->query_vars['type__not_in'], ); - // Exclude the 'note' comment type, unless 'all' types or the 'note' type explicitly are requested. - if ( - ! in_array( 'all', $raw_types['IN'], true ) && - ! in_array( 'note', $raw_types['IN'], true ) && - ! in_array( 'note', $raw_types['NOT IN'], true ) - ) { - $raw_types['NOT IN'][] = 'note'; + // Exclude internal comment types, unless 'all' types or a specific internal type is explicitly requested. + if ( ! in_array( 'all', $raw_types['IN'], true ) ) { + foreach ( wp_get_internal_comment_types() as $internal_type ) { + if ( + ! in_array( $internal_type, $raw_types['IN'], true ) && + ! in_array( $internal_type, $raw_types['NOT IN'], true ) + ) { + $raw_types['NOT IN'][] = $internal_type; + } + } } $comment_types = array(); diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index f460f5e1d5ee0..edcf9cf1ef7c8 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -302,76 +302,12 @@ function get_comment_statuses() { * They should typically be excluded from front-end and admin comment * listings, counts, and similar contexts that target user discussion. * - * @since 7.0.0 + * @since 7.1.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' ) ); -} - -/** - * Retrieves the list of curated emoji reactions allowed for note comments. - * - * Each entry is an associative array with: - * - `emoji` (string) The emoji character. - * - `label` (string) A human-readable label. - * - `value` (string) The slug used as the storage key in `comment_content`. - * - * Reactions submitted to the REST API may also use a lowercase - * hex-codepoint sequence (e.g. `1f44d`) to represent emojis outside the - * curated set; see WP_REST_Comments_Controller::create_item(). - * - * @since 7.0.0 - * - * @return array[] List of emoji definitions, each with `emoji`, `label`, - * and `value` keys. - */ -function wp_get_note_reaction_emojis(): array { - $default_emojis = array( - array( - 'emoji' => '❤️', - 'label' => __( 'Heart' ), - 'value' => 'heart', - ), - array( - 'emoji' => '🎉', - 'label' => __( 'Celebration' ), - 'value' => 'celebration', - ), - array( - 'emoji' => '😄', - 'label' => __( 'Smile' ), - 'value' => 'smile', - ), - array( - 'emoji' => '👀', - 'label' => __( 'Eyes' ), - 'value' => 'eyes', - ), - array( - 'emoji' => '🚀', - 'label' => __( 'Rocket' ), - 'value' => 'rocket', - ), - ); - - /** - * Filters the curated list of allowed emojis for note reactions. - * - * @since 7.0.0 - * - * @param array[] $emojis List of emoji definitions. Each item has - * `emoji`, `label`, and `value` keys. - */ - return (array) apply_filters( 'wp_note_reaction_emojis', $default_emojis ); + return array( 'note', 'reaction' ); } /** @@ -424,6 +360,7 @@ function get_default_comment_status( $post_type = 'post', $comment_type = 'comme * @since 1.5.0 * @since 4.7.0 Replaced caching the modified date in a local static variable * with the Object Cache API. + * @since 7.1.0 Internal comment types are excluded from the query. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -441,17 +378,30 @@ function get_lastcommentmodified( $timezone = 'server' ) { return $comment_modified_date; } + // Exclude internal comment types (notes, reactions, etc.) from the lookup. + $internal_types = wp_get_internal_comment_types(); + if ( ! empty( $internal_types ) ) { + $placeholders = implode( ', ', array_fill( 0, count( $internal_types ), '%s' ) ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + $type_not_in = $wpdb->prepare( " AND comment_type NOT IN ( $placeholders )", $internal_types ); + } else { + $type_not_in = ''; + } + switch ( $timezone ) { case 'gmt': - $comment_modified_date = $wpdb->get_var( "SELECT comment_date_gmt FROM $wpdb->comments WHERE comment_approved = '1' ORDER BY comment_date_gmt DESC LIMIT 1" ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $comment_modified_date = $wpdb->get_var( "SELECT comment_date_gmt FROM $wpdb->comments WHERE comment_approved = '1'{$type_not_in} ORDER BY comment_date_gmt DESC LIMIT 1" ); break; case 'blog': - $comment_modified_date = $wpdb->get_var( "SELECT comment_date FROM $wpdb->comments WHERE comment_approved = '1' ORDER BY comment_date_gmt DESC LIMIT 1" ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $comment_modified_date = $wpdb->get_var( "SELECT comment_date FROM $wpdb->comments WHERE comment_approved = '1'{$type_not_in} ORDER BY comment_date_gmt DESC LIMIT 1" ); break; case 'server': $add_seconds_server = gmdate( 'Z' ); - $comment_modified_date = $wpdb->get_var( $wpdb->prepare( "SELECT DATE_ADD(comment_date_gmt, INTERVAL %s SECOND) FROM $wpdb->comments WHERE comment_approved = '1' ORDER BY comment_date_gmt DESC LIMIT 1", $add_seconds_server ) ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $comment_modified_date = $wpdb->get_var( $wpdb->prepare( "SELECT DATE_ADD(comment_date_gmt, INTERVAL %s SECOND) FROM $wpdb->comments WHERE comment_approved = '1'{$type_not_in} ORDER BY comment_date_gmt DESC LIMIT 1", $add_seconds_server ) ); break; } diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index 966716077b7df..90722de52ef34 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -4349,7 +4349,7 @@ 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. + * @since 7.1.0 The 'reaction' comment type was added. * * @param array $types An array of content types. Default contains 'comment' and the * internal comment types returned by wp_get_internal_comment_types(). 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 9952e319e2023..ca68a6534a7f4 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 @@ -30,11 +30,58 @@ class WP_REST_Comments_Controller extends WP_REST_Controller { * Populated by get_items() to avoid N+1 queries when listing notes * with their reaction summaries. Reset after each get_items() call. * - * @since 7.0.0 + * @since 7.1.0 * @var array|null */ protected $reaction_summaries = null; + /** + * Retrieves the curated list of emoji reactions allowed for note comments. + * + * Each entry is an associative array with: + * - `emoji` (string) The emoji character. + * - `label` (string) A human-readable label. + * - `value` (string) The slug used as the storage key in `comment_content`. + * + * Reactions submitted to the REST API may also use a lowercase + * hex-codepoint sequence (e.g. `1f44d`) to represent emojis outside the + * curated set; see create_item(). + * + * @since 7.1.0 + * + * @return array[] List of emoji definitions, each with `emoji`, `label`, + * and `value` keys. + */ + protected static function get_note_reaction_emojis(): array { + return array( + array( + 'emoji' => '❤️', + 'label' => __( 'Heart' ), + 'value' => 'heart', + ), + array( + 'emoji' => '🎉', + 'label' => __( 'Celebration' ), + 'value' => 'celebration', + ), + array( + 'emoji' => '😄', + 'label' => __( 'Smile' ), + 'value' => 'smile', + ), + array( + 'emoji' => '👀', + 'label' => __( 'Eyes' ), + 'value' => 'eyes', + ), + array( + 'emoji' => '🚀', + 'label' => __( 'Rocket' ), + 'value' => 'rocket', + ), + ); + } + /** * Constructor. * @@ -352,12 +399,7 @@ public function get_items( $request ) { 'note' === $request['type'] && rest_is_field_included( 'reaction_summary', $fields ) ) { - $note_ids = array(); - foreach ( $query_result as $comment ) { - if ( 'note' === $comment->comment_type ) { - $note_ids[] = (int) $comment->comment_ID; - } - } + $note_ids = array_map( 'intval', wp_list_pluck( $query_result, 'comment_ID' ) ); if ( ! empty( $note_ids ) ) { $this->prefetch_reaction_summaries( $note_ids ); } @@ -724,7 +766,7 @@ public function create_item( $request ) { /* * Validate the reaction content. Two shapes are accepted: * - * - A curated slug (e.g. `heart`) from wp_get_note_reaction_emojis(). + * - A curated slug (e.g. `heart`) from self::get_note_reaction_emojis(). * - A lowercase hex-codepoint sequence joined by `-` (e.g. `1f44d` * for 👍 or `1f468-200d-1f4bb` for 👨‍💻). * @@ -734,7 +776,7 @@ public function create_item( $request ) { * U+FE0F is dropped on the client so visually-equivalent * presentations collapse onto a single key. */ - $valid_slugs = wp_list_pluck( wp_get_note_reaction_emojis(), 'value' ); + $valid_slugs = wp_list_pluck( self::get_note_reaction_emojis(), 'value' ); $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; $is_curated_slug = in_array( $emoji_slug, $valid_slugs, true ); @@ -1767,7 +1809,7 @@ public function get_item_schema() { ), ), ), - 'default' => wp_get_note_reaction_emojis(), + 'default' => self::get_note_reaction_emojis(), ), 'reaction_summary' => array( 'description' => __( 'Aggregated reaction counts for this note, keyed by emoji slug.' ), @@ -2091,7 +2133,7 @@ protected function check_read_post_permission( $post, $request ) { * batched note listing return reaction_summary for many notes without * issuing a per-note query. * - * @since 7.0.0 + * @since 7.1.0 * * @global wpdb $wpdb WordPress database abstraction object. * From dd9b750d5148071b5eeaac422fbe198ce2886509 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 14 Jul 2026 09:02:12 -0700 Subject: [PATCH 10/34] REST API: Harden reaction validation and canonicalize stored slugs. Port the reaction validation hardening from the upstream Gutenberg PR: - Require the parent note to belong to the post the reaction targets. - Reject hex-codepoint slugs outside assignable Unicode (above U+10FFFF or in the UTF-16 surrogate range). - Store the validated, canonical slug rather than the raw request content, so markup can never split reaction_summary grouping. - Align the reaction error codes with the Gutenberg implementation (rest_comment_invalid_parent, rest_comment_invalid_reaction, rest_comment_duplicate_reaction) so behavior does not change for clients when the feature ships in core. See https://github.com/WordPress/gutenberg/pull/76767 --- .../class-wp-rest-comments-controller.php | 57 ++++++- .../rest-api/rest-comments-controller.php | 142 +++++++++++++++++- 2 files changed, 186 insertions(+), 13 deletions(-) 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 ca68a6534a7f4..47b58dd0b536e 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 @@ -742,13 +742,19 @@ public function create_item( $request ) { ); } + /* + * The canonical reaction slug, populated once validated below so the + * stored content matches what was validated (not the raw input). + */ + $reaction_slug = null; + // Validate reaction-specific constraints. if ( ! empty( $request['type'] ) && 'reaction' === $request['type'] ) { // Reaction parent must be specified. if ( empty( $request['parent'] ) ) { return new WP_Error( - 'rest_reaction_parent_required', - __( 'Reactions must have a parent note.' ), + 'rest_comment_invalid_parent', + __( 'A reaction must have a parent note.' ), array( 'status' => 400 ) ); } @@ -757,8 +763,17 @@ public function create_item( $request ) { $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.' ), + 'rest_comment_invalid_parent', + __( 'A reaction must be attached to a note.' ), + array( 'status' => 400 ) + ); + } + + // The parent note must belong to the post the reaction targets. + if ( ! empty( $request['post'] ) && (int) $parent_comment->comment_post_ID !== (int) $request['post'] ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction must be attached to a note on the same post.' ), array( 'status' => 400 ) ); } @@ -782,10 +797,25 @@ public function create_item( $request ) { $is_curated_slug = in_array( $emoji_slug, $valid_slugs, true ); $is_hex_key = (bool) preg_match( '/^[0-9a-f]{2,6}(-[0-9a-f]{2,6}){0,15}$/', $emoji_slug ); + /* + * A hex-shaped slug must still be made of assignable Unicode code + * points: reject anything above U+10FFFF or in the UTF-16 surrogate + * range (U+D800–U+DFFF). + */ + if ( $is_hex_key ) { + foreach ( explode( '-', $emoji_slug ) as $codepoint ) { + $value = hexdec( $codepoint ); + if ( $value > 0x10FFFF || ( $value >= 0xD800 && $value <= 0xDFFF ) ) { + $is_hex_key = false; + break; + } + } + } + if ( '' === $emoji_slug || ( ! $is_curated_slug && ! $is_hex_key ) ) { return new WP_Error( - 'rest_reaction_invalid_emoji', - __( 'Reaction content must be a valid emoji slug.' ), + 'rest_comment_invalid_reaction', + __( 'Invalid reaction emoji.' ), array( 'status' => 400 ) ); } @@ -809,12 +839,14 @@ public function create_item( $request ) { foreach ( $existing as $existing_reaction ) { if ( wp_strip_all_tags( $existing_reaction->comment_content ) === $emoji_slug ) { return new WP_Error( - 'rest_reaction_duplicate', - __( 'You have already added this reaction.' ), + 'rest_comment_duplicate_reaction', + __( 'You have already reacted with this emoji.' ), array( 'status' => 409 ) ); } } + + $reaction_slug = $emoji_slug; } $prepared_comment = $this->prepare_item_for_database( $request ); @@ -824,6 +856,15 @@ public function create_item( $request ) { $prepared_comment['comment_type'] = $request['type']; + /* + * Persist the validated, canonical reaction slug rather than the raw + * request content, so stored values stay consistent for grouping and + * counting (e.g. "heart" is stored as "heart"). + */ + if ( null !== $reaction_slug ) { + $prepared_comment['comment_content'] = $reaction_slug; + } + if ( ! isset( $prepared_comment['comment_content'] ) ) { $prepared_comment['comment_content'] = ''; } diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 9ad0b4b3297c3..6177238b34827 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4328,7 +4328,7 @@ public function test_create_reaction_invalid_parent() { ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_reaction_invalid_parent', $response, 400 ); + $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); } /** @@ -4353,7 +4353,7 @@ public function test_create_reaction_no_parent() { ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_reaction_parent_required', $response, 400 ); + $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); } /** @@ -4388,7 +4388,7 @@ public function test_create_reaction_invalid_emoji() { ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_reaction_invalid_emoji', $response, 400 ); + $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); } /** @@ -4436,7 +4436,7 @@ public function test_create_reaction_duplicate() { ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_reaction_duplicate', $response, 409 ); + $this->assertErrorResponse( 'rest_comment_duplicate_reaction', $response, 409 ); } /** @@ -4603,7 +4603,7 @@ public function test_create_reaction_rejects_raw_emoji() { ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_reaction_invalid_emoji', $response, 400 ); + $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); } /** @@ -4657,6 +4657,138 @@ public function test_create_reaction_after_trashing_previous_one() { $this->assertSame( 201, $response->get_status() ); } + /** + * A reaction whose parent note belongs to a different post is rejected. + * + * @ticket 63191 + */ + public function test_create_reaction_on_note_from_different_post() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $other_post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $other_post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'Note on another post', + ) + ); + + $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_comment_invalid_parent', $response, 400 ); + } + + /** + * A hex-shaped slug must be made of assignable Unicode code points: + * values above U+10FFFF or in the UTF-16 surrogate range are rejected. + * + * @ticket 63191 + * + * @dataProvider data_invalid_codepoint_slugs + * + * @param string $slug The invalid hex-codepoint slug to submit. + */ + public function test_create_reaction_rejects_invalid_codepoints( $slug ) { + 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' => $slug, + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); + } + + public function data_invalid_codepoint_slugs() { + return array( + 'above U+10FFFF' => array( 'ffffff' ), + 'lead surrogate U+D800' => array( 'd800' ), + 'trail surrogate U+DFFF' => array( 'dfff' ), + 'surrogate inside a sequence' => array( '1f44d-d9ab' ), + 'above U+10FFFF in a sequence' => array( '1f468-200d-110000' ), + ); + } + + /** + * The stored reaction content is the validated, canonical slug — markup + * around the slug must not reach the database, or `reaction_summary` + * grouping would split visually identical reactions. + * + * @ticket 63191 + */ + public function test_create_reaction_stores_canonical_slug() { + 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() ); + + $new_comment = get_comment( $response->get_data()['id'] ); + $this->assertSame( 'heart', $new_comment->comment_content ); + } + /** * The note response exposes a `reaction_summary` field aggregating * counts per emoji slug, plus per-user `reacted` and `my_reaction_id`. From 417c5bf9c121bcc36c1356efd9761679bda621ec Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 14 Jul 2026 09:02:26 -0700 Subject: [PATCH 11/34] REST API: Converge concurrent duplicate reactions to a single row. The pre-insert uniqueness check is not atomic, so two concurrent requests for the same user/note/emoji can both insert an approved row. After insert, keep the earliest matching reaction (lowest comment ID), delete any later duplicates, and repoint the response to the survivor if this request's own row lost the race. Every concurrent request applies the same rule, so all requests settle on the same row. Ported from the upstream Gutenberg PR. See https://github.com/WordPress/gutenberg/pull/76767 --- .../class-wp-rest-comments-controller.php | 36 ++++++++ .../rest-api/rest-comments-controller.php | 88 +++++++++++++++++++ 2 files changed, 124 insertions(+) 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 47b58dd0b536e..cf85768d036ae 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 @@ -993,6 +993,42 @@ public function create_item( $request ) { ); } + /* + * The pre-insert uniqueness check is not atomic, so two concurrent + * requests for the same user/note/emoji can both insert an approved + * row. Converge on a single row deterministically: keep the earliest + * matching reaction (lowest comment ID) and delete any later + * duplicates. Every concurrent request applies the same rule, so they + * all settle on the same surviving row. If this request's own row lost + * the race, repoint the response to the survivor. + */ + if ( null !== $reaction_slug ) { + $matching = get_comments( + array( + 'parent' => $request['parent'], + 'user_id' => get_current_user_id(), + 'type' => 'reaction', + 'status' => 'approve', + 'orderby' => 'comment_ID', + 'order' => 'ASC', + ) + ); + $duplicates = array(); + foreach ( $matching as $candidate ) { + if ( wp_strip_all_tags( $candidate->comment_content ) === $reaction_slug ) { + $duplicates[] = (int) $candidate->comment_ID; + } + } + + if ( count( $duplicates ) > 1 ) { + $survivor_id = array_shift( $duplicates ); + foreach ( $duplicates as $duplicate_id ) { + wp_delete_comment( $duplicate_id, true ); + } + $comment_id = $survivor_id; + } + } + if ( isset( $request['status'] ) ) { $this->handle_status_param( $request['status'], $comment_id ); } diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 6177238b34827..68f8ecef8842a 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4789,6 +4789,94 @@ public function test_create_reaction_stores_canonical_slug() { $this->assertSame( 'heart', $new_comment->comment_content ); } + /** + * The pre-insert uniqueness check is not atomic. Simulate a concurrent + * request winning the race — inserting the same reaction after this + * request's check but before its own insert — and assert the post-insert + * cleanup converges on a single surviving row. + * + * @ticket 63191 + */ + public function test_concurrent_duplicate_reaction_converges_to_single_row() { + 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', + ) + ); + + /* + * Insert a competing reaction while the request is mid-flight (after + * its pre-insert check, before its own insert). + */ + $injected = false; + $inject = function ( $prepared ) use ( $note_id, $post_id, &$injected ) { + if ( ! $injected && isset( $prepared['comment_type'] ) && 'reaction' === $prepared['comment_type'] ) { + $injected = true; + wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'reaction', + 'comment_content' => 'heart', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + ) + ); + } + return $prepared; + }; + add_filter( 'rest_pre_insert_comment', $inject ); + + try { + $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() ); + } finally { + remove_filter( 'rest_pre_insert_comment', $inject ); + } + + // Exactly one approved heart reaction should remain for this user/note. + $remaining = get_comments( + array( + 'parent' => $note_id, + 'user_id' => self::$editor_id, + 'type' => 'reaction', + 'status' => 'approve', + ) + ); + $hearts = array_values( + array_filter( + $remaining, + static function ( $comment ) { + return 'heart' === $comment->comment_content; + } + ) + ); + $this->assertCount( 1, $hearts, 'Concurrent duplicate reactions should converge to a single row.' ); + + // The response must reference the surviving (earliest) row. + $this->assertSame( (int) $hearts[0]->comment_ID, $response->get_data()['id'] ); + } + /** * The note response exposes a `reaction_summary` field aggregating * counts per emoji slug, plus per-user `reacted` and `my_reaction_id`. From df707e4b1c39b3dcf46b1daa52e58edb59b04be0 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 26 Aug 2026 11:18:48 -0700 Subject: [PATCH 12/34] Comments: Delete and trash a note's reactions along with the note. `wp_delete_comment()` reparents a deleted comment's children one level up rather than deleting them, and `wp_trash_comment()` cascades a trashed note only to its `note` children. Reactions therefore outlived the note they belonged to: approved rows, still carrying the reactor's identity, attached to nothing. A reaction only means anything as a child of its note, so cascade deletion, trashing and restoring to a note's reactions. Adds `wp_get_note_reaction_ids()`. Replies are covered because core trashes and deletes each one in turn. Props adamsilverstein. See #63191. --- src/wp-includes/comment.php | 58 +++++++++ tests/phpunit/tests/comment.php | 200 ++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index edcf9cf1ef7c8..0ac8509707c9e 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -310,6 +310,37 @@ function wp_get_internal_comment_types(): array { return array( 'note', 'reaction' ); } +/** + * Retrieves the IDs of a note's reaction comments. + * + * Reactions hang off a note as child comments, so they have to be trashed, + * restored and deleted along with it. + * + * @since 7.1.0 + * + * @param int|WP_Comment $comment_id Note comment ID or WP_Comment object. + * @param string $status Optional. Comment status to match. Default 'all'. + * @return int[] Reaction comment IDs, oldest first. Empty if the comment is not a note. + */ +function wp_get_note_reaction_ids( $comment_id, $status = 'all' ): array { + $comment = get_comment( $comment_id ); + + if ( ! $comment || 'note' !== $comment->comment_type ) { + return array(); + } + + return get_comments( + array( + 'parent' => $comment->comment_ID, + 'type' => 'reaction', + 'status' => $status, + 'fields' => 'ids', + 'orderby' => 'comment_ID', + 'order' => 'ASC', + ) + ); +} + /** * Gets the default comment status for a post type. * @@ -1528,6 +1559,8 @@ function wp_count_comments( $post_id = 0 ) { * post ID available. * * @since 2.0.0 + * @since 7.1.0 A note's reactions are deleted along with it, rather than + * being reparented. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -1558,6 +1591,15 @@ function wp_delete_comment( $comment_id, $force_delete = false ) { */ do_action( 'delete_comment', $comment->comment_ID, $comment ); + /* + * Delete a note's reactions rather than letting them be reparented below. + * A reaction only means anything attached to its note, and an orphaned one + * would keep the reactor's identity on a note that no longer exists. + */ + foreach ( wp_get_note_reaction_ids( $comment ) as $reaction_id ) { + wp_delete_comment( $reaction_id, true ); + } + // Move children up a level. $children = $wpdb->get_col( $wpdb->prepare( "SELECT comment_ID FROM $wpdb->comments WHERE comment_parent = %d", $comment->comment_ID ) ); if ( ! empty( $children ) ) { @@ -1608,6 +1650,7 @@ function wp_delete_comment( $comment_id, $force_delete = false ) { * * @since 2.9.0 * @since 6.9.0 Any child notes are deleted when deleting a note. + * @since 7.1.0 A note's reactions are trashed along with it. * * @param int|WP_Comment $comment_id Comment ID or WP_Comment object. * @return bool True on success, false on failure. @@ -1674,6 +1717,15 @@ function wp_trash_comment( $comment_id ) { */ do_action( 'trashed_comment', $comment->comment_ID, $comment ); + /* + * Trash a note's reactions with it, at any depth. The child-note + * cascade below trashes each reply in turn, which brings the replies' + * own reactions along through this same branch. + */ + foreach ( wp_get_note_reaction_ids( $comment, 'approve' ) as $reaction_id ) { + wp_trash_comment( $reaction_id ); + } + // For top level 'note' type comments, also trash children. if ( 'note' === $comment->comment_type && 0 === (int) $comment->comment_parent ) { $children = $comment->get_children( @@ -1703,6 +1755,7 @@ function wp_trash_comment( $comment_id ) { * Removes a comment from the Trash * * @since 2.9.0 + * @since 7.1.0 A note's reactions are restored along with it. * * @param int|WP_Comment $comment_id Comment ID or WP_Comment object. * @return bool True on success, false on failure. @@ -1744,6 +1797,11 @@ function wp_untrash_comment( $comment_id ) { */ do_action( 'untrashed_comment', $comment->comment_ID, $comment ); + // Restore the note's reactions alongside it. + foreach ( wp_get_note_reaction_ids( $comment, 'trash' ) as $reaction_id ) { + wp_untrash_comment( $reaction_id ); + } + return true; } diff --git a/tests/phpunit/tests/comment.php b/tests/phpunit/tests/comment.php index 11e78140f1020..af5aebd50c414 100644 --- a/tests/phpunit/tests/comment.php +++ b/tests/phpunit/tests/comment.php @@ -1897,6 +1897,206 @@ public function test_wp_trash_comment_only_top_level_notes_trigger_child_deletio $this->assertSame( '1', get_comment( $sibling_note )->comment_approved ); } + /** + * Creates an approved reaction on a note. + * + * @param int $note_id Parent note comment ID. + * @param string $slug Reaction storage slug. + * @return int Reaction comment ID. + */ + private function create_reaction_on_note( $note_id, $slug = 'heart' ) { + return self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => '1', + 'comment_content' => $slug, + ) + ); + } + + /** + * Tests that permanently deleting a note deletes its reactions. + * + * wp_delete_comment() reparents a deleted comment's children one level up. + * A reaction only means anything attached to its note, so without a cascade + * it would survive as an approved top-level row still carrying the + * reactor's identity. + * + * @ticket 63191 + * @covers ::wp_delete_comment + */ + public function test_wp_delete_comment_deletes_note_reactions() { + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_parent' => 0, + 'comment_approved' => '1', + ) + ); + + $reaction_1 = $this->create_reaction_on_note( $note_id ); + $reaction_2 = $this->create_reaction_on_note( $note_id, 'rocket' ); + + wp_delete_comment( $note_id, true ); + + $this->assertNull( get_comment( $reaction_1 ), 'The first reaction outlived its note.' ); + $this->assertNull( get_comment( $reaction_2 ), 'The second reaction outlived its note.' ); + $this->assertSame( + array(), + get_comments( + array( + 'post_id' => self::$post_id, + 'type' => 'reaction', + 'status' => 'all', + 'fields' => 'ids', + ) + ), + 'Reaction rows remained after the note was permanently deleted.' + ); + } + + /** + * Tests that deleting a note reply takes only that reply's reactions. + * + * @ticket 63191 + * @covers ::wp_delete_comment + */ + public function test_wp_delete_comment_deletes_note_reply_reactions() { + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_parent' => 0, + 'comment_approved' => '1', + ) + ); + + $reply_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_parent' => $note_id, + 'comment_approved' => '1', + ) + ); + + $note_reaction = $this->create_reaction_on_note( $note_id ); + $reply_reaction = $this->create_reaction_on_note( $reply_id ); + + wp_delete_comment( $reply_id, true ); + + $this->assertNull( get_comment( $reply_reaction ), "The reply's reaction outlived the reply." ); + $this->assertNotNull( get_comment( $note_reaction ), "The root note's reaction should be untouched." ); + } + + /** + * Tests that a regular comment's children are still reparented. + * + * The reaction cascade must not change how any other comment type behaves. + * + * @ticket 63191 + * @covers ::wp_delete_comment + */ + public function test_wp_delete_comment_still_reparents_non_note_children() { + $parent_comment = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'comment', + 'comment_parent' => 0, + 'comment_approved' => '1', + ) + ); + + $child_comment = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'comment', + 'comment_parent' => $parent_comment, + 'comment_approved' => '1', + ) + ); + + wp_delete_comment( $parent_comment, true ); + + $child = get_comment( $child_comment ); + $this->assertNotNull( $child, 'The child comment should survive its parent.' ); + $this->assertSame( '0', $child->comment_parent, 'The child comment should have moved up a level.' ); + } + + /** + * Tests that trashing and restoring a note carries its reactions along. + * + * Core cascades a trashed note to its `note` children only, so without this + * a reaction stays approved under a trashed note. + * + * @ticket 63191 + * @covers ::wp_trash_comment + * @covers ::wp_untrash_comment + */ + public function test_wp_trash_comment_trashes_and_restores_note_reactions() { + if ( ! EMPTY_TRASH_DAYS ) { + $this->markTestSkipped( 'Trash is disabled, so trashing permanently deletes.' ); + } + + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_parent' => 0, + 'comment_approved' => '1', + ) + ); + + $reaction_id = $this->create_reaction_on_note( $note_id ); + + wp_trash_comment( $note_id ); + $this->assertSame( 'trash', get_comment( $reaction_id )->comment_approved, 'The reaction stayed approved under a trashed note.' ); + + wp_untrash_comment( $note_id ); + $this->assertSame( '1', get_comment( $reaction_id )->comment_approved, 'The reaction was not restored with its note.' ); + } + + /** + * Tests that trashing a note reply carries that reply's reactions along. + * + * @ticket 63191 + * @covers ::wp_trash_comment + */ + public function test_wp_trash_comment_trashes_note_reply_reactions() { + if ( ! EMPTY_TRASH_DAYS ) { + $this->markTestSkipped( 'Trash is disabled, so trashing permanently deletes.' ); + } + + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_parent' => 0, + 'comment_approved' => '1', + ) + ); + + $reply_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_parent' => $note_id, + 'comment_approved' => '1', + ) + ); + + $reply_reaction = $this->create_reaction_on_note( $reply_id ); + + // Trashing the root cascades to the reply, which brings its reactions. + wp_trash_comment( $note_id ); + + $this->assertSame( 'trash', get_comment( $reply_id )->comment_approved, 'The reply was not trashed.' ); + $this->assertSame( 'trash', get_comment( $reply_reaction )->comment_approved, "The reply's reaction was not trashed." ); + } + /** * @ticket 61244 * From 4f1c947d9eed2ca49ff59054a4c68e909dc09323 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 26 Aug 2026 11:18:56 -0700 Subject: [PATCH 13/34] REST API: Restrict reaction writes to create and delete. Reactions are validated as a set on create - author, parent note, target post and canonical emoji slug - and the inherited generic update route re-validates none of it. Anyone able to edit the note's post could PATCH a reaction onto another user, move it to a note on a post they cannot edit, or store a duplicate or invalid slug. Reject updates outright; removing a reaction is a delete. Accept only slugs from the allowed emoji list. Hex codepoint keys were accepted but nothing shipping today can decode them back, so they would render as the raw storage key in place of the emoji and its label. Repoint the post-insert duplicate cleanup whenever any matching row survives, not only when this request still sees its own. A competing request running the same cleanup can delete this request's row first, leaving the response pointing at a row that no longer exists. Props adamsilverstein. See #63191. --- .../class-wp-rest-comments-controller.php | 62 ++-- .../rest-api/rest-comments-controller.php | 318 ++++++++++++++---- 2 files changed, 291 insertions(+), 89 deletions(-) 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 cf85768d036ae..6e6da9fdb1170 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 @@ -779,40 +779,17 @@ public function create_item( $request ) { } /* - * Validate the reaction content. Two shapes are accepted: - * - * - A curated slug (e.g. `heart`) from self::get_note_reaction_emojis(). - * - A lowercase hex-codepoint sequence joined by `-` (e.g. `1f44d` - * for 👍 or `1f468-200d-1f4bb` for 👨‍💻). - * - * Raw emoji bytes are rejected because the comments table is not - * guaranteed to be utf8mb4 across all WordPress installs; clients - * are expected to normalize before submitting. Variation selector - * U+FE0F is dropped on the client so visually-equivalent - * presentations collapse onto a single key. + * Validate the reaction content against the allowed emoji list. + * Only a slug from self::get_note_reaction_emojis() is accepted: + * it is the one value a client can resolve back to an emoji and a + * label. Raw emoji bytes are rejected because the comments table + * is not guaranteed to be utf8mb4 across all WordPress installs; + * clients submit the slug instead. */ $valid_slugs = wp_list_pluck( self::get_note_reaction_emojis(), 'value' ); $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; - $is_curated_slug = in_array( $emoji_slug, $valid_slugs, true ); - $is_hex_key = (bool) preg_match( '/^[0-9a-f]{2,6}(-[0-9a-f]{2,6}){0,15}$/', $emoji_slug ); - - /* - * A hex-shaped slug must still be made of assignable Unicode code - * points: reject anything above U+10FFFF or in the UTF-16 surrogate - * range (U+D800–U+DFFF). - */ - if ( $is_hex_key ) { - foreach ( explode( '-', $emoji_slug ) as $codepoint ) { - $value = hexdec( $codepoint ); - if ( $value > 0x10FFFF || ( $value >= 0xD800 && $value <= 0xDFFF ) ) { - $is_hex_key = false; - break; - } - } - } - - if ( '' === $emoji_slug || ( ! $is_curated_slug && ! $is_hex_key ) ) { + if ( ! in_array( $emoji_slug, $valid_slugs, true ) ) { return new WP_Error( 'rest_comment_invalid_reaction', __( 'Invalid reaction emoji.' ), @@ -1020,7 +997,13 @@ public function create_item( $request ) { } } - if ( count( $duplicates ) > 1 ) { + /* + * Repoint whenever any matching row survives, not only when this + * request still sees its own duplicate: a competing request may + * already have deleted this request's row, leaving a single + * survivor that is not `$comment_id`. + */ + if ( ! empty( $duplicates ) ) { $survivor_id = array_shift( $duplicates ); foreach ( $duplicates as $duplicate_id ) { wp_delete_comment( $duplicate_id, true ); @@ -1091,6 +1074,7 @@ public function create_item( $request ) { * Checks if a given REST request has access to update a comment. * * @since 4.7.0 + * @since 7.1.0 Reactions cannot be updated. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has access to update the item, error object otherwise. @@ -1101,6 +1085,22 @@ public function update_item_permissions_check( $request ) { return $comment; } + /* + * Reactions are immutable. create_item() validates the author, parent + * note, target post and canonical emoji slug as a set, and none of that + * is re-checked here. Allowing an update would let anyone who can edit + * the note's post reattribute a reaction to another user, move it to a + * note on a post they cannot edit, or store a duplicate or invalid + * slug. Removing a reaction is a delete. + */ + if ( 'reaction' === $comment->comment_type ) { + return new WP_Error( + 'rest_comment_update_not_allowed', + __( 'Reactions cannot be edited. Remove the reaction and add a new one instead.' ), + array( 'status' => 403 ) + ); + } + if ( ! $this->check_edit_permission( $comment ) ) { return new WP_Error( 'rest_cannot_edit', diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 68f8ecef8842a..85ab244e689f3 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4528,12 +4528,19 @@ public function test_create_reaction_requires_login() { } /** - * A hex-codepoint sequence (e.g. `1f44d` for 👍) is accepted as a - * reaction slug, supporting emojis outside the curated set. + * Only a slug from the allowed emoji list is accepted. + * + * A hex-codepoint sequence (e.g. `1f44d` for 👍) is the storage format for + * a picker that can decode it back into an emoji and a label. Nothing + * shipping today can, so a hex slug would render as its raw storage key. * * @ticket 63191 + * + * @dataProvider data_hex_codepoint_slugs + * + * @param string $slug The hex-codepoint slug to submit. */ - public function test_create_reaction_accepts_hex_codepoint_slug() { + public function test_create_reaction_rejects_hex_codepoint_slug( $slug ) { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); @@ -4554,7 +4561,7 @@ public function test_create_reaction_accepts_hex_codepoint_slug() { array( 'post' => $post_id, 'parent' => $note_id, - 'content' => '1f468-200d-1f4bb', + 'content' => $slug, 'type' => 'reaction', 'author' => self::$editor_id, ) @@ -4562,10 +4569,17 @@ public function test_create_reaction_accepts_hex_codepoint_slug() { ); $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 201, $response->get_status() ); + $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); + } - $new_comment = get_comment( $response->get_data()['id'] ); - $this->assertSame( '1f468-200d-1f4bb', $new_comment->comment_content ); + public function data_hex_codepoint_slugs() { + return array( + 'single codepoint' => array( '1f44d' ), + 'ZWJ sequence' => array( '1f468-200d-1f4bb' ), + 'assignable ASCII' => array( '41' ), + 'above U+10FFFF' => array( 'ffffff' ), + 'UTF-16 surrogate' => array( 'd800' ), + ); } /** @@ -4695,57 +4709,6 @@ public function test_create_reaction_on_note_from_different_post() { $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); } - /** - * A hex-shaped slug must be made of assignable Unicode code points: - * values above U+10FFFF or in the UTF-16 surrogate range are rejected. - * - * @ticket 63191 - * - * @dataProvider data_invalid_codepoint_slugs - * - * @param string $slug The invalid hex-codepoint slug to submit. - */ - public function test_create_reaction_rejects_invalid_codepoints( $slug ) { - 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' => $slug, - 'type' => 'reaction', - 'author' => self::$editor_id, - ) - ) - ); - - $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); - } - - public function data_invalid_codepoint_slugs() { - return array( - 'above U+10FFFF' => array( 'ffffff' ), - 'lead surrogate U+D800' => array( 'd800' ), - 'trail surrogate U+DFFF' => array( 'dfff' ), - 'surrogate inside a sequence' => array( '1f44d-d9ab' ), - 'above U+10FFFF in a sequence' => array( '1f468-200d-110000' ), - ); - } /** * The stored reaction content is the validated, canonical slug — markup @@ -4877,6 +4840,245 @@ static function ( $comment ) { $this->assertSame( (int) $hearts[0]->comment_ID, $response->get_data()['id'] ); } + /** + * The cleanup in create_item() must repoint to the surviving row even when + * a competing request has already deleted this request's own row - the + * losing side of the same race the test above covers from the winner. + * + * @ticket 63191 + */ + public function test_concurrent_cleanup_deleting_own_row_still_returns_survivor() { + 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', + ) + ); + + $survivor_id = 0; + $deleted = false; + + /* + * Stand in for the competing request's cleanup: it keeps the earliest + * row and deletes this request's later one, which lands first. + */ + $race = function ( $comment_id, $comment ) use ( &$survivor_id, &$deleted ) { + if ( ! $deleted && 'reaction' === $comment->comment_type && (int) $comment_id !== $survivor_id ) { + $deleted = true; + wp_delete_comment( $comment_id, true ); + } + }; + + /* + * Insert the competing row after this request's pre-insert uniqueness + * check has passed, so it takes the earlier ID. Arm the cleanup only + * once that row exists, so its own insert does not trigger it. + */ + $inject = function ( $prepared ) use ( $note_id, $post_id, $race, &$survivor_id ) { + if ( ! $survivor_id && isset( $prepared['comment_type'] ) && 'reaction' === $prepared['comment_type'] ) { + $survivor_id = wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'reaction', + 'comment_content' => 'heart', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + ) + ); + add_action( 'wp_insert_comment', $race, 10, 2 ); + } + return $prepared; + }; + add_filter( 'rest_pre_insert_comment', $inject ); + + try { + $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 ); + } finally { + remove_filter( 'rest_pre_insert_comment', $inject ); + remove_action( 'wp_insert_comment', $race, 10 ); + } + + $this->assertTrue( $deleted, "The race injection did not delete this request's row." ); + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( $survivor_id, $response->get_data()['id'], 'The response did not repoint to the surviving row.' ); + } + + /** + * Creates an approved reaction on a note, bypassing REST validation. + * + * @param int $post_id Post the parent note belongs to. + * @param int $note_id Parent note comment ID. + * @param int $user_id Reacting user ID. + * @param string $slug Reaction storage slug. + * @return int Reaction comment ID. + */ + private function create_reaction_for_update_tests( $post_id, $note_id, $user_id, $slug = 'heart' ) { + return self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'reaction', + 'comment_approved' => 1, + 'comment_content' => $slug, + 'user_id' => $user_id, + ) + ); + } + + /** + * Reactions are validated as a set on create - author, parent note, target + * post and canonical slug - and the generic update route re-validates none + * of it, so updating a reaction is not allowed at all. + * + * @ticket 63191 + */ + public function test_update_reaction_content_is_not_allowed() { + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $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', + ) + ); + $reaction_id = $this->create_reaction_for_update_tests( $post_id, $note_id, self::$editor_id ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $reaction_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'content' => 'rocket' ) ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_update_not_allowed', $response, 403 ); + $this->assertSame( 'heart', get_comment( $reaction_id )->comment_content ); + } + + /** + * The reactor's identity must not be reassignable through the update route. + * + * @ticket 63191 + */ + public function test_update_reaction_author_is_not_allowed() { + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $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', + ) + ); + $reaction_id = $this->create_reaction_for_update_tests( $post_id, $note_id, self::$author_id ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $reaction_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'author' => self::$editor_id ) ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_update_not_allowed', $response, 403 ); + $this->assertSame( (string) self::$author_id, get_comment( $reaction_id )->user_id ); + } + + /** + * A reaction must not be movable onto a note on a post the user cannot edit. + * + * @ticket 63191 + */ + public function test_update_reaction_cannot_move_to_note_on_another_post() { + $editable_post = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $other_post = self::factory()->post->create( array( 'post_author' => self::$admin_id ) ); + + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $editable_post, + 'comment_type' => 'note', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + $other_note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $other_post, + 'comment_type' => 'note', + 'comment_approved' => 1, + 'user_id' => self::$admin_id, + 'comment_content' => 'Other note', + ) + ); + + $reaction_id = $this->create_reaction_for_update_tests( $editable_post, $note_id, self::$editor_id ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $reaction_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'parent' => $other_note_id, + 'post' => $other_post, + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_update_not_allowed', $response, 403 ); + + $reaction = get_comment( $reaction_id ); + $this->assertSame( (string) $note_id, $reaction->comment_parent ); + $this->assertSame( (string) $editable_post, $reaction->comment_post_ID ); + } + + /** + * Only reactions are locked down; notes stay editable. + * + * @ticket 63191 + */ + public function test_update_note_is_still_allowed() { + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $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', + ) + ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $note_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'content' => 'Edited note' ) ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + } + /** * The note response exposes a `reaction_summary` field aggregating * counts per emoji slug, plus per-user `reacted` and `my_reaction_id`. From a892699077315d8d4e9d99be01913277109aebb3 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 26 Aug 2026 12:41:02 -0700 Subject: [PATCH 14/34] Comments: Target reactions at 7.2.0. Retarget every `@since` this change introduces, across the comment lifecycle helpers, the reactions REST surface and the note permalink handling. Props adamsilverstein. See #63191. --- src/wp-includes/comment.php | 12 ++++++------ src/wp-includes/link-template.php | 2 +- .../endpoints/class-wp-rest-comments-controller.php | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 0ac8509707c9e..0082f3c7e6044 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -302,7 +302,7 @@ function get_comment_statuses() { * They should typically be excluded from front-end and admin comment * listings, counts, and similar contexts that target user discussion. * - * @since 7.1.0 + * @since 7.2.0 * * @return string[] List of internal comment type slugs. */ @@ -316,7 +316,7 @@ function wp_get_internal_comment_types(): array { * Reactions hang off a note as child comments, so they have to be trashed, * restored and deleted along with it. * - * @since 7.1.0 + * @since 7.2.0 * * @param int|WP_Comment $comment_id Note comment ID or WP_Comment object. * @param string $status Optional. Comment status to match. Default 'all'. @@ -391,7 +391,7 @@ function get_default_comment_status( $post_type = 'post', $comment_type = 'comme * @since 1.5.0 * @since 4.7.0 Replaced caching the modified date in a local static variable * with the Object Cache API. - * @since 7.1.0 Internal comment types are excluded from the query. + * @since 7.2.0 Internal comment types are excluded from the query. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -1559,7 +1559,7 @@ function wp_count_comments( $post_id = 0 ) { * post ID available. * * @since 2.0.0 - * @since 7.1.0 A note's reactions are deleted along with it, rather than + * @since 7.2.0 A note's reactions are deleted along with it, rather than * being reparented. * * @global wpdb $wpdb WordPress database abstraction object. @@ -1650,7 +1650,7 @@ function wp_delete_comment( $comment_id, $force_delete = false ) { * * @since 2.9.0 * @since 6.9.0 Any child notes are deleted when deleting a note. - * @since 7.1.0 A note's reactions are trashed along with it. + * @since 7.2.0 A note's reactions are trashed along with it. * * @param int|WP_Comment $comment_id Comment ID or WP_Comment object. * @return bool True on success, false on failure. @@ -1755,7 +1755,7 @@ function wp_trash_comment( $comment_id ) { * Removes a comment from the Trash * * @since 2.9.0 - * @since 7.1.0 A note's reactions are restored along with it. + * @since 7.2.0 A note's reactions are restored along with it. * * @param int|WP_Comment $comment_id Comment ID or WP_Comment object. * @return bool True on success, false on failure. diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index 90722de52ef34..364859202a65c 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -4349,7 +4349,7 @@ function is_avatar_comment_type( $comment_type ) { * @since 3.0.0 * * @since 6.9.0 The 'note' comment type was added. - * @since 7.1.0 The 'reaction' comment type was added. + * @since 7.2.0 The 'reaction' comment type was added. * * @param array $types An array of content types. Default contains 'comment' and the * internal comment types returned by wp_get_internal_comment_types(). 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 6e6da9fdb1170..dbd4e4a559166 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 @@ -30,7 +30,7 @@ class WP_REST_Comments_Controller extends WP_REST_Controller { * Populated by get_items() to avoid N+1 queries when listing notes * with their reaction summaries. Reset after each get_items() call. * - * @since 7.1.0 + * @since 7.2.0 * @var array|null */ protected $reaction_summaries = null; @@ -47,7 +47,7 @@ class WP_REST_Comments_Controller extends WP_REST_Controller { * hex-codepoint sequence (e.g. `1f44d`) to represent emojis outside the * curated set; see create_item(). * - * @since 7.1.0 + * @since 7.2.0 * * @return array[] List of emoji definitions, each with `emoji`, `label`, * and `value` keys. @@ -1074,7 +1074,7 @@ public function create_item( $request ) { * Checks if a given REST request has access to update a comment. * * @since 4.7.0 - * @since 7.1.0 Reactions cannot be updated. + * @since 7.2.0 Reactions cannot be updated. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has access to update the item, error object otherwise. @@ -2210,7 +2210,7 @@ protected function check_read_post_permission( $post, $request ) { * batched note listing return reaction_summary for many notes without * issuing a per-note query. * - * @since 7.1.0 + * @since 7.2.0 * * @global wpdb $wpdb WordPress database abstraction object. * From c2bc4f71e33d02a2cf1b3dd06f360d1d2e18cc4c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 26 Aug 2026 12:51:30 -0700 Subject: [PATCH 15/34] REST API: Pin a reaction to the user who adds it. The reaction uniqueness check and the reaction summary both key on the current user, but nothing forced the stored row to belong to them. Passing `author` let anyone with 'moderate_comments' file a reaction against another user, and passing author details alone left `user_id` at 0; either way the row was invisible to the one-emoji-per-user-per-note check, so it could be added repeatedly and never removed. Refuse an `author` that names somebody else, and set the reaction's author fields from the current user regardless of what the request carried. Notes and comments are unaffected. Also adds coverage for removing a reaction, for the collection pre-fetch path, and for summaries spanning several users. --- .../class-wp-rest-comments-controller.php | 31 ++ .../rest-api/rest-comments-controller.php | 359 ++++++++++++++++++ 2 files changed, 390 insertions(+) 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 dbd4e4a559166..714c35499ee6c 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 @@ -626,6 +626,23 @@ public function create_item_permissions_check( $request ) { ); } + /* + * A reaction is always first-person. create_item() enforces one emoji per + * user per note against the current user, and update_item() refuses to + * reattribute one, so a reaction stored against somebody else would be a + * row the uniqueness check and the reaction summary can never see. + */ + if ( + ! empty( $request['type'] ) && 'reaction' === $request['type'] && + isset( $request['author'] ) && get_current_user_id() !== (int) $request['author'] + ) { + return new WP_Error( + 'rest_comment_invalid_author', + __( 'Sorry, you are not allowed to add a reaction on behalf of another user.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + if ( isset( $request['author_ip'] ) && ! current_user_can( 'moderate_comments' ) ) { if ( empty( $_SERVER['REMOTE_ADDR'] ) || $request['author_ip'] !== $_SERVER['REMOTE_ADDR'] ) { return new WP_Error( @@ -879,6 +896,20 @@ public function create_item( $request ) { $prepared_comment['comment_author_url'] = $user->user_url; } + /* + * Pin a reaction to the current user, whatever author details the request + * carried. Author fields alone leave `user_id` at 0, which the uniqueness + * check and the reaction summary both key on. + */ + if ( null !== $reaction_slug ) { + $user = wp_get_current_user(); + + $prepared_comment['user_id'] = $user->ID; + $prepared_comment['comment_author'] = $user->display_name; + $prepared_comment['comment_author_email'] = $user->user_email; + $prepared_comment['comment_author_url'] = $user->user_url; + } + // Honor the discussion setting that requires a name and email address of the comment author. if ( get_option( 'require_name_email' ) ) { if ( empty( $prepared_comment['comment_author'] ) || empty( $prepared_comment['comment_author_email'] ) ) { diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 85ab244e689f3..93a2bc20618f2 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -5196,4 +5196,363 @@ public function test_note_children_link_targets_reactions() { $this->assertStringContainsString( 'type=reaction', $href ); $this->assertStringNotContainsString( 'type=note', $href ); } + /** + * A reaction may only be added on the current user's own behalf. + * + * @ticket 63191 + */ + public function test_create_reaction_cannot_be_attributed_to_another_user() { + wp_set_current_user( self::$admin_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::$admin_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->assertErrorResponse( 'rest_comment_invalid_author', $response, 403 ); + $this->assertCount( + 0, + get_comments( + array( + 'parent' => $note_id, + 'type' => 'reaction', + 'status' => 'approve', + ) + ), + 'No reaction should have been stored.' + ); + } + + /** + * Author fields in the request must not detach a reaction from its user. + * + * A reaction stored with `user_id` 0 is invisible to the uniqueness check and + * to `reaction_summary`, so it could be added repeatedly and never removed. + * + * @ticket 63191 + */ + public function test_create_reaction_ignores_request_author_fields() { + 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', + ) + ); + + $body = wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => 'heart', + 'type' => 'reaction', + 'author_name' => 'Someone Else', + 'author_email' => 'someone@example.com', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( $body ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status() ); + + $data = $response->get_data(); + $reaction = get_comment( $data['id'] ); + $this->assertSame( (string) self::$editor_id, $reaction->user_id, 'The reaction should belong to the current user.' ); + $this->assertNotSame( 'someone@example.com', $reaction->comment_author_email ); + + // The uniqueness check can now see the stored reaction. + $duplicate = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $duplicate->add_header( 'Content-Type', 'application/json' ); + $duplicate->set_body( $body ); + + $this->assertErrorResponse( 'rest_comment_duplicate_reaction', rest_get_server()->dispatch( $duplicate ), 409 ); + } + + /** + * Removing a reaction takes it out of the note's summary. + * + * @ticket 63191 + */ + public function test_delete_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', + ) + ); + + $reaction_id = 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', + ) + ); + + $response = rest_get_server()->dispatch( new WP_REST_Request( 'DELETE', '/wp/v2/comments/' . $reaction_id ) ); + + $this->assertSame( 200, $response->get_status() ); + + $note = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ) ); + $summary = $note->get_data()['reaction_summary']; + $this->assertSame( array(), $summary, 'The removed reaction should no longer be summarized.' ); + } + + /** + * A user who cannot edit the note's post cannot remove a reaction on it. + * + * @ticket 63191 + */ + public function test_delete_reaction_requires_edit_permission() { + $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', + ) + ); + $reaction_id = 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', + ) + ); + + wp_set_current_user( self::$subscriber_id ); + + $response = rest_get_server()->dispatch( new WP_REST_Request( 'DELETE', '/wp/v2/comments/' . $reaction_id ) ); + + $this->assertErrorResponse( 'rest_cannot_delete', $response, 403 ); + $this->assertNotNull( get_comment( $reaction_id ) ); + } + + /** + * Listing notes returns each note's reaction summary without a per-note query. + * + * @ticket 63191 + */ + public function test_note_collection_includes_reaction_summary() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_ids = array(); + + for ( $i = 0; $i < 3; $i++ ) { + $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' => 'Note ' . $i, + ) + ); + $note_ids[] = $note_id; + + 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', + ) + ); + } + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + $request->set_param( 'type', 'note' ); + $request->set_param( 'post', $post_id ); + $request->set_param( 'context', 'edit' ); + + $queries_before = get_num_queries(); + $response = rest_get_server()->dispatch( $request ); + $queries_after = get_num_queries(); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertCount( 3, $data ); + + foreach ( $data as $note ) { + $this->assertArrayHasKey( 'reaction_summary', $note ); + $this->assertSame( 1, $note['reaction_summary']['heart']['count'] ); + $this->assertTrue( $note['reaction_summary']['heart']['reacted'] ); + } + + /* + * Summaries are pre-fetched in two aggregated queries for the whole + * collection. Pin a ceiling well under one query per note so a + * regression back to the N+1 path is caught. + */ + $this->assertLessThan( + $queries_before + 20, + $queries_after, + 'Listing notes should not run a reaction query per note.' + ); + } + + /** + * A note is not readable, and so neither is its reaction summary, without permission. + * + * @ticket 63191 + */ + public function test_reaction_summary_is_not_exposed_to_logged_out_users() { + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'heart', + ) + ); + + wp_set_current_user( 0 ); + + $response = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ) ); + + $this->assertErrorResponse( 'rest_cannot_read', $response, 401 ); + } + + /** + * Reactions from several users are counted together, and the current user's + * own row is the one reported back. + * + * @ticket 63191 + */ + public function test_reaction_summary_counts_reactions_from_multiple_users() { + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $their_reaction_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$admin_id, + 'comment_content' => 'heart', + ) + ); + $my_reaction_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'heart', + ) + ); + + wp_set_current_user( self::$editor_id ); + + $response = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ) ); + $summary = $response->get_data()['reaction_summary']; + + $this->assertSame( 2, $summary['heart']['count'], 'Both users should be counted under the same emoji.' ); + $this->assertTrue( $summary['heart']['reacted'] ); + $this->assertSame( $my_reaction_id, $summary['heart']['my_reaction_id'] ); + $this->assertNotSame( $their_reaction_id, $summary['heart']['my_reaction_id'] ); + } + + /** + * A trashed reaction drops out of the summary. + * + * @ticket 63191 + */ + public function test_reaction_summary_excludes_trashed_reactions() { + wp_set_current_user( self::$editor_id ); + + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + $reaction_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'heart', + ) + ); + + wp_trash_comment( $reaction_id ); + + $response = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ) ); + + $this->assertSame( array(), $response->get_data()['reaction_summary'] ); + } } From aae954541363cd22545f0a9ac358759d8c457100 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 26 Aug 2026 12:56:33 -0700 Subject: [PATCH 16/34] Comments: Delete a note's trashed reactions along with it. wp_get_note_reaction_ids() defaulted to the 'all' comment status, which in WP_Comment_Query means approved and pending only. A reaction the user had already removed is trashed rather than deleted, so it fell outside that default and wp_delete_comment() reparented it to the top level instead of deleting it, leaving an orphan carrying the reactor's identity. Default to 'any' and say so in the docblock. Also covers the functions this feature generalized from 'note' to wp_get_internal_comment_types() and had left untested: comment queries, comment counts, pending counts, the admin list table, avatar types, and the last comment modified date, which had no coverage at all. --- src/wp-admin/includes/comment.php | 1 + src/wp-includes/class-wp-comment-query.php | 1 + src/wp-includes/comment.php | 10 +- .../class-wp-rest-comments-controller.php | 5 +- tests/phpunit/tests/admin/includesComment.php | 64 ++++++++ .../tests/admin/wpCommentsListTable.php | 34 +++-- tests/phpunit/tests/comment.php | 141 ++++++++++++++++++ .../tests/comment/getLastCommentModified.php | 75 ++++++++++ .../tests/comment/isAvatarCommentType.php | 3 + tests/phpunit/tests/comment/query.php | 68 +++++++-- 10 files changed, 368 insertions(+), 34 deletions(-) diff --git a/src/wp-admin/includes/comment.php b/src/wp-admin/includes/comment.php index 4732d3fed0590..1e1f68b81cbff 100644 --- a/src/wp-admin/includes/comment.php +++ b/src/wp-admin/includes/comment.php @@ -139,6 +139,7 @@ function get_comment_to_edit( $id ) { * * @since 2.3.0 * @since 6.9.0 Exclude the 'note' comment type from the count. + * @since 7.2.0 Exclude every internal comment type from the count. * * @global wpdb $wpdb WordPress database abstraction object. * diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index a7efe99cb5ed1..8cf4076783532 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -537,6 +537,7 @@ public function get_comments() { * * @since 4.4.0 * @since 6.9.0 Excludes the 'note' comment type, unless 'all' or the 'note' types are requested. + * @since 7.2.0 Excludes every internal comment type, unless 'all' or that type is requested. * * @global wpdb $wpdb WordPress database abstraction object. * diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 0082f3c7e6044..77a1870741b92 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -319,10 +319,13 @@ function wp_get_internal_comment_types(): array { * @since 7.2.0 * * @param int|WP_Comment $comment_id Note comment ID or WP_Comment object. - * @param string $status Optional. Comment status to match. Default 'all'. + * @param string $status Optional. Comment status to match, as accepted by + * WP_Comment_Query. Note that 'all' covers only approved + * and pending comments, so trashed reactions need an + * explicit status. Default 'any'. * @return int[] Reaction comment IDs, oldest first. Empty if the comment is not a note. */ -function wp_get_note_reaction_ids( $comment_id, $status = 'all' ): array { +function wp_get_note_reaction_ids( $comment_id, $status = 'any' ): array { $comment = get_comment( $comment_id ); if ( ! $comment || 'note' !== $comment->comment_type ) { @@ -1594,7 +1597,8 @@ function wp_delete_comment( $comment_id, $force_delete = false ) { /* * Delete a note's reactions rather than letting them be reparented below. * A reaction only means anything attached to its note, and an orphaned one - * would keep the reactor's identity on a note that no longer exists. + * would keep the reactor's identity on a note that no longer exists. This + * covers every status: a reaction the user removed is trashed, not deleted. */ foreach ( wp_get_note_reaction_ids( $comment ) as $reaction_id ) { wp_delete_comment( $reaction_id, 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 714c35499ee6c..a59c9ebab1ca4 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 @@ -43,9 +43,8 @@ class WP_REST_Comments_Controller extends WP_REST_Controller { * - `label` (string) A human-readable label. * - `value` (string) The slug used as the storage key in `comment_content`. * - * Reactions submitted to the REST API may also use a lowercase - * hex-codepoint sequence (e.g. `1f44d`) to represent emojis outside the - * curated set; see create_item(). + * A reaction's `value` is the only content the REST API accepts, so this + * list is the whole set of reactions a note can carry. * * @since 7.2.0 * diff --git a/tests/phpunit/tests/admin/includesComment.php b/tests/phpunit/tests/admin/includesComment.php index ace0988570d9f..73a6d36fff2b4 100644 --- a/tests/phpunit/tests/admin/includesComment.php +++ b/tests/phpunit/tests/admin/includesComment.php @@ -90,4 +90,68 @@ public function test_should_respect_timezone_gmt() { public function test_invalid_timezone_should_fall_back_on_blog() { $this->assertSame( (string) self::$post_id, comment_exists( 1, '2014-05-06 12:00:00', 'not_a_valid_value' ) ); } + /** + * Internal comment types are not awaiting moderation, so they must not be + * counted as pending. + * + * @ticket 63191 + * + * @covers ::get_pending_comments_num + */ + public function test_get_pending_comments_num_excludes_internal_comment_types() { + $post_id = self::factory()->post->create(); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => '0', + ) + ); + + foreach ( wp_get_internal_comment_types() as $internal_type ) { + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => '0', + 'comment_type' => $internal_type, + ) + ); + } + + $this->assertSame( 1, (int) get_pending_comments_num( $post_id ) ); + } + + /** + * The array form of the count excludes internal comment types too. + * + * @ticket 63191 + * + * @covers ::get_pending_comments_num + */ + public function test_get_pending_comments_num_for_multiple_posts_excludes_internal_comment_types() { + $with_comment = self::factory()->post->create(); + $notes_only = self::factory()->post->create(); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $with_comment, + 'comment_approved' => '0', + ) + ); + + foreach ( wp_get_internal_comment_types() as $internal_type ) { + self::factory()->comment->create( + array( + 'comment_post_ID' => $notes_only, + 'comment_approved' => '0', + 'comment_type' => $internal_type, + ) + ); + } + + $counts = get_pending_comments_num( array( $with_comment, $notes_only ) ); + + $this->assertSame( 1, (int) $counts[ $with_comment ] ); + $this->assertSame( 0, (int) $counts[ $notes_only ] ); + } } diff --git a/tests/phpunit/tests/admin/wpCommentsListTable.php b/tests/phpunit/tests/admin/wpCommentsListTable.php index 185bc5bfa48b0..d974cf421ace4 100644 --- a/tests/phpunit/tests/admin/wpCommentsListTable.php +++ b/tests/phpunit/tests/admin/wpCommentsListTable.php @@ -215,27 +215,30 @@ public function test_get_views_should_return_views_by_default() { } /** - * Verify that the comments table never shows the note comment_type. + * Verify that the comments table never shows internal comment types. * * @ticket 64198 * @ticket 64474 + * @ticket 63191 * * @dataProvider data_comment_type * * @param string $comment_type The comment_type parameter value to test. */ - public function test_comments_list_table_does_not_show_note_comment_type( string $comment_type ) { + public function test_comments_list_table_does_not_show_internal_comment_types( string $comment_type ) { $post_id = self::factory()->post->create(); - self::factory()->comment->create( - array( - 'comment_post_ID' => $post_id, - 'comment_content' => 'This is a note.', - 'comment_type' => 'note', - 'comment_approved' => '1', - 'comment_date' => '2024-01-01 10:00:00', - 'comment_date_gmt' => '2024-01-01 10:00:00', - ) - ); + foreach ( wp_get_internal_comment_types() as $internal_type ) { + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_content' => 'This is a ' . $internal_type . '.', + 'comment_type' => $internal_type, + 'comment_approved' => '1', + 'comment_date' => '2024-01-01 10:00:00', + 'comment_date_gmt' => '2024-01-01 10:00:00', + ) + ); + } $regular_comment_id = self::factory()->comment->create( array( 'comment_post_ID' => $post_id, @@ -265,14 +268,15 @@ public function test_comments_list_table_does_not_show_note_comment_type( string } /** - * Data provider for test_comments_list_table_does_not_show_note_comment_type(). + * Data provider for test_comments_list_table_does_not_show_internal_comment_types(). * * @return array */ public function data_comment_type(): array { return array( - 'note type explicitly requested' => array( 'note' ), - 'all type requested' => array( 'all' ), + 'note type explicitly requested' => array( 'note' ), + 'reaction type explicitly requested' => array( 'reaction' ), + 'all type requested' => array( 'all' ), ); } } diff --git a/tests/phpunit/tests/comment.php b/tests/phpunit/tests/comment.php index af5aebd50c414..1920c9a8f301b 100644 --- a/tests/phpunit/tests/comment.php +++ b/tests/phpunit/tests/comment.php @@ -2097,6 +2097,147 @@ public function test_wp_trash_comment_trashes_note_reply_reactions() { $this->assertSame( 'trash', get_comment( $reply_reaction )->comment_approved, "The reply's reaction was not trashed." ); } + /** + * A reaction the user already removed is trashed, not deleted, so deleting + * its note must take it along too rather than leave it orphaned. + * + * @ticket 63191 + * + * @covers ::wp_delete_comment + */ + public function test_wp_delete_comment_deletes_trashed_note_reactions() { + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => '1', + ) + ); + + $approved = $this->create_reaction_on_note( $note_id ); + $trashed = $this->create_reaction_on_note( $note_id, 'rocket' ); + wp_trash_comment( $trashed ); + + wp_delete_comment( $note_id, true ); + + $this->assertNull( get_comment( $approved ), 'The approved reaction was not deleted.' ); + $this->assertNull( get_comment( $trashed ), 'The trashed reaction was left behind.' ); + } + + /** + * @ticket 63191 + * + * @covers ::wp_get_internal_comment_types + */ + public function test_wp_get_internal_comment_types() { + $types = wp_get_internal_comment_types(); + + $this->assertContains( 'note', $types ); + $this->assertContains( 'reaction', $types ); + $this->assertNotContains( 'comment', $types, 'Discussion comments are not an internal type.' ); + } + + /** + * @ticket 63191 + * + * @covers ::wp_get_note_reaction_ids + */ + public function test_wp_get_note_reaction_ids_returns_reactions_oldest_first() { + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => '1', + ) + ); + + $heart = $this->create_reaction_on_note( $note_id ); + $rocket = $this->create_reaction_on_note( $note_id, 'rocket' ); + + // A reply is a child of the note, but it is not a reaction. + self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_parent' => $note_id, + 'comment_approved' => '1', + ) + ); + + $this->assertSame( array( $heart, $rocket ), array_map( 'intval', wp_get_note_reaction_ids( $note_id ) ) ); + } + + /** + * @ticket 63191 + * + * @covers ::wp_get_note_reaction_ids + */ + public function test_wp_get_note_reaction_ids_filters_by_status() { + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => '1', + ) + ); + + $approved = $this->create_reaction_on_note( $note_id ); + $trashed = $this->create_reaction_on_note( $note_id, 'rocket' ); + wp_trash_comment( $trashed ); + + $this->assertSame( array( $approved ), array_map( 'intval', wp_get_note_reaction_ids( $note_id, 'approve' ) ), 'Only the approved reaction was expected.' ); + $this->assertSame( array( $trashed ), array_map( 'intval', wp_get_note_reaction_ids( $note_id, 'trash' ) ), 'Only the trashed reaction was expected.' ); + $this->assertCount( 2, wp_get_note_reaction_ids( $note_id ), 'Both reactions were expected by default.' ); + $this->assertSame( array( $approved, $trashed ), array_map( 'intval', wp_get_note_reaction_ids( $note_id ) ) ); + } + + /** + * Only notes carry reactions. + * + * @ticket 63191 + * + * @covers ::wp_get_note_reaction_ids + * + * @dataProvider data_wp_get_note_reaction_ids_non_note_comments + * + * @param string $comment_type The comment type to attach the reaction to. + */ + public function test_wp_get_note_reaction_ids_returns_empty_for_non_notes( $comment_type ) { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => $comment_type, + 'comment_approved' => '1', + ) + ); + + $this->create_reaction_on_note( $comment_id ); + + $this->assertSame( array(), wp_get_note_reaction_ids( $comment_id ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_wp_get_note_reaction_ids_non_note_comments() { + return array( + 'a discussion comment' => array( 'comment' ), + 'a pingback' => array( 'pingback' ), + 'another reaction' => array( 'reaction' ), + ); + } + + /** + * @ticket 63191 + * + * @covers ::wp_get_note_reaction_ids + */ + public function test_wp_get_note_reaction_ids_returns_empty_for_unknown_comment() { + $this->assertSame( array(), wp_get_note_reaction_ids( PHP_INT_MAX ) ); + } + /** * @ticket 61244 * diff --git a/tests/phpunit/tests/comment/getLastCommentModified.php b/tests/phpunit/tests/comment/getLastCommentModified.php index 03229ba350a9b..55b0c64ff74b7 100644 --- a/tests/phpunit/tests/comment/getLastCommentModified.php +++ b/tests/phpunit/tests/comment/getLastCommentModified.php @@ -137,4 +137,79 @@ public function test_cache_is_cleared_when_comment_is_trashed() { $this->assertSame( strtotime( '1998-01-01 10:00:00' ), strtotime( get_lastcommentmodified() ) ); $this->assertSame( strtotime( '1998-01-01 10:00:00' ), strtotime( wp_cache_get( 'lastcommentmodified:server', 'timeinfo' ) ) ); } + /** + * Internal comment types are not user-facing discussion, so they must not + * move the last comment modified date. + * + * @ticket 63191 + * + * @dataProvider data_internal_comment_types_are_excluded + * + * @param string $timezone Timezone argument to pass to get_lastcommentmodified(). + * @param string $expected Expected date. + */ + public function test_internal_comment_types_are_excluded( $timezone, $expected ) { + self::factory()->comment->create( + array( + 'comment_status' => 1, + 'comment_date' => '2000-01-01 11:00:00', + 'comment_date_gmt' => '2000-01-01 10:00:00', + ) + ); + + foreach ( wp_get_internal_comment_types() as $comment_type ) { + self::factory()->comment->create( + array( + 'comment_status' => 1, + 'comment_type' => $comment_type, + 'comment_date' => '2020-01-01 11:00:00', + 'comment_date_gmt' => '2020-01-01 10:00:00', + ) + ); + } + + $this->assertSame( strtotime( $expected ), strtotime( get_lastcommentmodified( $timezone ) ) ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_internal_comment_types_are_excluded() { + return array( + 'server timezone' => array( + 'timezone' => 'server', + 'expected' => '2000-01-01 10:00:00', + ), + 'blog timezone' => array( + 'timezone' => 'blog', + 'expected' => '2000-01-01 11:00:00', + ), + 'gmt timezone' => array( + 'timezone' => 'gmt', + 'expected' => '2000-01-01 10:00:00', + ), + ); + } + + /** + * With nothing but internal comment types stored there is no last modified date. + * + * @ticket 63191 + */ + public function test_only_internal_comment_types_returns_false() { + foreach ( wp_get_internal_comment_types() as $comment_type ) { + self::factory()->comment->create( + array( + 'comment_status' => 1, + 'comment_type' => $comment_type, + 'comment_date' => '2020-01-01 11:00:00', + 'comment_date_gmt' => '2020-01-01 10:00:00', + ) + ); + } + + $this->assertFalse( get_lastcommentmodified() ); + } } diff --git a/tests/phpunit/tests/comment/isAvatarCommentType.php b/tests/phpunit/tests/comment/isAvatarCommentType.php index aa91a6d6fe806..40d542ed693eb 100644 --- a/tests/phpunit/tests/comment/isAvatarCommentType.php +++ b/tests/phpunit/tests/comment/isAvatarCommentType.php @@ -40,6 +40,9 @@ public function data_is_avatar_comment_type() { array( '', false ), array( 'non-existing-comment-type', false ), array( 'comment', true ), + // Internal comment types are authored by a user, so they get an avatar. + array( 'note', true ), + array( 'reaction', true ), ); } diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index dc870a78ae494..3809168d32701 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5374,13 +5374,15 @@ public function test_query_does_not_have_leading_whitespace() { } /** - * Helper method to create standard test comments for note type exclusion tests. + * Helper method to create standard test comments for internal comment type + * exclusion tests. * * @since 6.9.0 + * @since 7.2.0 A 'reaction' comment is created alongside the 'note'. * - * @return array<'comment'|'pingback'|'note', int> Array of comments created. + * @return array<'comment'|'pingback'|'note'|'reaction', int> Array of comments created. */ - protected function create_note_type_test_comments(): array { + protected function create_internal_comment_type_test_comments(): array { return array( 'comment' => self::factory()->comment->create( array( @@ -5402,19 +5404,27 @@ protected function create_note_type_test_comments(): array { 'comment_type' => 'note', ) ), + 'reaction' => self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => '1', + 'comment_type' => 'reaction', + ) + ), ); } /** * @ticket 64145 + * @ticket 63191 * @covers WP_Comment_Query::get_comment_ids - * @dataProvider data_note_type_exclusion + * @dataProvider data_internal_comment_type_exclusion * * @param array $query_args Query arguments for WP_Comment_Query. * @param string[] $expected_types Expected comment types. */ - public function test_note_type_exclusion( array $query_args, array $expected_types ) { - $this->create_note_type_test_comments(); + public function test_internal_comment_type_exclusion( array $query_args, array $expected_types ) { + $this->create_internal_comment_type_test_comments(); $query = new WP_Comment_Query(); $found = $query->query( array_merge( $query_args, array( 'fields' => 'ids' ) ) ); @@ -5436,7 +5446,7 @@ static function ( int $comment_id ): string { * * @return array, expected_types: string[] }> */ - public function data_note_type_exclusion(): array { + public function data_internal_comment_type_exclusion(): array { return array( 'default query excludes note' => array( 'query_args' => array(), @@ -5448,7 +5458,7 @@ public function data_note_type_exclusion(): array { ), 'type all includes note' => array( 'query_args' => array( 'type' => 'all' ), - 'expected_types' => array( 'comment', 'pingback', 'note' ), + 'expected_types' => array( 'comment', 'pingback', 'note', 'reaction' ), ), 'explicit note type' => array( 'query_args' => array( 'type' => 'note' ), @@ -5470,17 +5480,34 @@ public function data_note_type_exclusion(): array { 'query_args' => array( 'type__not_in' => array( 'note' ) ), 'expected_types' => array( 'comment', 'pingback' ), ), + 'explicit reaction type' => array( + 'query_args' => array( 'type' => 'reaction' ), + 'expected_types' => array( 'reaction' ), + ), + 'type__in with reaction' => array( + 'query_args' => array( 'type__in' => array( 'reaction' ) ), + 'expected_types' => array( 'reaction' ), + ), + 'type__in with note and reaction' => array( + 'query_args' => array( 'type__in' => array( 'note', 'reaction' ) ), + 'expected_types' => array( 'note', 'reaction' ), + ), + 'type__not_in with reaction' => array( + 'query_args' => array( 'type__not_in' => array( 'reaction' ) ), + 'expected_types' => array( 'comment', 'pingback' ), + ), ); } /** * @ticket 64145 + * @ticket 63191 * @covers WP_Comment_Query::get_comment_ids */ - public function test_note_type_not_duplicated_in_type__not_in() { + public function test_internal_comment_types_not_duplicated_in_type__not_in() { global $wpdb; - $comments = $this->create_note_type_test_comments(); + $comments = $this->create_internal_comment_type_test_comments(); $query = new WP_Comment_Query(); $found = $query->query( @@ -5492,15 +5519,23 @@ public function test_note_type_not_duplicated_in_type__not_in() { $this->assertSameSets( array( $comments['comment'], $comments['pingback'] ), $found ); $this->assertNotContains( $comments['note'], $found ); - $note_count = substr_count( $wpdb->last_query, "'note'" ); - $this->assertSame( 1, $note_count, 'The note type should only appear once in the query' ); + $this->assertNotContains( $comments['reaction'], $found ); + + foreach ( wp_get_internal_comment_types() as $internal_type ) { + $this->assertSame( + 1, + substr_count( $wpdb->last_query, "'" . $internal_type . "'" ), + "The {$internal_type} type should only appear once in the query" + ); + } } /** * @ticket 64145 + * @ticket 63191 * @covers ::get_comment_count */ - public function test_get_comment_count_excludes_note_type() { + public function test_get_comment_count_excludes_internal_comment_types() { $post_id = self::factory()->post->create(); self::factory()->comment->create( @@ -5523,6 +5558,13 @@ public function test_get_comment_count_excludes_note_type() { 'comment_type' => 'note', ) ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => '1', + 'comment_type' => 'reaction', + ) + ); $counts = get_comment_count( $post_id ); From e8efed9ecff9d4654eb88882d4ef5f6c6ce30a8c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 26 Aug 2026 17:47:14 -0700 Subject: [PATCH 17/34] REST API: Accept hex codepoint reaction slugs again. A reaction stores either a curated slug from get_note_reaction_emojis() or a lowercase hex codepoint sequence. Acceptance of the latter was dropped while nothing shipping could decode one back into an emoji and a label, so a hex slug would have rendered as its raw storage key. The editor's full emoji picker is that decoder, and it submits this format for every pick outside the curated list, which the API now has to accept rather than reject with a 400. A hex slug is still bounded to assignable Unicode code points: anything above U+10FFFF or in the UTF-16 surrogate range is rejected, as is a sequence that is not lowercase. Props adamsilverstein. See #63191. --- .../class-wp-rest-comments-controller.php | 44 ++++++++--- .../rest-api/rest-comments-controller.php | 79 ++++++++++++++++--- 2 files changed, 102 insertions(+), 21 deletions(-) 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 a59c9ebab1ca4..fd1e28f926474 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 @@ -43,8 +43,9 @@ class WP_REST_Comments_Controller extends WP_REST_Controller { * - `label` (string) A human-readable label. * - `value` (string) The slug used as the storage key in `comment_content`. * - * A reaction's `value` is the only content the REST API accepts, so this - * list is the whole set of reactions a note can carry. + * Reactions submitted to the REST API may also use a lowercase + * hex-codepoint sequence (e.g. `1f44d`) to represent emojis outside the + * curated set; see create_item(). * * @since 7.2.0 * @@ -795,17 +796,42 @@ public function create_item( $request ) { } /* - * Validate the reaction content against the allowed emoji list. - * Only a slug from self::get_note_reaction_emojis() is accepted: - * it is the one value a client can resolve back to an emoji and a - * label. Raw emoji bytes are rejected because the comments table - * is not guaranteed to be utf8mb4 across all WordPress installs; - * clients submit the slug instead. + * Validate the reaction content. Two shapes are accepted: + * + * - A curated slug (e.g. `heart`) from self::get_note_reaction_emojis(). + * - A lowercase hex-codepoint sequence joined by `-` (e.g. `1f44d` + * for 👍 or `1f468-200d-1f4bb` for 👨‍💻), which is how a client + * offering a full emoji picker stores a pick outside the curated + * list. The client decodes the sequence back into the emoji. + * + * Raw emoji bytes are rejected because the comments table is not + * guaranteed to be utf8mb4 across all WordPress installs; clients + * are expected to normalize before submitting. Variation selector + * U+FE0F is dropped on the client so visually-equivalent + * presentations collapse onto a single key. */ $valid_slugs = wp_list_pluck( self::get_note_reaction_emojis(), 'value' ); $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; - if ( ! in_array( $emoji_slug, $valid_slugs, true ) ) { + $is_curated_slug = in_array( $emoji_slug, $valid_slugs, true ); + $is_hex_key = (bool) preg_match( '/^[0-9a-f]{2,6}(-[0-9a-f]{2,6}){0,15}$/', $emoji_slug ); + + /* + * A hex-shaped slug must still be made of assignable Unicode code + * points: reject anything above U+10FFFF or in the UTF-16 surrogate + * range (U+D800–U+DFFF). + */ + if ( $is_hex_key ) { + foreach ( explode( '-', $emoji_slug ) as $codepoint ) { + $value = hexdec( $codepoint ); + if ( $value > 0x10FFFF || ( $value >= 0xD800 && $value <= 0xDFFF ) ) { + $is_hex_key = false; + break; + } + } + } + + if ( '' === $emoji_slug || ( ! $is_curated_slug && ! $is_hex_key ) ) { return new WP_Error( 'rest_comment_invalid_reaction', __( 'Invalid reaction emoji.' ), diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 93a2bc20618f2..9340cdeb95b96 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4528,11 +4528,10 @@ public function test_create_reaction_requires_login() { } /** - * Only a slug from the allowed emoji list is accepted. - * - * A hex-codepoint sequence (e.g. `1f44d` for 👍) is the storage format for - * a picker that can decode it back into an emoji and a label. Nothing - * shipping today can, so a hex slug would render as its raw storage key. + * A hex-codepoint sequence (e.g. `1f44d` for 👍) is accepted as a + * reaction slug, supporting emojis outside the curated set. It is the + * storage format a client offering a full emoji picker submits, and the + * one it decodes back into the emoji. * * @ticket 63191 * @@ -4540,7 +4539,7 @@ public function test_create_reaction_requires_login() { * * @param string $slug The hex-codepoint slug to submit. */ - public function test_create_reaction_rejects_hex_codepoint_slug( $slug ) { + public function test_create_reaction_accepts_hex_codepoint_slug( $slug ) { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); @@ -4569,16 +4568,18 @@ public function test_create_reaction_rejects_hex_codepoint_slug( $slug ) { ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); + $this->assertSame( 201, $response->get_status() ); + + $new_comment = get_comment( $response->get_data()['id'] ); + $this->assertSame( $slug, $new_comment->comment_content ); } public function data_hex_codepoint_slugs() { return array( - 'single codepoint' => array( '1f44d' ), - 'ZWJ sequence' => array( '1f468-200d-1f4bb' ), - 'assignable ASCII' => array( '41' ), - 'above U+10FFFF' => array( 'ffffff' ), - 'UTF-16 surrogate' => array( 'd800' ), + 'single codepoint' => array( '1f44d' ), + 'ZWJ sequence' => array( '1f468-200d-1f4bb' ), + 'assignable ASCII' => array( '41' ), + 'skin-tone variant' => array( '270b-1f3ff' ), ); } @@ -4710,6 +4711,60 @@ public function test_create_reaction_on_note_from_different_post() { } + /** + * A hex-shaped slug must be made of assignable Unicode code points: + * values above U+10FFFF or in the UTF-16 surrogate range are rejected, + * as is a sequence that is not lowercase. + * + * @ticket 63191 + * + * @dataProvider data_invalid_codepoint_slugs + * + * @param string $slug The invalid hex-codepoint slug to submit. + */ + public function test_create_reaction_rejects_invalid_codepoints( $slug ) { + 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' => $slug, + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); + } + + public function data_invalid_codepoint_slugs() { + return array( + 'above U+10FFFF' => array( 'ffffff' ), + 'lead surrogate U+D800' => array( 'd800' ), + 'trail surrogate U+DFFF' => array( 'dfff' ), + 'surrogate inside a sequence' => array( '1f44d-d9ab' ), + 'above U+10FFFF in a sequence' => array( '1f468-200d-110000' ), + 'uppercase hex' => array( '1F44D' ), + ); + } + /** * The stored reaction content is the validated, canonical slug — markup * around the slug must not reach the database, or `reaction_summary` From 6f0a92854242b7ad9675d5b448ab4b99a0d74171 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 29 Sep 2026 11:11:59 -0700 Subject: [PATCH 18/34] Feeds: Keep reactions out of comment feeds. Reactions are stored approved, and the comment feed queries only excluded notes, so every reaction on a published post showed up in the site and post comment feeds along with the reacting editor's display name. The feed WHERE clauses now exclude every type from wp_get_internal_comment_types(), built in one helper so the two feed paths cannot drift apart again. See #63191. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UUkvzWUaoyh4ztL1iCcx7M --- src/wp-includes/class-wp-query.php | 30 +++++++++++-- tests/phpunit/tests/query/commentFeed.php | 51 +++++++++++++++++------ 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 513f0f702c61f..3eb1cbd93bb37 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2801,13 +2801,15 @@ public function get_posts() { // Comments feeds. if ( $this->is_comment_feed && ! $this->is_singular ) { + $internal_types_where = $this->get_comment_feed_internal_types_where(); + if ( $this->is_archive || $this->is_search ) { $cjoin = "JOIN {$wpdb->posts} ON ( {$wpdb->comments}.comment_post_ID = {$wpdb->posts}.ID ) $join "; - $cwhere = "WHERE comment_approved = '1' AND {$wpdb->comments}.comment_type != 'note' $where"; + $cwhere = "WHERE comment_approved = '1' AND $internal_types_where $where"; $cgroupby = "{$wpdb->comments}.comment_id"; } else { // Other non-singular, e.g. front. $cjoin = "JOIN {$wpdb->posts} ON ( {$wpdb->comments}.comment_post_ID = {$wpdb->posts}.ID )"; - $cwhere = "WHERE ( post_status = 'publish' OR ( post_status = 'inherit' AND post_type = 'attachment' ) ) AND comment_approved = '1' AND {$wpdb->comments}.comment_type != 'note'"; + $cwhere = "WHERE ( post_status = 'publish' OR ( post_status = 'inherit' AND post_type = 'attachment' ) ) AND comment_approved = '1' AND $internal_types_where"; $cgroupby = ''; } @@ -3466,7 +3468,7 @@ public function get_posts() { $cjoin = apply_filters_ref_array( 'comment_feed_join', array( '', &$this ) ); /** This filter is documented in wp-includes/class-wp-query.php */ - $cwhere = apply_filters_ref_array( 'comment_feed_where', array( "WHERE comment_post_ID = '{$this->posts[0]->ID}' AND comment_approved = '1' AND {$wpdb->comments}.comment_type != 'note'", &$this ) ); + $cwhere = apply_filters_ref_array( 'comment_feed_where', array( "WHERE comment_post_ID = '{$this->posts[0]->ID}' AND comment_approved = '1' AND " . $this->get_comment_feed_internal_types_where(), &$this ) ); /** This filter is documented in wp-includes/class-wp-query.php */ $cgroupby = apply_filters_ref_array( 'comment_feed_groupby', array( '', &$this ) ); @@ -3655,6 +3657,28 @@ public function get_posts() { return $this->posts; } + /** + * Builds the SQL condition that keeps internal comment types out of comment feeds. + * + * Notes and reactions are stored approved, so without this condition they + * would be published in the site and post comment feeds. + * + * @since 7.2.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @return string SQL condition, without a leading `AND`. + */ + private function get_comment_feed_internal_types_where() { + global $wpdb; + + $internal_types = wp_get_internal_comment_types(); + $placeholders = implode( ', ', array_fill( 0, count( $internal_types ), '%s' ) ); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + return $wpdb->prepare( "{$wpdb->comments}.comment_type NOT IN ( $placeholders )", $internal_types ); + } + /** * Sets up the amount of found posts and the number of pages (if limit clause was used) * for the current query. diff --git a/tests/phpunit/tests/query/commentFeed.php b/tests/phpunit/tests/query/commentFeed.php index d26bd3829c06a..df40c1ae76ac3 100644 --- a/tests/phpunit/tests/query/commentFeed.php +++ b/tests/phpunit/tests/query/commentFeed.php @@ -88,12 +88,17 @@ public function test_archive_comment_feed_invalid_cache() { /** * @ticket 65613 + * @ticket 63191 + * + * @dataProvider data_internal_comment_types + * + * @param string $comment_type Internal comment type. */ - public function test_main_comment_feed_should_exclude_notes(): void { - $note_id = self::factory()->comment->create( + public function test_main_comment_feed_should_exclude_internal_comment_types( string $comment_type ): void { + $internal_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_ids[0], - 'comment_type' => 'note', + 'comment_type' => $comment_type, 'comment_approved' => '1', ) ); @@ -110,18 +115,23 @@ public function test_main_comment_feed_should_exclude_notes(): void { $this->assertFalse( $q->is_singular() ); $comment_ids = array_map( 'intval', wp_list_pluck( $q->comments, 'comment_ID' ) ); - $this->assertNotContains( $note_id, $comment_ids, 'Comments feed should not include notes.' ); + $this->assertNotContains( $internal_id, $comment_ids, "Comments feed should not include '{$comment_type}' comments." ); $this->assertSame( 15, $q->comment_count, 'Comments feed should include all regular comments.' ); } /** * @ticket 65613 + * @ticket 63191 + * + * @dataProvider data_internal_comment_types + * + * @param string $comment_type Internal comment type. */ - public function test_archive_comment_feed_should_exclude_notes(): void { - $note_id = self::factory()->comment->create( + public function test_archive_comment_feed_should_exclude_internal_comment_types( string $comment_type ): void { + $internal_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_ids[0], - 'comment_type' => 'note', + 'comment_type' => $comment_type, 'comment_approved' => '1', ) ); @@ -139,21 +149,26 @@ public function test_archive_comment_feed_should_exclude_notes(): void { $this->assertTrue( $q->is_archive() ); $comment_ids = array_map( 'intval', wp_list_pluck( $q->comments, 'comment_ID' ) ); - $this->assertNotContains( $note_id, $comment_ids, 'Archive comments feed should not include notes.' ); + $this->assertNotContains( $internal_id, $comment_ids, "Archive comments feed should not include '{$comment_type}' comments." ); $this->assertSame( 15, $q->comment_count, 'Archive comments feed should include all regular comments.' ); } /** * @ticket 65613 + * @ticket 63191 + * + * @dataProvider data_internal_comment_types + * + * @param string $comment_type Internal comment type. */ - public function test_single_comment_feed_should_exclude_notes(): void { + public function test_single_comment_feed_should_exclude_internal_comment_types( string $comment_type ): void { $post = get_post( self::$post_ids[0] ); $this->assertInstanceOf( WP_Post::class, $post ); - $note_id = self::factory()->comment->create( + $internal_id = self::factory()->comment->create( array( 'comment_post_ID' => $post->ID, - 'comment_type' => 'note', + 'comment_type' => $comment_type, 'comment_approved' => '1', ) ); @@ -172,10 +187,22 @@ public function test_single_comment_feed_should_exclude_notes(): void { $this->assertTrue( $q->is_singular() ); $comment_ids = array_map( 'intval', wp_list_pluck( $q->comments, 'comment_ID' ) ); - $this->assertNotContains( $note_id, $comment_ids, 'Singular comments feed should not include notes.' ); + $this->assertNotContains( $internal_id, $comment_ids, "Singular comments feed should not include '{$comment_type}' comments." ); $this->assertSame( 5, $q->comment_count, 'Singular comments feed should include all regular comments.' ); } + /** + * Data provider. + * + * @return array + */ + public function data_internal_comment_types(): array { + return array( + 'note' => array( 'note' ), + 'reaction' => array( 'reaction' ), + ); + } + /** * @ticket 36904 */ From 28c8dae270b639bc9b52934fb27684ce9975c494 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 29 Sep 2026 11:13:10 -0700 Subject: [PATCH 19/34] Comments: Restore only the reactions trashed along with a note. Removing a reaction trashes it rather than deleting it, and restoring a note restored every trashed reaction under it. A reaction the user had taken back came back approved, next to any live one with the same emoji, breaking the one-emoji-per-user rule and inflating the reaction summary. Reactions trashed by the note cascade are now flagged, and restoring the note restores only those. The flag is cleared on any untrash, so it never outlives the trash round trip. See #63191. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UUkvzWUaoyh4ztL1iCcx7M --- src/wp-includes/comment.php | 20 +++++++++++++---- tests/phpunit/tests/comment.php | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index d7ce4f433ec3a..e0808287153a9 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -1798,9 +1798,15 @@ function wp_trash_comment( $comment_id ) { * Trash a note's reactions with it, at any depth. The child-note * cascade below trashes each reply in turn, which brings the replies' * own reactions along through this same branch. + * + * Each one is flagged so that restoring the note brings back only these + * reactions, and not ones the user had already removed, which are + * trashed too. */ foreach ( wp_get_note_reaction_ids( $comment, 'approve' ) as $reaction_id ) { - wp_trash_comment( $reaction_id ); + if ( wp_trash_comment( $reaction_id ) ) { + add_comment_meta( $reaction_id, '_wp_trash_meta_with_note', '1', true ); + } } // For top level 'note' type comments, also trash children. @@ -1832,7 +1838,7 @@ function wp_trash_comment( $comment_id ) { * Removes a comment from the Trash * * @since 2.9.0 - * @since 7.2.0 A note's reactions are restored along with it. + * @since 7.2.0 A note's reactions that were trashed along with it are restored. * * @param int|WP_Comment $comment_id Comment ID or WP_Comment object. * @return bool True on success, false on failure. @@ -1862,6 +1868,7 @@ function wp_untrash_comment( $comment_id ) { if ( wp_set_comment_status( $comment, $status ) ) { delete_comment_meta( $comment->comment_ID, '_wp_trash_meta_time' ); delete_comment_meta( $comment->comment_ID, '_wp_trash_meta_status' ); + delete_comment_meta( $comment->comment_ID, '_wp_trash_meta_with_note' ); /** * Fires immediately after a comment is restored from the Trash. @@ -1874,9 +1881,14 @@ function wp_untrash_comment( $comment_id ) { */ do_action( 'untrashed_comment', $comment->comment_ID, $comment ); - // Restore the note's reactions alongside it. + /* + * Restore the reactions that were trashed along with the note. Reactions + * the user removed before that stay in the trash. + */ foreach ( wp_get_note_reaction_ids( $comment, 'trash' ) as $reaction_id ) { - wp_untrash_comment( $reaction_id ); + if ( get_comment_meta( $reaction_id, '_wp_trash_meta_with_note', true ) ) { + wp_untrash_comment( $reaction_id ); + } } return true; diff --git a/tests/phpunit/tests/comment.php b/tests/phpunit/tests/comment.php index 4cd15b4478388..2c8d0342a6c1c 100644 --- a/tests/phpunit/tests/comment.php +++ b/tests/phpunit/tests/comment.php @@ -2171,6 +2171,44 @@ public function test_wp_trash_comment_trashes_and_restores_note_reactions() { $this->assertSame( '1', get_comment( $reaction_id )->comment_approved, 'The reaction was not restored with its note.' ); } + /** + * Tests that restoring a note leaves reactions the user had removed in the trash. + * + * Removing a reaction trashes it, so a note can have both a removed reaction + * and a live one with the same emoji. Only the live one was trashed along + * with the note, so only it should come back. + * + * @ticket 63191 + * @covers ::wp_trash_comment + * @covers ::wp_untrash_comment + */ + public function test_wp_untrash_comment_does_not_restore_removed_note_reactions() { + if ( ! EMPTY_TRASH_DAYS ) { + $this->markTestSkipped( 'Trash is disabled, so trashing permanently deletes.' ); + } + + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_parent' => 0, + 'comment_approved' => '1', + ) + ); + + $removed_id = $this->create_reaction_on_note( $note_id ); + wp_trash_comment( $removed_id ); + + $live_id = $this->create_reaction_on_note( $note_id ); + + wp_trash_comment( $note_id ); + wp_untrash_comment( $note_id ); + + $this->assertSame( 'trash', get_comment( $removed_id )->comment_approved, 'A reaction the user removed was restored with its note.' ); + $this->assertSame( '1', get_comment( $live_id )->comment_approved, 'The live reaction was not restored with its note.' ); + $this->assertSame( '', get_comment_meta( $live_id, '_wp_trash_meta_with_note', true ), 'The restored reaction kept its cascade flag.' ); + } + /** * Tests that trashing a note reply carries that reply's reactions along. * From 877868ba3b01db94f08d3eae100c7efa6908ab3c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 29 Sep 2026 11:14:19 -0700 Subject: [PATCH 20/34] REST API: Point a note's children link back at its reply notes. The link had been rewritten to filter on `type=reaction` for every note, so a note with replies advertised a link that never returned them, and the 6.9 test asserting `type=note` had been changed to match. That breaks any client that embeds a note's replies. The link is restored to its trunk form. Reactions are already exposed through `reaction_summary`, so they are also left out of the check that decides whether the link appears at all, which keeps a note with only reactions from advertising an empty children collection. See #63191. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UUkvzWUaoyh4ztL1iCcx7M --- .../class-wp-rest-comments-controller.php | 21 ++++++---- .../rest-api/rest-comments-controller.php | 41 ++++++++++++------- 2 files changed, 39 insertions(+), 23 deletions(-) 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 adc7f7cc2b7da..5102508e95d32 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 @@ -1604,12 +1604,17 @@ protected function prepare_links( $comment ) { ); } - // Only grab one comment to verify the comment has children. + /* + * Only grab one comment to verify the comment has children. Reactions are + * left out: they are summarized in `reaction_summary`, and counting them + * would advertise a `children` link on a note that has no replies. + */ $comment_children = $comment->get_children( array( - 'count' => true, - 'orderby' => 'none', - 'type' => 'all', + 'count' => true, + 'orderby' => 'none', + 'type' => 'all', + 'type__not_in' => array( 'reaction' ), ) ); @@ -1627,12 +1632,10 @@ protected function prepare_links( $comment ) { } // Embedding children for notes requires `type` and `status` inheritance. - if ( isset( $links['children'] ) && in_array( $comment->comment_type, wp_get_internal_comment_types(), true ) ) { - // Notes have reaction children; reactions don't have children of their own. - $child_type = 'note' === $comment->comment_type ? 'reaction' : $comment->comment_type; - $args = array( + if ( isset( $links['children'] ) && 'note' === $comment->comment_type ) { + $args = array( 'parent' => $comment->comment_ID, - 'type' => $child_type, + 'type' => $comment->comment_type, 'status' => 'all', ); diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 2dca65351e4dc..b10d1c061b201 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4137,11 +4137,7 @@ public function data_note_status_provider() { /** * Test children link for note comment type. Based on test_get_comment_with_children_link. * - * Notes expose a `children` link that targets their reaction children - * (not nested notes), so embedded children resolve to reactions. - * * @ticket 64152 - * @ticket 63191 */ public function test_get_note_with_children_link() { $parent_comment_id = self::factory()->comment->create( @@ -4160,8 +4156,8 @@ public function test_get_note_with_children_link() { 'comment_parent' => $parent_comment_id, 'comment_post_ID' => self::$post_id, 'user_id' => self::$admin_id, - 'comment_type' => 'reaction', - 'comment_content' => 'heart', + 'comment_type' => 'note', + 'comment_content' => 'First child note comment', ) ); @@ -4192,7 +4188,7 @@ public function test_get_note_with_children_link() { // Verify the href attribute contains the expected status and type parameters. $this->assertStringContainsString( 'status=all', $children[0]['href'] ); - $this->assertStringContainsString( 'type=reaction', $children[0]['href'] ); + $this->assertStringContainsString( 'type=note', $children[0]['href'] ); } /** @@ -5265,12 +5261,14 @@ public function test_comment_schema_exposes_reaction_emojis() { } /** - * The `children` link on a note response points at reaction children, - * not at notes — so embedded children resolve to reactions. + * A note's `children` link keeps targeting its reply notes once reactions exist. + * + * Reactions are summarized in `reaction_summary`, so they neither change + * where the link points nor make a note without replies advertise one. * * @ticket 63191 */ - public function test_note_children_link_targets_reactions() { + public function test_note_children_link_ignores_reactions() { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); @@ -5284,7 +5282,6 @@ public function test_note_children_link_targets_reactions() { ) ); - // Create a reaction child so the note exposes a children link. self::factory()->comment->create( array( 'comment_post_ID' => $post_id, @@ -5298,14 +5295,30 @@ public function test_note_children_link_targets_reactions() { $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ); $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertArrayNotHasKey( 'children', $response->get_links(), 'A note with only reactions should not advertise children.' ); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_parent' => $note_id, + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'Reply note', + ) + ); + $response = rest_get_server()->dispatch( $request ); $links = $response->get_links(); - $this->assertArrayHasKey( 'children', $links ); + $this->assertArrayHasKey( 'children', $links, 'A note with a reply should advertise children.' ); $href = $links['children'][0]['href']; - $this->assertStringContainsString( 'type=reaction', $href ); - $this->assertStringNotContainsString( 'type=note', $href ); + $this->assertStringContainsString( 'type=note', $href ); + $this->assertStringNotContainsString( 'type=reaction', $href ); } + /** * A reaction may only be added on the current user's own behalf. * From 82256de05cb094d86fcb372e2bd5e6e134d6c56c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 29 Sep 2026 11:14:57 -0700 Subject: [PATCH 21/34] REST API: Accept the object form of content when adding a reaction. The reaction slug was read by passing `content` straight to wp_strip_all_tags(), so a request using `content: { raw: 'heart' }`, which prepare_item_for_database() accepts for every other comment, raised a warning and was rejected as an invalid emoji. The slug is now read from `content` or `content.raw` the same way prepare_item_for_database() reads it, and trimmed to match what gets stored. See #63191. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UUkvzWUaoyh4ztL1iCcx7M --- .../class-wp-rest-comments-controller.php | 13 ++++++- .../rest-api/rest-comments-controller.php | 39 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) 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 5102508e95d32..756a1889fb56d 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 @@ -812,8 +812,19 @@ public function create_item( $request ) { * U+FE0F is dropped on the client so visually-equivalent * presentations collapse onto a single key. */ + /* + * Read the content the same two ways prepare_item_for_database() + * does, so `content` and `content.raw` are both accepted. + */ + $raw_content = ''; + if ( isset( $request['content'] ) && is_string( $request['content'] ) ) { + $raw_content = $request['content']; + } elseif ( isset( $request['content']['raw'] ) && is_string( $request['content']['raw'] ) ) { + $raw_content = $request['content']['raw']; + } + $valid_slugs = wp_list_pluck( self::get_note_reaction_emojis(), 'value' ); - $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; + $emoji_slug = trim( wp_strip_all_tags( $raw_content ) ); $is_curated_slug = in_array( $emoji_slug, $valid_slugs, true ); $is_hex_key = (bool) preg_match( '/^[0-9a-f]{2,6}(-[0-9a-f]{2,6}){0,15}$/', $emoji_slug ); diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index b10d1c061b201..47e2f2e26e459 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4858,6 +4858,45 @@ public function test_create_reaction_stores_canonical_slug() { $this->assertSame( 'heart', $new_comment->comment_content ); } + /** + * A reaction can be sent in the object form of `content`, like any comment. + * + * @ticket 63191 + */ + public function test_create_reaction_accepts_raw_content_object() { + 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' => array( 'raw' => 'rocket' ), + 'type' => 'reaction', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + + $new_comment = get_comment( $response->get_data()['id'] ); + $this->assertSame( 'rocket', $new_comment->comment_content ); + } + /** * The pre-insert uniqueness check is not atomic. Simulate a concurrent * request winning the race — inserting the same reaction after this From 86f307bd6ea128851dc09c48f3bc6964b77dc378 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 29 Sep 2026 11:15:58 -0700 Subject: [PATCH 22/34] REST API: Only create reactions in the approved status. Anyone who could edit the post could send `status: 'hold'` (or spam, or trash) with a new reaction. The uniqueness check, the race cleanup and the reaction summary only look at approved rows, so repeated requests stacked up hidden duplicates for one user that the API could not later fix, since reactions cannot be updated. Because the status was applied after the race cleanup, it could also land on a row that another request had created. A reaction request with any status other than approved is now rejected, and reactions skip the post-insert status handling entirely. See #63191. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UUkvzWUaoyh4ztL1iCcx7M --- .../class-wp-rest-comments-controller.php | 21 ++++- .../rest-api/rest-comments-controller.php | 77 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) 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 756a1889fb56d..673877624c46a 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 @@ -852,6 +852,20 @@ public function create_item( $request ) { ); } + /* + * A reaction is always approved. The uniqueness check, the race + * cleanup below and the reaction summary only see approved rows, so + * a reaction created in any other status could never be counted, + * deduplicated or, since reactions cannot be updated, fixed. + */ + if ( isset( $request['status'] ) && ! in_array( $request['status'], array( 'approve', 'approved', '1' ), true ) ) { + return new WP_Error( + 'rest_comment_invalid_status', + __( 'A reaction cannot be created with that status.' ), + array( 'status' => 400 ) + ); + } + /* * Enforce uniqueness: one emoji per user per note. * @@ -1081,7 +1095,12 @@ public function create_item( $request ) { } } - if ( isset( $request['status'] ) ) { + /* + * Reactions are inserted approved and their status was validated above. + * Skipping them here also keeps a request from changing the status of a + * row that the race cleanup above may have handed it from another request. + */ + if ( isset( $request['status'] ) && null === $reaction_slug ) { $this->handle_status_param( $request['status'], $comment_id ); } diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 47e2f2e26e459..e22f21533fc97 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4897,6 +4897,83 @@ public function test_create_reaction_accepts_raw_content_object() { $this->assertSame( 'rocket', $new_comment->comment_content ); } + /** + * A reaction can only be created approved. + * + * Held, spammed or trashed reactions are invisible to the uniqueness check + * and the reaction summary, so repeated requests could pile them up. + * + * @ticket 63191 + * + * @dataProvider data_create_reaction_status + * + * @param string $status Requested status. + * @param int $expected_status Expected HTTP status. + */ + public function test_create_reaction_only_allows_approved_status( $status, $expected_status ) { + 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', + 'status' => $status, + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + if ( 201 === $expected_status ) { + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( '1', get_comment( $response->get_data()['id'] )->comment_approved ); + } else { + $this->assertErrorResponse( 'rest_comment_invalid_status', $response, $expected_status ); + $this->assertSame( + array(), + get_comments( + array( + 'parent' => $note_id, + 'type' => 'reaction', + 'status' => 'any', + 'fields' => 'ids', + ) + ), + 'No reaction should have been stored.' + ); + } + } + + /** + * Data provider. + * + * @return array + */ + public function data_create_reaction_status() { + return array( + 'approve' => array( 'approve', 201 ), + 'hold' => array( 'hold', 400 ), + 'spam' => array( 'spam', 400 ), + 'trash' => array( 'trash', 400 ), + ); + } + /** * The pre-insert uniqueness check is not atomic. Simulate a concurrent * request winning the race — inserting the same reaction after this From fd48524c875098078bfdaa6fe18f6191fb1e7cd3 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 6 Oct 2026 12:27:29 +0200 Subject: [PATCH 23/34] REST API: Simplify the note reaction_summary entry shape. Replace the `reacted` boolean and `my_reaction_id` with a single `current_user_reaction` field holding the current user's reaction comment ID, or 0 when they have not reacted. The boolean was derivable from the ID, so one field keeps the response smaller and unambiguous, per review feedback on the Gutenberg companion PR. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012dpDEqJD9APinkNrMN7Kem --- .../class-wp-rest-comments-controller.php | 22 +++++++------------ .../rest-api/rest-comments-controller.php | 16 ++++++-------- 2 files changed, 15 insertions(+), 23 deletions(-) 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 673877624c46a..9235dd8327c9f 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 @@ -2008,16 +2008,12 @@ public function get_item_schema() { 'additionalProperties' => array( 'type' => 'object', 'properties' => array( - 'count' => array( + 'count' => array( 'description' => __( 'Total number of reactions with this emoji.' ), 'type' => 'integer', ), - 'reacted' => array( - 'description' => __( 'Whether the current user reacted with this emoji.' ), - 'type' => 'boolean', - ), - 'my_reaction_id' => array( - 'description' => __( "The current user's reaction comment ID, or 0 if not reacted." ), + 'current_user_reaction' => array( + 'description' => __( "The current user's reaction comment ID for this emoji, or 0 if they have not reacted." ), 'type' => 'integer', ), ), @@ -2389,15 +2385,13 @@ protected function prefetch_reaction_summaries( $note_ids ) { } foreach ( $counts as $row ) { - $note_id = (int) $row->comment_parent; - $slug = wp_strip_all_tags( $row->comment_content ); - $key = $note_id . ':' . $slug; - $my_reaction_id = $my_reactions[ $key ] ?? 0; + $note_id = (int) $row->comment_parent; + $slug = wp_strip_all_tags( $row->comment_content ); + $key = $note_id . ':' . $slug; $this->reaction_summaries[ $note_id ][ $slug ] = array( - 'count' => (int) $row->reaction_count, - 'reacted' => $my_reaction_id > 0, - 'my_reaction_id' => $my_reaction_id, + 'count' => (int) $row->reaction_count, + 'current_user_reaction' => $my_reactions[ $key ] ?? 0, ); } } diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index e22f21533fc97..1a5b5b905992d 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -5303,7 +5303,8 @@ public function test_update_note_is_still_allowed() { /** * The note response exposes a `reaction_summary` field aggregating - * counts per emoji slug, plus per-user `reacted` and `my_reaction_id`. + * counts per emoji slug, plus the current user's reaction ID as + * `current_user_reaction`. * * @ticket 63191 */ @@ -5351,13 +5352,11 @@ public function test_note_response_includes_reaction_summary() { $this->assertArrayHasKey( 'reaction_summary', $data ); $this->assertArrayHasKey( 'heart', $data['reaction_summary'] ); $this->assertSame( 1, $data['reaction_summary']['heart']['count'] ); - $this->assertTrue( $data['reaction_summary']['heart']['reacted'] ); - $this->assertSame( $heart_id, $data['reaction_summary']['heart']['my_reaction_id'] ); + $this->assertSame( $heart_id, $data['reaction_summary']['heart']['current_user_reaction'] ); $this->assertArrayHasKey( 'rocket', $data['reaction_summary'] ); $this->assertSame( 1, $data['reaction_summary']['rocket']['count'] ); - $this->assertFalse( $data['reaction_summary']['rocket']['reacted'] ); - $this->assertSame( 0, $data['reaction_summary']['rocket']['my_reaction_id'] ); + $this->assertSame( 0, $data['reaction_summary']['rocket']['current_user_reaction'] ); } /** @@ -5663,7 +5662,7 @@ public function test_note_collection_includes_reaction_summary() { foreach ( $data as $note ) { $this->assertArrayHasKey( 'reaction_summary', $note ); $this->assertSame( 1, $note['reaction_summary']['heart']['count'] ); - $this->assertTrue( $note['reaction_summary']['heart']['reacted'] ); + $this->assertGreaterThan( 0, $note['reaction_summary']['heart']['current_user_reaction'] ); } /* @@ -5755,9 +5754,8 @@ public function test_reaction_summary_counts_reactions_from_multiple_users() { $summary = $response->get_data()['reaction_summary']; $this->assertSame( 2, $summary['heart']['count'], 'Both users should be counted under the same emoji.' ); - $this->assertTrue( $summary['heart']['reacted'] ); - $this->assertSame( $my_reaction_id, $summary['heart']['my_reaction_id'] ); - $this->assertNotSame( $their_reaction_id, $summary['heart']['my_reaction_id'] ); + $this->assertSame( $my_reaction_id, $summary['heart']['current_user_reaction'] ); + $this->assertNotSame( $their_reaction_id, $summary['heart']['current_user_reaction'] ); } /** From 7aedba71919f2dfcffd3a4a754b5e6712fe095e7 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Wed, 7 Oct 2026 10:26:35 +0200 Subject: [PATCH 24/34] Apply suggestion from @westonruter Co-authored-by: Weston Ruter --- .../rest-api/endpoints/class-wp-rest-comments-controller.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 9235dd8327c9f..769a96048ce7e 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 @@ -49,8 +49,9 @@ class WP_REST_Comments_Controller extends WP_REST_Controller { * * @since 7.2.0 * - * @return array[] List of emoji definitions, each with `emoji`, `label`, - * and `value` keys. + * @return array> List of emoji definitions, each with `emoji`, `label`, and `value` keys. + * + * @phpstan-return non-empty-list Date: Wed, 7 Oct 2026 10:30:54 +0200 Subject: [PATCH 25/34] Comments: Make the internal comment types helper private. Avoid committing to a public "internal comment type" concept before a custom comment types API exists. The list now lives in a private helper that can change freely, while keeping the note and reaction exclusions in one place instead of repeating the literals at every call site. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019bw9jxkJwGzejoE6KbwXg2 --- .../includes/class-wp-comments-list-table.php | 4 ++-- src/wp-admin/includes/comment.php | 2 +- src/wp-includes/class-wp-comment-query.php | 2 +- src/wp-includes/class-wp-query.php | 2 +- src/wp-includes/comment.php | 10 +++++++--- src/wp-includes/link-template.php | 6 +++--- .../endpoints/class-wp-rest-comments-controller.php | 12 ++++++------ .../admin/includes/comment/CommentExists_Test.php | 4 ++-- tests/phpunit/tests/admin/wpCommentsListTable.php | 2 +- tests/phpunit/tests/comment.php | 4 ++-- .../phpunit/tests/comment/getLastCommentModified.php | 4 ++-- tests/phpunit/tests/comment/query.php | 2 +- 12 files changed, 29 insertions(+), 25 deletions(-) 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 92774227ca4b5..27de1e1eeb31f 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'] ) && ! in_array( $_REQUEST['comment_type'], wp_get_internal_comment_types(), true ) ) { + 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' => wp_get_internal_comment_types(), + '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 e87d3462132a7..cdb8af1efa10d 100644 --- a/src/wp-admin/includes/comment.php +++ b/src/wp-admin/includes/comment.php @@ -243,7 +243,7 @@ function get_pending_comments_num( $post_id ) { $post_id_array = array_map( 'intval', $post_id_array ); $post_id_in = "'" . implode( "', '", $post_id_array ) . "'"; - $internal_comment_types = wp_get_internal_comment_types(); + $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( diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index 3b6b63e04f8f8..095d055c68f78 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -787,7 +787,7 @@ protected function get_comment_ids() { // Exclude internal comment types, unless 'all' types or a specific internal type is explicitly requested. if ( ! in_array( 'all', $raw_types['IN'], true ) ) { - foreach ( wp_get_internal_comment_types() as $internal_type ) { + foreach ( _wp_get_internal_comment_types() as $internal_type ) { if ( ! in_array( $internal_type, $raw_types['IN'], true ) && ! in_array( $internal_type, $raw_types['NOT IN'], true ) diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 3eb1cbd93bb37..13cb2c6893aa0 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -3672,7 +3672,7 @@ public function get_posts() { private function get_comment_feed_internal_types_where() { global $wpdb; - $internal_types = wp_get_internal_comment_types(); + $internal_types = _wp_get_internal_comment_types(); $placeholders = implode( ', ', array_fill( 0, count( $internal_types ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index e0808287153a9..745eb83481b7b 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -359,11 +359,15 @@ function get_comment_statuses() { * They should typically be excluded from front-end and admin comment * listings, counts, and similar contexts that target user discussion. * + * This is a private helper and not a public API. It may change or be + * replaced once a custom comment types API exists. + * * @since 7.2.0 + * @access private * * @return string[] List of internal comment type slugs. */ -function wp_get_internal_comment_types(): array { +function _wp_get_internal_comment_types(): array { return array( 'note', 'reaction' ); } @@ -470,7 +474,7 @@ function get_lastcommentmodified( $timezone = 'server' ) { } // Exclude internal comment types (notes, reactions, etc.) from the lookup. - $internal_types = wp_get_internal_comment_types(); + $internal_types = _wp_get_internal_comment_types(); if ( ! empty( $internal_types ) ) { $placeholders = implode( ', ', array_fill( 0, count( $internal_types ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare @@ -3263,7 +3267,7 @@ 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 ) ) { - $internal_comment_types = wp_get_internal_comment_types(); + $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( diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index 85b0b9989f08d..4c01023fbeb0b 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -4378,10 +4378,10 @@ function is_avatar_comment_type( $comment_type ) { * @since 6.9.0 The 'note' comment type was added. * @since 7.2.0 The 'reaction' comment type was added. * - * @param array $types An array of content types. Default contains 'comment' and the - * internal comment types returned by wp_get_internal_comment_types(). + * @param array $types An array of content types. Default contains 'comment', 'note', + * and 'reaction'. */ - $allowed_comment_types = apply_filters( 'get_avatar_comment_types', array_merge( array( 'comment' ), wp_get_internal_comment_types() ) ); + $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 769a96048ce7e..12d2e2ae255fe 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 @@ -182,7 +182,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 = in_array( $request['type'], wp_get_internal_comment_types(), true ); + $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(); @@ -516,7 +516,7 @@ public function get_item_permissions_check( $request ) { } // 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' ); + $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', @@ -575,7 +575,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'] ) && in_array( $request['type'], wp_get_internal_comment_types(), true ); + $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( @@ -754,7 +754,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_merge( array( 'comment' ), wp_get_internal_comment_types() ), 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.' ), @@ -999,7 +999,7 @@ public function create_item( $request ) { // Don't check for duplicates or flooding for notes or reactions. $prepared_comment['comment_approved'] = - in_array( $prepared_comment['comment_type'], wp_get_internal_comment_types(), true ) ? + in_array( $prepared_comment['comment_type'], _wp_get_internal_comment_types(), true ) ? '1' : wp_allow_comment( $prepared_comment, true ); @@ -2407,7 +2407,7 @@ protected function prefetch_reaction_summaries( $note_ids ) { * @return bool Whether the comment can be read. */ protected function check_read_permission( $comment, $request ) { - if ( ! in_array( $comment->comment_type, wp_get_internal_comment_types(), true ) && ! 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 ) { diff --git a/tests/phpunit/tests/admin/includes/comment/CommentExists_Test.php b/tests/phpunit/tests/admin/includes/comment/CommentExists_Test.php index 8c655a459373c..f1c6dc939d153 100644 --- a/tests/phpunit/tests/admin/includes/comment/CommentExists_Test.php +++ b/tests/phpunit/tests/admin/includes/comment/CommentExists_Test.php @@ -101,7 +101,7 @@ public function test_get_pending_comments_num_excludes_internal_comment_types() ) ); - foreach ( wp_get_internal_comment_types() as $internal_type ) { + foreach ( _wp_get_internal_comment_types() as $internal_type ) { self::factory()->comment->create( array( 'comment_post_ID' => $post_id, @@ -132,7 +132,7 @@ public function test_get_pending_comments_num_for_multiple_posts_excludes_intern ) ); - foreach ( wp_get_internal_comment_types() as $internal_type ) { + foreach ( _wp_get_internal_comment_types() as $internal_type ) { self::factory()->comment->create( array( 'comment_post_ID' => $notes_only, diff --git a/tests/phpunit/tests/admin/wpCommentsListTable.php b/tests/phpunit/tests/admin/wpCommentsListTable.php index 7f4259c0ef7ae..960ddd466a5ca 100644 --- a/tests/phpunit/tests/admin/wpCommentsListTable.php +++ b/tests/phpunit/tests/admin/wpCommentsListTable.php @@ -227,7 +227,7 @@ public function test_get_views_should_return_views_by_default() { */ public function test_comments_list_table_does_not_show_internal_comment_types( string $comment_type ) { $post_id = self::factory()->post->create(); - foreach ( wp_get_internal_comment_types() as $internal_type ) { + foreach ( _wp_get_internal_comment_types() as $internal_type ) { self::factory()->comment->create( array( 'comment_post_ID' => $post_id, diff --git a/tests/phpunit/tests/comment.php b/tests/phpunit/tests/comment.php index 2c8d0342a6c1c..6bee59076ceb6 100644 --- a/tests/phpunit/tests/comment.php +++ b/tests/phpunit/tests/comment.php @@ -2277,10 +2277,10 @@ public function test_wp_delete_comment_deletes_trashed_note_reactions() { /** * @ticket 63191 * - * @covers ::wp_get_internal_comment_types + * @covers ::_wp_get_internal_comment_types */ public function test_wp_get_internal_comment_types() { - $types = wp_get_internal_comment_types(); + $types = _wp_get_internal_comment_types(); $this->assertContains( 'note', $types ); $this->assertContains( 'reaction', $types ); diff --git a/tests/phpunit/tests/comment/getLastCommentModified.php b/tests/phpunit/tests/comment/getLastCommentModified.php index 55b0c64ff74b7..f1fdb11c5d83c 100644 --- a/tests/phpunit/tests/comment/getLastCommentModified.php +++ b/tests/phpunit/tests/comment/getLastCommentModified.php @@ -157,7 +157,7 @@ public function test_internal_comment_types_are_excluded( $timezone, $expected ) ) ); - foreach ( wp_get_internal_comment_types() as $comment_type ) { + foreach ( _wp_get_internal_comment_types() as $comment_type ) { self::factory()->comment->create( array( 'comment_status' => 1, @@ -199,7 +199,7 @@ public function data_internal_comment_types_are_excluded() { * @ticket 63191 */ public function test_only_internal_comment_types_returns_false() { - foreach ( wp_get_internal_comment_types() as $comment_type ) { + foreach ( _wp_get_internal_comment_types() as $comment_type ) { self::factory()->comment->create( array( 'comment_status' => 1, diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index 9cb8ff86f2add..c6a31bc715ee4 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5592,7 +5592,7 @@ public function test_internal_comment_types_not_duplicated_in_type__not_in() { $this->assertNotContains( $comments['note'], $found ); $this->assertNotContains( $comments['reaction'], $found ); - foreach ( wp_get_internal_comment_types() as $internal_type ) { + foreach ( _wp_get_internal_comment_types() as $internal_type ) { $this->assertSame( 1, substr_count( $wpdb->last_query, "'" . $internal_type . "'" ), From e164e321050d14e066def450d1658fe4888a5737 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Wed, 7 Oct 2026 22:35:44 +0200 Subject: [PATCH 26/34] Apply suggestion from @westonruter Co-authored-by: Weston Ruter --- .../rest-api/endpoints/class-wp-rest-comments-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 12d2e2ae255fe..9469131a50a53 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 @@ -51,7 +51,7 @@ class WP_REST_Comments_Controller extends WP_REST_Controller { * * @return array> List of emoji definitions, each with `emoji`, `label`, and `value` keys. * - * @phpstan-return non-empty-list */ protected static function get_note_reaction_emojis(): array { return array( From 611a0ef1ce796be6d51fce41567fb0b0e46c5f88 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 7 Oct 2026 23:50:30 +0200 Subject: [PATCH 27/34] REST API: Accept only the curated emoji hex keys for note reactions. Store a reaction as the hex key of one of the five curated emoji (heart, celebration, smile, eyes, rocket), the lowercase code points padded to four digits that the editor's `emojiToHexKey()` produces. This matches the Gutenberg implementation, so the client and server agree on a single storage format. The curated slugs and arbitrary hex sequences are no longer accepted, and the `reaction_emojis` schema property is removed along with the list that backed it, since the editor does not read it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017a8BrMHjzF5oUjpCyXWoRu --- .../class-wp-rest-comments-controller.php | 155 ++++----------- tests/phpunit/tests/comment.php | 14 +- .../rest-api/rest-comments-controller.php | 188 ++++++++---------- 3 files changed, 128 insertions(+), 229 deletions(-) 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 9469131a50a53..60b9da60fa8af 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 @@ -36,51 +36,20 @@ class WP_REST_Comments_Controller extends WP_REST_Controller { protected $reaction_summaries = null; /** - * Retrieves the curated list of emoji reactions allowed for note comments. + * Retrieves the hex keys of the emoji a note reaction accepts. * - * Each entry is an associative array with: - * - `emoji` (string) The emoji character. - * - `label` (string) A human-readable label. - * - `value` (string) The slug used as the storage key in `comment_content`. - * - * Reactions submitted to the REST API may also use a lowercase - * hex-codepoint sequence (e.g. `1f44d`) to represent emojis outside the - * curated set; see create_item(). + * Each key is the emoji's lowercase code points, padded to four digits, + * matching the client's `emojiToHexKey()`. A reaction stores its key in + * `comment_content`. * * @since 7.2.0 * - * @return array> List of emoji definitions, each with `emoji`, `label`, and `value` keys. - * - * @phpstan-return non-empty-list + * @return string[] Hex keys for heart, celebration, smile, eyes and rocket. + * + * @phpstan-return non-empty-list */ - protected static function get_note_reaction_emojis(): array { - return array( - array( - 'emoji' => '❤️', - 'label' => __( 'Heart' ), - 'value' => 'heart', - ), - array( - 'emoji' => '🎉', - 'label' => __( 'Celebration' ), - 'value' => 'celebration', - ), - array( - 'emoji' => '😄', - 'label' => __( 'Smile' ), - 'value' => 'smile', - ), - array( - 'emoji' => '👀', - 'label' => __( 'Eyes' ), - 'value' => 'eyes', - ), - array( - 'emoji' => '🚀', - 'label' => __( 'Rocket' ), - 'value' => 'rocket', - ), - ); + private static function get_note_reaction_keys(): array { + return array( '2764', '1f389', '1f604', '1f440', '1f680' ); } /** @@ -763,10 +732,10 @@ public function create_item( $request ) { } /* - * The canonical reaction slug, populated once validated below so the + * The canonical reaction key, populated once validated below so the * stored content matches what was validated (not the raw input). */ - $reaction_slug = null; + $reaction_key = null; // Validate reaction-specific constraints. if ( ! empty( $request['type'] ) && 'reaction' === $request['type'] ) { @@ -799,21 +768,12 @@ public function create_item( $request ) { } /* - * Validate the reaction content. Two shapes are accepted: + * Validate the reaction content: the hex key of one of the curated + * reaction emoji, as listed by self::get_note_reaction_keys() (e.g. + * `2764` for the heart). Raw emoji bytes are rejected because the + * comments table is not guaranteed to be utf8mb4 across all WordPress + * installs; clients are expected to normalize before submitting. * - * - A curated slug (e.g. `heart`) from self::get_note_reaction_emojis(). - * - A lowercase hex-codepoint sequence joined by `-` (e.g. `1f44d` - * for 👍 or `1f468-200d-1f4bb` for 👨‍💻), which is how a client - * offering a full emoji picker stores a pick outside the curated - * list. The client decodes the sequence back into the emoji. - * - * Raw emoji bytes are rejected because the comments table is not - * guaranteed to be utf8mb4 across all WordPress installs; clients - * are expected to normalize before submitting. Variation selector - * U+FE0F is dropped on the client so visually-equivalent - * presentations collapse onto a single key. - */ - /* * Read the content the same two ways prepare_item_for_database() * does, so `content` and `content.raw` are both accepted. */ @@ -824,28 +784,9 @@ public function create_item( $request ) { $raw_content = $request['content']['raw']; } - $valid_slugs = wp_list_pluck( self::get_note_reaction_emojis(), 'value' ); - $emoji_slug = trim( wp_strip_all_tags( $raw_content ) ); - - $is_curated_slug = in_array( $emoji_slug, $valid_slugs, true ); - $is_hex_key = (bool) preg_match( '/^[0-9a-f]{2,6}(-[0-9a-f]{2,6}){0,15}$/', $emoji_slug ); + $emoji_key = trim( wp_strip_all_tags( $raw_content ) ); - /* - * A hex-shaped slug must still be made of assignable Unicode code - * points: reject anything above U+10FFFF or in the UTF-16 surrogate - * range (U+D800–U+DFFF). - */ - if ( $is_hex_key ) { - foreach ( explode( '-', $emoji_slug ) as $codepoint ) { - $value = hexdec( $codepoint ); - if ( $value > 0x10FFFF || ( $value >= 0xD800 && $value <= 0xDFFF ) ) { - $is_hex_key = false; - break; - } - } - } - - if ( '' === $emoji_slug || ( ! $is_curated_slug && ! $is_hex_key ) ) { + if ( ! in_array( $emoji_key, self::get_note_reaction_keys(), true ) ) { return new WP_Error( 'rest_comment_invalid_reaction', __( 'Invalid reaction emoji.' ), @@ -884,7 +825,7 @@ public function create_item( $request ) { ); foreach ( $existing as $existing_reaction ) { - if ( wp_strip_all_tags( $existing_reaction->comment_content ) === $emoji_slug ) { + if ( wp_strip_all_tags( $existing_reaction->comment_content ) === $emoji_key ) { return new WP_Error( 'rest_comment_duplicate_reaction', __( 'You have already reacted with this emoji.' ), @@ -893,7 +834,7 @@ public function create_item( $request ) { } } - $reaction_slug = $emoji_slug; + $reaction_key = $emoji_key; } $prepared_comment = $this->prepare_item_for_database( $request ); @@ -904,12 +845,12 @@ public function create_item( $request ) { $prepared_comment['comment_type'] = $request['type']; /* - * Persist the validated, canonical reaction slug rather than the raw + * Persist the validated, canonical reaction key rather than the raw * request content, so stored values stay consistent for grouping and - * counting (e.g. "heart" is stored as "heart"). + * counting (e.g. "2764" is stored as "2764"). */ - if ( null !== $reaction_slug ) { - $prepared_comment['comment_content'] = $reaction_slug; + if ( null !== $reaction_key ) { + $prepared_comment['comment_content'] = $reaction_key; } if ( ! isset( $prepared_comment['comment_content'] ) ) { @@ -954,7 +895,7 @@ public function create_item( $request ) { * carried. Author fields alone leave `user_id` at 0, which the uniqueness * check and the reaction summary both key on. */ - if ( null !== $reaction_slug ) { + if ( null !== $reaction_key ) { $user = wp_get_current_user(); $prepared_comment['user_id'] = $user->ID; @@ -1063,7 +1004,7 @@ public function create_item( $request ) { * all settle on the same surviving row. If this request's own row lost * the race, repoint the response to the survivor. */ - if ( null !== $reaction_slug ) { + if ( null !== $reaction_key ) { $matching = get_comments( array( 'parent' => $request['parent'], @@ -1076,7 +1017,7 @@ public function create_item( $request ) { ); $duplicates = array(); foreach ( $matching as $candidate ) { - if ( wp_strip_all_tags( $candidate->comment_content ) === $reaction_slug ) { + if ( wp_strip_all_tags( $candidate->comment_content ) === $reaction_key ) { $duplicates[] = (int) $candidate->comment_ID; } } @@ -1101,7 +1042,7 @@ public function create_item( $request ) { * Skipping them here also keeps a request from changing the status of a * row that the race cleanup above may have handed it from another request. */ - if ( isset( $request['status'] ) && null === $reaction_slug ) { + if ( isset( $request['status'] ) && null === $reaction_key ) { $this->handle_status_param( $request['status'], $comment_id ); } @@ -1177,11 +1118,11 @@ public function update_item_permissions_check( $request ) { /* * Reactions are immutable. create_item() validates the author, parent - * note, target post and canonical emoji slug as a set, and none of that + * note, target post and canonical emoji hex key as a set, and none of that * is re-checked here. Allowing an update would let anyone who can edit * the note's post reattribute a reaction to another user, move it to a * note on a post they cannot edit, or store a duplicate or invalid - * slug. Removing a reaction is a delete. + * key. Removing a reaction is a delete. */ if ( 'reaction' === $comment->comment_type ) { return new WP_Error( @@ -1977,32 +1918,8 @@ public function get_item_schema() { 'readonly' => true, 'default' => 'comment', ), - 'reaction_emojis' => array( - 'description' => __( 'Allowed emoji reactions for notes.' ), - 'type' => 'array', - 'context' => array( 'view', 'edit' ), - 'readonly' => true, - 'items' => array( - 'type' => 'object', - 'properties' => array( - 'emoji' => array( - 'description' => __( 'The emoji character.' ), - 'type' => 'string', - ), - 'label' => array( - 'description' => __( 'A human-readable label for the emoji.' ), - 'type' => 'string', - ), - 'value' => array( - 'description' => __( 'The slug used as the storage key.' ), - 'type' => 'string', - ), - ), - ), - 'default' => self::get_note_reaction_emojis(), - ), 'reaction_summary' => array( - 'description' => __( 'Aggregated reaction counts for this note, keyed by emoji slug.' ), + 'description' => __( 'Aggregated reaction counts for this note, keyed by emoji hex key.' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, @@ -2386,11 +2303,11 @@ protected function prefetch_reaction_summaries( $note_ids ) { } foreach ( $counts as $row ) { - $note_id = (int) $row->comment_parent; - $slug = wp_strip_all_tags( $row->comment_content ); - $key = $note_id . ':' . $slug; + $note_id = (int) $row->comment_parent; + $emoji_key = wp_strip_all_tags( $row->comment_content ); + $key = $note_id . ':' . $emoji_key; - $this->reaction_summaries[ $note_id ][ $slug ] = array( + $this->reaction_summaries[ $note_id ][ $emoji_key ] = array( 'count' => (int) $row->reaction_count, 'current_user_reaction' => $my_reactions[ $key ] ?? 0, ); @@ -2522,7 +2439,7 @@ protected function check_is_comment_content_allowed( $prepared_comment ) { return true; } - // Reactions always have content (the emoji slug), so allow them. + // Reactions always have content (the emoji hex key), so allow them. if ( isset( $check['comment_type'] ) && 'reaction' === $check['comment_type'] ) { return true; } diff --git a/tests/phpunit/tests/comment.php b/tests/phpunit/tests/comment.php index 6bee59076ceb6..4bc61b1d2d7b6 100644 --- a/tests/phpunit/tests/comment.php +++ b/tests/phpunit/tests/comment.php @@ -2013,17 +2013,17 @@ public function test_wp_trash_comment_only_top_level_notes_trigger_child_deletio * Creates an approved reaction on a note. * * @param int $note_id Parent note comment ID. - * @param string $slug Reaction storage slug. + * @param string $key Reaction hex key. * @return int Reaction comment ID. */ - private function create_reaction_on_note( $note_id, $slug = 'heart' ) { + private function create_reaction_on_note( $note_id, $key = '2764' ) { return self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id, 'comment_type' => 'reaction', 'comment_parent' => $note_id, 'comment_approved' => '1', - 'comment_content' => $slug, + 'comment_content' => $key, ) ); } @@ -2050,7 +2050,7 @@ public function test_wp_delete_comment_deletes_note_reactions() { ); $reaction_1 = $this->create_reaction_on_note( $note_id ); - $reaction_2 = $this->create_reaction_on_note( $note_id, 'rocket' ); + $reaction_2 = $this->create_reaction_on_note( $note_id, '1f680' ); wp_delete_comment( $note_id, true ); @@ -2265,7 +2265,7 @@ public function test_wp_delete_comment_deletes_trashed_note_reactions() { ); $approved = $this->create_reaction_on_note( $note_id ); - $trashed = $this->create_reaction_on_note( $note_id, 'rocket' ); + $trashed = $this->create_reaction_on_note( $note_id, '1f680' ); wp_trash_comment( $trashed ); wp_delete_comment( $note_id, true ); @@ -2302,7 +2302,7 @@ public function test_wp_get_note_reaction_ids_returns_reactions_oldest_first() { ); $heart = $this->create_reaction_on_note( $note_id ); - $rocket = $this->create_reaction_on_note( $note_id, 'rocket' ); + $rocket = $this->create_reaction_on_note( $note_id, '1f680' ); // A reply is a child of the note, but it is not a reaction. self::factory()->comment->create( @@ -2332,7 +2332,7 @@ public function test_wp_get_note_reaction_ids_filters_by_status() { ); $approved = $this->create_reaction_on_note( $note_id ); - $trashed = $this->create_reaction_on_note( $note_id, 'rocket' ); + $trashed = $this->create_reaction_on_note( $note_id, '1f680' ); wp_trash_comment( $trashed ); $this->assertSame( array( $approved ), array_map( 'intval', wp_get_note_reaction_ids( $note_id, 'approve' ) ), 'Only the approved reaction was expected.' ); diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index f642cd8ddb9c1..67fa78455e145 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -3335,7 +3335,7 @@ public function test_get_item_schema() { $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertCount( 19, $properties ); + $this->assertCount( 18, $properties ); $this->assertArrayHasKey( 'id', $properties ); $this->assertArrayHasKey( 'author', $properties ); $this->assertArrayHasKey( 'author_avatar_urls', $properties ); @@ -3351,7 +3351,6 @@ public function test_get_item_schema() { $this->assertArrayHasKey( 'meta', $properties ); $this->assertArrayHasKey( 'parent', $properties ); $this->assertArrayHasKey( 'post', $properties ); - $this->assertArrayHasKey( 'reaction_emojis', $properties ); $this->assertArrayHasKey( 'reaction_summary', $properties ); $this->assertArrayHasKey( 'status', $properties ); $this->assertArrayHasKey( 'type', $properties ); @@ -4330,7 +4329,7 @@ public function test_create_reaction() { array( 'post' => $post_id, 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'type' => 'reaction', 'author' => self::$editor_id, ) @@ -4342,7 +4341,7 @@ public function test_create_reaction() { $data = $response->get_data(); $new_comment = get_comment( $data['id'] ); - $this->assertSame( 'heart', $new_comment->comment_content ); + $this->assertSame( '2764', $new_comment->comment_content ); $this->assertSame( 'reaction', $new_comment->comment_type ); $this->assertSame( (string) $note_id, $new_comment->comment_parent ); } @@ -4371,7 +4370,7 @@ public function test_create_reaction_invalid_parent() { array( 'post' => $post_id, 'parent' => $comment_id, - 'content' => 'heart', + 'content' => '2764', 'type' => 'reaction', 'author' => self::$editor_id, ) @@ -4396,7 +4395,7 @@ public function test_create_reaction_no_parent() { wp_json_encode( array( 'post' => $post_id, - 'content' => 'heart', + 'content' => '2764', 'type' => 'reaction', 'author' => self::$editor_id, ) @@ -4467,7 +4466,7 @@ public function test_create_reaction_duplicate() { 'comment_parent' => $note_id, 'comment_approved' => 1, 'user_id' => self::$editor_id, - 'comment_content' => 'heart', + 'comment_content' => '2764', ) ); @@ -4479,7 +4478,7 @@ public function test_create_reaction_duplicate() { array( 'post' => $post_id, 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'type' => 'reaction', 'author' => self::$editor_id, ) @@ -4515,7 +4514,7 @@ public function test_create_different_reactions_on_same_note() { array( 'post' => $post_id, 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'type' => 'reaction', 'author' => self::$editor_id, ) @@ -4533,7 +4532,7 @@ public function test_create_different_reactions_on_same_note() { array( 'post' => $post_id, 'parent' => $note_id, - 'content' => 'rocket', + 'content' => '1f680', 'type' => 'reaction', 'author' => self::$editor_id, ) @@ -4568,7 +4567,7 @@ public function test_create_reaction_requires_login() { array( 'post' => $post_id, 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'type' => 'reaction', ) ) @@ -4579,18 +4578,16 @@ public function test_create_reaction_requires_login() { } /** - * A hex-codepoint sequence (e.g. `1f44d` for 👍) is accepted as a - * reaction slug, supporting emojis outside the curated set. It is the - * storage format a client offering a full emoji picker submits, and the - * one it decodes back into the emoji. + * Each curated reaction emoji is accepted by its hex key: the emoji's + * lowercase code points, padded to four digits. * * @ticket 63191 * - * @dataProvider data_hex_codepoint_slugs + * @dataProvider data_curated_reaction_keys * - * @param string $slug The hex-codepoint slug to submit. + * @param string $key The reaction hex key to submit. */ - public function test_create_reaction_accepts_hex_codepoint_slug( $slug ) { + public function test_create_reaction_accepts_curated_key( $key ) { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); @@ -4611,7 +4608,7 @@ public function test_create_reaction_accepts_hex_codepoint_slug( $slug ) { array( 'post' => $post_id, 'parent' => $note_id, - 'content' => $slug, + 'content' => $key, 'type' => 'reaction', 'author' => self::$editor_id, ) @@ -4622,21 +4619,22 @@ public function test_create_reaction_accepts_hex_codepoint_slug( $slug ) { $this->assertSame( 201, $response->get_status() ); $new_comment = get_comment( $response->get_data()['id'] ); - $this->assertSame( $slug, $new_comment->comment_content ); + $this->assertSame( $key, $new_comment->comment_content ); } - public function data_hex_codepoint_slugs() { + public function data_curated_reaction_keys() { return array( - 'single codepoint' => array( '1f44d' ), - 'ZWJ sequence' => array( '1f468-200d-1f4bb' ), - 'assignable ASCII' => array( '41' ), - 'skin-tone variant' => array( '270b-1f3ff' ), + '2764' => array( '2764' ), + 'celebration' => array( '1f389' ), + 'smile' => array( '1f604' ), + 'eyes' => array( '1f440' ), + '1f680' => array( '1f680' ), ); } /** - * Raw emoji bytes must be rejected — clients are expected to normalize - * to a curated slug or hex-codepoint sequence before submitting. + * Raw emoji bytes must be rejected - clients are expected to normalize + * to a curated hex key before submitting. * * @ticket 63191 */ @@ -4701,7 +4699,7 @@ public function test_create_reaction_after_trashing_previous_one() { 'comment_parent' => $note_id, 'comment_approved' => 'trash', 'user_id' => self::$editor_id, - 'comment_content' => 'heart', + 'comment_content' => '2764', ) ); @@ -4712,7 +4710,7 @@ public function test_create_reaction_after_trashing_previous_one() { array( 'post' => $post_id, 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'type' => 'reaction', 'author' => self::$editor_id, ) @@ -4750,7 +4748,7 @@ public function test_create_reaction_on_note_from_different_post() { array( 'post' => $post_id, 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'type' => 'reaction', 'author' => self::$editor_id, ) @@ -4763,17 +4761,17 @@ public function test_create_reaction_on_note_from_different_post() { /** - * A hex-shaped slug must be made of assignable Unicode code points: - * values above U+10FFFF or in the UTF-16 surrogate range are rejected, - * as is a sequence that is not lowercase. + * Only the curated hex keys are accepted. Other emoji, even when + * submitted as well-formed hex keys, are rejected, as are the slugs an + * earlier version of the API accepted and keys that are not lowercase. * * @ticket 63191 * - * @dataProvider data_invalid_codepoint_slugs + * @dataProvider data_uncurated_reaction_keys * - * @param string $slug The invalid hex-codepoint slug to submit. + * @param string $key The reaction content to submit. */ - public function test_create_reaction_rejects_invalid_codepoints( $slug ) { + public function test_create_reaction_rejects_uncurated_key( $key ) { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); @@ -4794,7 +4792,7 @@ public function test_create_reaction_rejects_invalid_codepoints( $slug ) { array( 'post' => $post_id, 'parent' => $note_id, - 'content' => $slug, + 'content' => $key, 'type' => 'reaction', 'author' => self::$editor_id, ) @@ -4805,25 +4803,25 @@ public function test_create_reaction_rejects_invalid_codepoints( $slug ) { $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); } - public function data_invalid_codepoint_slugs() { + public function data_uncurated_reaction_keys() { return array( - 'above U+10FFFF' => array( 'ffffff' ), - 'lead surrogate U+D800' => array( 'd800' ), - 'trail surrogate U+DFFF' => array( 'dfff' ), - 'surrogate inside a sequence' => array( '1f44d-d9ab' ), - 'above U+10FFFF in a sequence' => array( '1f468-200d-110000' ), - 'uppercase hex' => array( '1F44D' ), + 'uncurated emoji' => array( '1f44d' ), + 'ZWJ sequence' => array( '1f468-200d-1f4bb' ), + 'with variation selector' => array( '2764-fe0f' ), + 'uppercase key' => array( '1F680' ), + 'slug' => array( 'heart' ), + 'empty' => array( '' ), ); } /** - * The stored reaction content is the validated, canonical slug — markup - * around the slug must not reach the database, or `reaction_summary` + * The stored reaction content is the validated, canonical key - markup + * around the key must not reach the database, or `reaction_summary` * grouping would split visually identical reactions. * * @ticket 63191 */ - public function test_create_reaction_stores_canonical_slug() { + public function test_create_reaction_stores_canonical_key() { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); @@ -4844,7 +4842,7 @@ public function test_create_reaction_stores_canonical_slug() { array( 'post' => $post_id, 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'type' => 'reaction', 'author' => self::$editor_id, ) @@ -4855,7 +4853,7 @@ public function test_create_reaction_stores_canonical_slug() { $this->assertSame( 201, $response->get_status() ); $new_comment = get_comment( $response->get_data()['id'] ); - $this->assertSame( 'heart', $new_comment->comment_content ); + $this->assertSame( '2764', $new_comment->comment_content ); } /** @@ -4884,7 +4882,7 @@ public function test_create_reaction_accepts_raw_content_object() { array( 'post' => $post_id, 'parent' => $note_id, - 'content' => array( 'raw' => 'rocket' ), + 'content' => array( 'raw' => '1f680' ), 'type' => 'reaction', ) ) @@ -4894,7 +4892,7 @@ public function test_create_reaction_accepts_raw_content_object() { $this->assertSame( 201, $response->get_status() ); $new_comment = get_comment( $response->get_data()['id'] ); - $this->assertSame( 'rocket', $new_comment->comment_content ); + $this->assertSame( '1f680', $new_comment->comment_content ); } /** @@ -4931,7 +4929,7 @@ public function test_create_reaction_only_allows_approved_status( $status, $expe array( 'post' => $post_id, 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'type' => 'reaction', 'status' => $status, ) @@ -5009,7 +5007,7 @@ public function test_concurrent_duplicate_reaction_converges_to_single_row() { 'comment_post_ID' => $post_id, 'comment_parent' => $note_id, 'comment_type' => 'reaction', - 'comment_content' => 'heart', + 'comment_content' => '2764', 'comment_approved' => 1, 'user_id' => self::$editor_id, ) @@ -5027,7 +5025,7 @@ public function test_concurrent_duplicate_reaction_converges_to_single_row() { array( 'post' => $post_id, 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'type' => 'reaction', 'author' => self::$editor_id, ) @@ -5052,7 +5050,7 @@ public function test_concurrent_duplicate_reaction_converges_to_single_row() { array_filter( $remaining, static function ( $comment ) { - return 'heart' === $comment->comment_content; + return '2764' === $comment->comment_content; } ) ); @@ -5109,7 +5107,7 @@ public function test_concurrent_cleanup_deleting_own_row_still_returns_survivor( 'comment_post_ID' => $post_id, 'comment_parent' => $note_id, 'comment_type' => 'reaction', - 'comment_content' => 'heart', + 'comment_content' => '2764', 'comment_approved' => 1, 'user_id' => self::$editor_id, ) @@ -5128,7 +5126,7 @@ public function test_concurrent_cleanup_deleting_own_row_still_returns_survivor( array( 'post' => $post_id, 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'type' => 'reaction', 'author' => self::$editor_id, ) @@ -5151,17 +5149,17 @@ public function test_concurrent_cleanup_deleting_own_row_still_returns_survivor( * @param int $post_id Post the parent note belongs to. * @param int $note_id Parent note comment ID. * @param int $user_id Reacting user ID. - * @param string $slug Reaction storage slug. + * @param string $key Reaction hex key. * @return int Reaction comment ID. */ - private function create_reaction_for_update_tests( $post_id, $note_id, $user_id, $slug = 'heart' ) { + private function create_reaction_for_update_tests( $post_id, $note_id, $user_id, $key = '2764' ) { return self::factory()->comment->create( array( 'comment_post_ID' => $post_id, 'comment_parent' => $note_id, 'comment_type' => 'reaction', 'comment_approved' => 1, - 'comment_content' => $slug, + 'comment_content' => $key, 'user_id' => $user_id, ) ); @@ -5169,7 +5167,7 @@ private function create_reaction_for_update_tests( $post_id, $note_id, $user_id, /** * Reactions are validated as a set on create - author, parent note, target - * post and canonical slug - and the generic update route re-validates none + * post and canonical key - and the generic update route re-validates none * of it, so updating a reaction is not allowed at all. * * @ticket 63191 @@ -5190,11 +5188,11 @@ public function test_update_reaction_content_is_not_allowed() { wp_set_current_user( self::$editor_id ); $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $reaction_id ); $request->add_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( array( 'content' => 'rocket' ) ) ); + $request->set_body( wp_json_encode( array( 'content' => '1f680' ) ) ); $response = rest_get_server()->dispatch( $request ); $this->assertErrorResponse( 'rest_comment_update_not_allowed', $response, 403 ); - $this->assertSame( 'heart', get_comment( $reaction_id )->comment_content ); + $this->assertSame( '2764', get_comment( $reaction_id )->comment_content ); } /** @@ -5303,7 +5301,7 @@ public function test_update_note_is_still_allowed() { /** * The note response exposes a `reaction_summary` field aggregating - * counts per emoji slug, plus the current user's reaction ID as + * counts per emoji hex key, plus the current user's reaction ID as * `current_user_reaction`. * * @ticket 63191 @@ -5329,7 +5327,7 @@ public function test_note_response_includes_reaction_summary() { 'comment_parent' => $note_id, 'comment_approved' => 1, 'user_id' => self::$editor_id, - 'comment_content' => 'heart', + 'comment_content' => '2764', ) ); @@ -5340,7 +5338,7 @@ public function test_note_response_includes_reaction_summary() { 'comment_parent' => $note_id, 'comment_approved' => 1, 'user_id' => self::$subscriber_id, - 'comment_content' => 'rocket', + 'comment_content' => '1f680', ) ); @@ -5350,29 +5348,13 @@ public function test_note_response_includes_reaction_summary() { $data = $response->get_data(); $this->assertArrayHasKey( 'reaction_summary', $data ); - $this->assertArrayHasKey( 'heart', $data['reaction_summary'] ); - $this->assertSame( 1, $data['reaction_summary']['heart']['count'] ); - $this->assertSame( $heart_id, $data['reaction_summary']['heart']['current_user_reaction'] ); + $this->assertArrayHasKey( '2764', $data['reaction_summary'] ); + $this->assertSame( 1, $data['reaction_summary']['2764']['count'] ); + $this->assertSame( $heart_id, $data['reaction_summary']['2764']['current_user_reaction'] ); - $this->assertArrayHasKey( 'rocket', $data['reaction_summary'] ); - $this->assertSame( 1, $data['reaction_summary']['rocket']['count'] ); - $this->assertSame( 0, $data['reaction_summary']['rocket']['current_user_reaction'] ); - } - - /** - * Comment schema exposes the curated reaction emoji list so clients - * can discover which slugs the server accepts. - * - * @ticket 63191 - */ - public function test_comment_schema_exposes_reaction_emojis() { - $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comments' ); - $response = rest_get_server()->dispatch( $request ); - $schema = $response->get_data()['schema']; - - $this->assertArrayHasKey( 'reaction_emojis', $schema['properties'] ); - $slugs = wp_list_pluck( $schema['properties']['reaction_emojis']['default'], 'value' ); - $this->assertSame( array( 'heart', 'celebration', 'smile', 'eyes', 'rocket' ), $slugs ); + $this->assertArrayHasKey( '1f680', $data['reaction_summary'] ); + $this->assertSame( 1, $data['reaction_summary']['1f680']['count'] ); + $this->assertSame( 0, $data['reaction_summary']['1f680']['current_user_reaction'] ); } /** @@ -5404,7 +5386,7 @@ public function test_note_children_link_ignores_reactions() { 'comment_parent' => $note_id, 'comment_approved' => 1, 'user_id' => self::$editor_id, - 'comment_content' => 'heart', + 'comment_content' => '2764', ) ); @@ -5460,7 +5442,7 @@ public function test_create_reaction_cannot_be_attributed_to_another_user() { array( 'post' => $post_id, 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'type' => 'reaction', 'author' => self::$editor_id, ) @@ -5509,7 +5491,7 @@ public function test_create_reaction_ignores_request_author_fields() { array( 'post' => $post_id, 'parent' => $note_id, - 'content' => 'heart', + 'content' => '2764', 'type' => 'reaction', 'author_name' => 'Someone Else', 'author_email' => 'someone@example.com', @@ -5562,7 +5544,7 @@ public function test_delete_reaction() { 'comment_parent' => $note_id, 'comment_approved' => 1, 'user_id' => self::$editor_id, - 'comment_content' => 'heart', + 'comment_content' => '2764', ) ); @@ -5598,7 +5580,7 @@ public function test_delete_reaction_requires_edit_permission() { 'comment_parent' => $note_id, 'comment_approved' => 1, 'user_id' => self::$editor_id, - 'comment_content' => 'heart', + 'comment_content' => '2764', ) ); @@ -5640,7 +5622,7 @@ public function test_note_collection_includes_reaction_summary() { 'comment_parent' => $note_id, 'comment_approved' => 1, 'user_id' => self::$editor_id, - 'comment_content' => 'heart', + 'comment_content' => '2764', ) ); } @@ -5661,8 +5643,8 @@ public function test_note_collection_includes_reaction_summary() { foreach ( $data as $note ) { $this->assertArrayHasKey( 'reaction_summary', $note ); - $this->assertSame( 1, $note['reaction_summary']['heart']['count'] ); - $this->assertGreaterThan( 0, $note['reaction_summary']['heart']['current_user_reaction'] ); + $this->assertSame( 1, $note['reaction_summary']['2764']['count'] ); + $this->assertGreaterThan( 0, $note['reaction_summary']['2764']['current_user_reaction'] ); } /* @@ -5699,7 +5681,7 @@ public function test_reaction_summary_is_not_exposed_to_logged_out_users() { 'comment_parent' => $note_id, 'comment_approved' => 1, 'user_id' => self::$editor_id, - 'comment_content' => 'heart', + 'comment_content' => '2764', ) ); @@ -5734,7 +5716,7 @@ public function test_reaction_summary_counts_reactions_from_multiple_users() { 'comment_parent' => $note_id, 'comment_approved' => 1, 'user_id' => self::$admin_id, - 'comment_content' => 'heart', + 'comment_content' => '2764', ) ); $my_reaction_id = self::factory()->comment->create( @@ -5744,7 +5726,7 @@ public function test_reaction_summary_counts_reactions_from_multiple_users() { 'comment_parent' => $note_id, 'comment_approved' => 1, 'user_id' => self::$editor_id, - 'comment_content' => 'heart', + 'comment_content' => '2764', ) ); @@ -5753,9 +5735,9 @@ public function test_reaction_summary_counts_reactions_from_multiple_users() { $response = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ) ); $summary = $response->get_data()['reaction_summary']; - $this->assertSame( 2, $summary['heart']['count'], 'Both users should be counted under the same emoji.' ); - $this->assertSame( $my_reaction_id, $summary['heart']['current_user_reaction'] ); - $this->assertNotSame( $their_reaction_id, $summary['heart']['current_user_reaction'] ); + $this->assertSame( 2, $summary['2764']['count'], 'Both users should be counted under the same emoji.' ); + $this->assertSame( $my_reaction_id, $summary['2764']['current_user_reaction'] ); + $this->assertNotSame( $their_reaction_id, $summary['2764']['current_user_reaction'] ); } /** @@ -5782,7 +5764,7 @@ public function test_reaction_summary_excludes_trashed_reactions() { 'comment_parent' => $note_id, 'comment_approved' => 1, 'user_id' => self::$editor_id, - 'comment_content' => 'heart', + 'comment_content' => '2764', ) ); From 6ef4ee74bdac4eefee7460fd8fd406ba16c59d6d Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 7 Oct 2026 23:52:16 +0200 Subject: [PATCH 28/34] REST API: Restrict reaction removal and reactions on closed notes. Only the user who added a reaction can delete it. Anyone who can edit the post can edit a note, and the generic delete check follows that, but a reaction belongs to its author. Reject a reaction whose parent note is trashed or spammed, since it would escape the trash cascade, and one anywhere in a resolved thread, since the editor disables reactions once a thread's root note is approved. The walk up to the thread root guards against a cyclic parent chain. These match the checks in the Gutenberg implementation. Test notes that receive reactions are now created open, as the editor creates them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017a8BrMHjzF5oUjpCyXWoRu --- .../class-wp-rest-comments-controller.php | 51 +++ .../rest-api/rest-comments-controller.php | 304 ++++++++++++++++-- 2 files changed, 326 insertions(+), 29 deletions(-) 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 60b9da60fa8af..9e6454f4e8a7e 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 @@ -709,6 +709,7 @@ public function create_item_permissions_check( $request ) { * Creates a comment. * * @since 4.7.0 + * @since 7.2.0 Added support for the `reaction` comment type. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or error object on failure. @@ -758,6 +759,42 @@ public function create_item( $request ) { ); } + // A reaction under a trashed or spammed note would escape the trash cascade. + if ( in_array( $parent_comment->comment_approved, array( 'trash', 'spam' ), true ) ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction cannot be added to a trashed or spam note.' ), + array( 'status' => 400 ) + ); + } + + /* + * Resolving a thread approves its root note, and the editor disables + * reactions from then on. Hold requests from stale editor sessions + * to that too, for the root note and for every reply in its thread. + */ + $thread_root = $parent_comment; + $visited = array( (int) $thread_root->comment_ID => true ); + while ( $thread_root->comment_parent ) { + $ancestor = get_comment( $thread_root->comment_parent ); + + // Stop at a missing ancestor or a corrupt, cyclic chain. + if ( ! $ancestor || isset( $visited[ (int) $ancestor->comment_ID ] ) ) { + break; + } + + $visited[ (int) $ancestor->comment_ID ] = true; + $thread_root = $ancestor; + } + + if ( '1' === $thread_root->comment_approved ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction cannot be added to a resolved note.' ), + array( 'status' => 400 ) + ); + } + // The parent note must belong to the post the reaction targets. if ( ! empty( $request['post'] ) && (int) $parent_comment->comment_post_ID !== (int) $request['post'] ) { return new WP_Error( @@ -1291,6 +1328,7 @@ public function update_item( $request ) { * Checks if a given request has access to delete a comment. * * @since 4.7.0 + * @since 7.2.0 A reaction can only be deleted by the user who added it. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has access to delete the item, error object otherwise. @@ -1301,6 +1339,19 @@ public function delete_item_permissions_check( $request ) { return $comment; } + /* + * Anyone who can edit a note's post can edit the note, and the check + * below follows that, but a reaction belongs to the user who added it: + * only they can take it back. + */ + if ( 'reaction' === $comment->comment_type && get_current_user_id() !== (int) $comment->user_id ) { + return new WP_Error( + 'rest_cannot_delete', + __( 'Sorry, you can only remove your own reactions.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + if ( ! $this->check_edit_permission( $comment ) ) { return new WP_Error( 'rest_cannot_delete', diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 67fa78455e145..fe23c5b5f1e1b 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4316,7 +4316,8 @@ public function test_create_reaction() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + // Open, like the editor creates it: an approved note is resolved. + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -4417,7 +4418,7 @@ public function test_create_reaction_invalid_emoji() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -4452,7 +4453,7 @@ public function test_create_reaction_duplicate() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -4500,7 +4501,7 @@ public function test_create_different_reactions_on_same_note() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -4554,7 +4555,7 @@ public function test_create_reaction_requires_login() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -4595,7 +4596,7 @@ public function test_create_reaction_accepts_curated_key( $key ) { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -4646,7 +4647,7 @@ public function test_create_reaction_rejects_raw_emoji() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -4685,7 +4686,7 @@ public function test_create_reaction_after_trashing_previous_one() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -4735,7 +4736,7 @@ public function test_create_reaction_on_note_from_different_post() { array( 'comment_post_ID' => $other_post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Note on another post', ) @@ -4779,7 +4780,7 @@ public function test_create_reaction_rejects_uncurated_key( $key ) { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -4829,7 +4830,7 @@ public function test_create_reaction_stores_canonical_key() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -4869,7 +4870,7 @@ public function test_create_reaction_accepts_raw_content_object() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -4916,7 +4917,7 @@ public function test_create_reaction_only_allows_approved_status( $status, $expe array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -4988,7 +4989,7 @@ public function test_concurrent_duplicate_reaction_converges_to_single_row() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -5075,7 +5076,7 @@ public function test_concurrent_cleanup_deleting_own_row_still_returns_survivor( array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -5178,7 +5179,7 @@ public function test_update_reaction_content_is_not_allowed() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -5206,7 +5207,7 @@ public function test_update_reaction_author_is_not_allowed() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -5236,7 +5237,7 @@ public function test_update_reaction_cannot_move_to_note_on_another_post() { array( 'comment_post_ID' => $editable_post, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -5245,7 +5246,7 @@ public function test_update_reaction_cannot_move_to_note_on_another_post() { array( 'comment_post_ID' => $other_post, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$admin_id, 'comment_content' => 'Other note', ) @@ -5284,7 +5285,7 @@ public function test_update_note_is_still_allowed() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -5314,7 +5315,7 @@ public function test_note_response_includes_reaction_summary() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -5373,7 +5374,7 @@ public function test_note_children_link_ignores_reactions() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -5429,7 +5430,7 @@ public function test_create_reaction_cannot_be_attributed_to_another_user() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$admin_id, 'comment_content' => 'Test note', ) @@ -5481,7 +5482,7 @@ public function test_create_reaction_ignores_request_author_fields() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -5531,7 +5532,7 @@ public function test_delete_reaction() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -5568,7 +5569,7 @@ public function test_delete_reaction_requires_edit_permission() { array( 'comment_post_ID' => $post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -5592,6 +5593,251 @@ public function test_delete_reaction_requires_edit_permission() { $this->assertNotNull( get_comment( $reaction_id ) ); } + /** + * Only the user who added a reaction can remove it, even though other + * users who can edit the post can edit the note it belongs to. + * + * @ticket 63191 + */ + public function test_delete_reaction_of_another_user_is_not_allowed() { + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + $reaction_id = 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' => '2764', + ) + ); + + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/comments/' . $reaction_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_delete', $response, 403 ); + $this->assertSame( 'Sorry, you can only remove your own reactions.', $response->as_error()->get_error_message() ); + $this->assertNotNull( get_comment( $reaction_id ), 'Another user removed the reaction.' ); + } + + /** + * A reaction's author can remove it from a note somebody else wrote. + * + * @ticket 63191 + */ + public function test_delete_own_reaction_on_another_users_note() { + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$admin_id, + 'comment_content' => 'Test note', + ) + ); + $reaction_id = 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' => '2764', + ) + ); + + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/comments/' . $reaction_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNull( get_comment( $reaction_id ) ); + } + + /** + * A reaction cannot be added to a trashed or spammed note, where it would + * escape the trash cascade. + * + * @ticket 63191 + * + * @dataProvider data_hidden_note_statuses + * + * @param string $status Status to move the parent note to. + */ + public function test_create_reaction_on_hidden_note( $status ) { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + wp_set_comment_status( $note_id, $status ); + + $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' => '2764', + 'type' => 'reaction', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); + } + + /** + * Data provider for test_create_reaction_on_hidden_note(). + * + * @return array[] + */ + public function data_hidden_note_statuses() { + return array( + 'trash' => array( 'trash' ), + 'spam' => array( 'spam' ), + ); + } + + /** + * Resolving a thread approves its root note and the editor disables + * reactions from then on, so the server rejects them too, on the root + * note and on its replies. + * + * @ticket 63191 + * + * @dataProvider data_resolved_thread_targets + * + * @param bool $on_reply Whether to react to a reply rather than the root note. + */ + public function test_create_reaction_on_resolved_thread( $on_reply ) { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + $target = $note_id; + if ( $on_reply ) { + $target = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test reply', + ) + ); + } + wp_set_comment_status( $note_id, 'approve' ); + + $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' => $target, + 'content' => '2764', + 'type' => 'reaction', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); + $this->assertSame( 'A reaction cannot be added to a resolved note.', $response->as_error()->get_error_message() ); + } + + /** + * Data provider for test_create_reaction_on_resolved_thread(). + * + * @return array[] + */ + public function data_resolved_thread_targets() { + return array( + 'root note' => array( false ), + 'reply' => array( true ), + ); + } + + /** + * A reaction can be added to a reply in an open thread. + * + * @ticket 63191 + */ + public function test_create_reaction_on_reply_in_open_thread() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + $reply_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test reply', + ) + ); + + $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' => $reply_id, + 'content' => '2764', + 'type' => 'reaction', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + } + /** * Listing notes returns each note's reaction summary without a per-note query. * @@ -5669,7 +5915,7 @@ public function test_reaction_summary_is_not_exposed_to_logged_out_users() { array( 'comment_post_ID' => self::$post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -5703,7 +5949,7 @@ public function test_reaction_summary_counts_reactions_from_multiple_users() { array( 'comment_post_ID' => self::$post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) @@ -5752,7 +5998,7 @@ public function test_reaction_summary_excludes_trashed_reactions() { array( 'comment_post_ID' => self::$post_id, 'comment_type' => 'note', - 'comment_approved' => 1, + 'comment_approved' => 0, 'user_id' => self::$editor_id, 'comment_content' => 'Test note', ) From 4a380526767e698b63c4749d5f5061fc2212fd2c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 7 Oct 2026 23:53:09 +0200 Subject: [PATCH 29/34] Comments: Stop restoring a note's reactions when the note is untrashed. wp_untrash_comment() restores no children of any type, including a note's replies, so bringing back only reactions was inconsistent and needed a `_wp_trash_meta_with_note` marker to tell them apart from reactions the user had removed. Leave restoring children to a cascade that covers every child type together, matching the Gutenberg implementation. Reactions are still trashed with their note and permanently deleted with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017a8BrMHjzF5oUjpCyXWoRu --- src/wp-includes/comment.php | 26 +++-------- tests/phpunit/tests/comment.php | 79 +++++++++++++++------------------ 2 files changed, 41 insertions(+), 64 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index f854e6b164dff..11f5e5fab6e55 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -374,8 +374,8 @@ function _wp_get_internal_comment_types(): array { /** * Retrieves the IDs of a note's reaction comments. * - * Reactions hang off a note as child comments, so they have to be trashed, - * restored and deleted along with it. + * Reactions hang off a note as child comments, so they have to be trashed + * and deleted along with it. * * @since 7.2.0 * @@ -1803,14 +1803,12 @@ function wp_trash_comment( $comment_id ) { * cascade below trashes each reply in turn, which brings the replies' * own reactions along through this same branch. * - * Each one is flagged so that restoring the note brings back only these - * reactions, and not ones the user had already removed, which are - * trashed too. + * Restoring the note does not bring its reactions back: + * wp_untrash_comment() restores no children of any type, so restoring + * children is left to a cascade that covers every child type together. */ foreach ( wp_get_note_reaction_ids( $comment, 'approve' ) as $reaction_id ) { - if ( wp_trash_comment( $reaction_id ) ) { - add_comment_meta( $reaction_id, '_wp_trash_meta_with_note', '1', true ); - } + wp_trash_comment( $reaction_id ); } // For top level 'note' type comments, also trash children. @@ -1842,7 +1840,6 @@ function wp_trash_comment( $comment_id ) { * Removes a comment from the Trash * * @since 2.9.0 - * @since 7.2.0 A note's reactions that were trashed along with it are restored. * * @param int|WP_Comment $comment_id Comment ID or WP_Comment object. * @return bool True on success, false on failure. @@ -1872,7 +1869,6 @@ function wp_untrash_comment( $comment_id ) { if ( wp_set_comment_status( $comment, $status ) ) { delete_comment_meta( $comment->comment_ID, '_wp_trash_meta_time' ); delete_comment_meta( $comment->comment_ID, '_wp_trash_meta_status' ); - delete_comment_meta( $comment->comment_ID, '_wp_trash_meta_with_note' ); /** * Fires immediately after a comment is restored from the Trash. @@ -1885,16 +1881,6 @@ function wp_untrash_comment( $comment_id ) { */ do_action( 'untrashed_comment', $comment->comment_ID, $comment ); - /* - * Restore the reactions that were trashed along with the note. Reactions - * the user removed before that stay in the trash. - */ - foreach ( wp_get_note_reaction_ids( $comment, 'trash' ) as $reaction_id ) { - if ( get_comment_meta( $reaction_id, '_wp_trash_meta_with_note', true ) ) { - wp_untrash_comment( $reaction_id ); - } - } - return true; } diff --git a/tests/phpunit/tests/comment.php b/tests/phpunit/tests/comment.php index 4bc61b1d2d7b6..f0e7db55e5946 100644 --- a/tests/phpunit/tests/comment.php +++ b/tests/phpunit/tests/comment.php @@ -2139,16 +2139,15 @@ public function test_wp_delete_comment_still_reparents_non_note_children() { } /** - * Tests that trashing and restoring a note carries its reactions along. + * Tests that trashing a note carries its reactions along. * * Core cascades a trashed note to its `note` children only, so without this * a reaction stays approved under a trashed note. * * @ticket 63191 * @covers ::wp_trash_comment - * @covers ::wp_untrash_comment */ - public function test_wp_trash_comment_trashes_and_restores_note_reactions() { + public function test_wp_trash_comment_trashes_note_reactions() { if ( ! EMPTY_TRASH_DAYS ) { $this->markTestSkipped( 'Trash is disabled, so trashing permanently deletes.' ); } @@ -2166,47 +2165,6 @@ public function test_wp_trash_comment_trashes_and_restores_note_reactions() { wp_trash_comment( $note_id ); $this->assertSame( 'trash', get_comment( $reaction_id )->comment_approved, 'The reaction stayed approved under a trashed note.' ); - - wp_untrash_comment( $note_id ); - $this->assertSame( '1', get_comment( $reaction_id )->comment_approved, 'The reaction was not restored with its note.' ); - } - - /** - * Tests that restoring a note leaves reactions the user had removed in the trash. - * - * Removing a reaction trashes it, so a note can have both a removed reaction - * and a live one with the same emoji. Only the live one was trashed along - * with the note, so only it should come back. - * - * @ticket 63191 - * @covers ::wp_trash_comment - * @covers ::wp_untrash_comment - */ - public function test_wp_untrash_comment_does_not_restore_removed_note_reactions() { - if ( ! EMPTY_TRASH_DAYS ) { - $this->markTestSkipped( 'Trash is disabled, so trashing permanently deletes.' ); - } - - $note_id = self::factory()->comment->create( - array( - 'comment_post_ID' => self::$post_id, - 'comment_type' => 'note', - 'comment_parent' => 0, - 'comment_approved' => '1', - ) - ); - - $removed_id = $this->create_reaction_on_note( $note_id ); - wp_trash_comment( $removed_id ); - - $live_id = $this->create_reaction_on_note( $note_id ); - - wp_trash_comment( $note_id ); - wp_untrash_comment( $note_id ); - - $this->assertSame( 'trash', get_comment( $removed_id )->comment_approved, 'A reaction the user removed was restored with its note.' ); - $this->assertSame( '1', get_comment( $live_id )->comment_approved, 'The live reaction was not restored with its note.' ); - $this->assertSame( '', get_comment_meta( $live_id, '_wp_trash_meta_with_note', true ), 'The restored reaction kept its cascade flag.' ); } /** @@ -2274,6 +2232,39 @@ public function test_wp_delete_comment_deletes_trashed_note_reactions() { $this->assertNull( get_comment( $trashed ), 'The trashed reaction was left behind.' ); } + /** + * Trashing a note trashes its reactions, so permanently deleting the + * trashed note must take those reactions along, as well as any the user + * removed earlier. + * + * @ticket 63191 + * + * @covers ::wp_delete_comment + */ + public function test_wp_delete_comment_deletes_reactions_of_trashed_note() { + if ( ! EMPTY_TRASH_DAYS ) { + $this->markTestSkipped( 'Trash is disabled, so trashing permanently deletes.' ); + } + + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => '1', + ) + ); + + $removed_id = $this->create_reaction_on_note( $note_id ); + wp_trash_comment( $removed_id ); + $live_id = $this->create_reaction_on_note( $note_id ); + + wp_trash_comment( $note_id ); + wp_delete_comment( $note_id, true ); + + $this->assertNull( get_comment( $live_id ), 'A reaction trashed with its note outlived it.' ); + $this->assertNull( get_comment( $removed_id ), 'A reaction the user removed outlived its note.' ); + } + /** * @ticket 63191 * From 2be8091823d6a0eeb5b430d91cca0487be674a1f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 7 Oct 2026 23:54:21 +0200 Subject: [PATCH 30/34] REST API: Check a reaction's post before its parent note's status. The trashed, spam and resolved checks ran before the check that the parent note belongs to the reaction's post. A user who can edit one post could pass the ID of a note on a post they cannot edit and tell from the error whether that note is trashed, spammed or resolved. Run the same-post check first, so a note on another post always gets the same error. `post` is already required for every comment type by create_item_permissions_check(), which also checks edit access to it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017a8BrMHjzF5oUjpCyXWoRu --- .../class-wp-rest-comments-controller.php | 24 ++++--- .../rest-api/rest-comments-controller.php | 62 +++++++++++++++++++ 2 files changed, 77 insertions(+), 9 deletions(-) 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 9e6454f4e8a7e..1a5c814ef218f 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 @@ -759,6 +759,21 @@ public function create_item( $request ) { ); } + /* + * The parent note must belong to the post the reaction targets. + * create_item_permissions_check() requires `post` and checks that the + * user can edit it, so this runs before the parent's status checks: + * a note on another post gets the same error whatever its status, + * rather than revealing whether it is trashed, spammed or resolved. + */ + if ( ! empty( $request['post'] ) && (int) $parent_comment->comment_post_ID !== (int) $request['post'] ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction must be attached to a note on the same post.' ), + array( 'status' => 400 ) + ); + } + // A reaction under a trashed or spammed note would escape the trash cascade. if ( in_array( $parent_comment->comment_approved, array( 'trash', 'spam' ), true ) ) { return new WP_Error( @@ -795,15 +810,6 @@ public function create_item( $request ) { ); } - // The parent note must belong to the post the reaction targets. - if ( ! empty( $request['post'] ) && (int) $parent_comment->comment_post_ID !== (int) $request['post'] ) { - return new WP_Error( - 'rest_comment_invalid_parent', - __( 'A reaction must be attached to a note on the same post.' ), - array( 'status' => 400 ) - ); - } - /* * Validate the reaction content: the hex key of one of the curated * reaction emoji, as listed by self::get_note_reaction_keys() (e.g. diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index fe23c5b5f1e1b..1a60cfc40d437 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -5792,6 +5792,68 @@ public function data_resolved_thread_targets() { ); } + /** + * A note on a post the user cannot edit gets the same error whatever its + * status, so a reaction request cannot reveal whether that note is + * trashed, spammed or resolved. + * + * @ticket 63191 + * + * @dataProvider data_other_post_note_states + * + * @param string $state The state to put the other post's note in. + */ + public function test_create_reaction_on_note_from_uneditable_post_does_not_reveal_its_status( $state ) { + $own_post_id = self::factory()->post->create( array( 'post_author' => self::$author_id ) ); + $other_post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $other_post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Note on another post', + ) + ); + if ( 'open' !== $state ) { + wp_set_comment_status( $note_id, $state ); + } + + wp_set_current_user( self::$author_id ); + $this->assertFalse( current_user_can( 'edit_post', $other_post_id ), 'The user should not be able to edit the other post.' ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $own_post_id, + 'parent' => $note_id, + 'content' => '2764', + 'type' => 'reaction', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); + $this->assertSame( 'A reaction must be attached to a note on the same post.', $response->as_error()->get_error_message() ); + } + + /** + * Data provider for test_create_reaction_on_note_from_uneditable_post_does_not_reveal_its_status(). + * + * @return array[] + */ + public function data_other_post_note_states() { + return array( + 'open' => array( 'open' ), + 'trash' => array( 'trash' ), + 'spam' => array( 'spam' ), + 'resolved' => array( 'approve' ), + ); + } + /** * A reaction can be added to a reply in an open thread. * From 92b0795437ad4e9f9bceb49c909d689d792b01a7 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 7 Oct 2026 23:54:58 +0200 Subject: [PATCH 31/34] Tests: Cover reaction read access and paged reaction summaries. Mirror two Gutenberg tests: a reaction is readable only by its author and users who can edit it, and a later page of notes still builds its reaction summaries with two queries rather than one per note. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017a8BrMHjzF5oUjpCyXWoRu --- .../rest-api/rest-comments-controller.php | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 1a60cfc40d437..ec7dc0bc89eef 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -5854,6 +5854,121 @@ public function data_other_post_note_states() { ); } + /** + * Reactions are an internal comment type and are not world-readable, even + * when approved on a public post. Only the reacting user or a user who can + * edit the comment can read one. + * + * @ticket 63191 + */ + public function test_reaction_is_not_publicly_readable() { + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_author' => self::$editor_id, + ) + ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + $reaction_id = 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' => '2764', + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $reaction_id ); + + wp_set_current_user( 0 ); + $this->assertErrorResponse( 'rest_cannot_read', rest_get_server()->dispatch( $request ), 401 ); + + wp_set_current_user( self::$subscriber_id ); + $this->assertErrorResponse( 'rest_cannot_read', rest_get_server()->dispatch( $request ), 403 ); + + wp_set_current_user( self::$editor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status(), 'The reacting user should be able to read their reaction.' ); + $this->assertSame( $reaction_id, $response->get_data()['id'] ); + + wp_set_current_user( self::$admin_id ); + $this->assertSame( 200, rest_get_server()->dispatch( $request )->get_status(), 'A user who can edit the reaction should be able to read it.' ); + } + + /** + * A later page of notes summarizes its own notes' reactions with the same + * two queries as the first page, rather than one query per note. + * + * @ticket 63191 + */ + public function test_second_page_of_notes_keeps_reaction_summary_queries_bounded() { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_ids = self::factory()->comment->create_many( + 4, + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + foreach ( $note_ids as $note_id ) { + 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' => '2764', + ) + ); + } + + $summary_queries = 0; + $count_queries = static function ( $query ) use ( &$summary_queries ) { + if ( str_contains( $query, "comment_type = 'reaction'" ) ) { + ++$summary_queries; + } + return $query; + }; + add_filter( 'query', $count_queries ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + $request->set_param( 'post', $post_id ); + $request->set_param( 'type', 'note' ); + $request->set_param( 'status', 'all' ); + $request->set_param( 'context', 'edit' ); + $request->set_param( 'per_page', 2 ); + $request->set_param( 'page', 2 ); + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'query', $count_queries ); + + $data = $response->get_data(); + $this->assertCount( 2, $data ); + foreach ( $data as $note ) { + $this->assertSame( 1, $note['reaction_summary']['2764']['count'] ); + $this->assertGreaterThan( 0, $note['reaction_summary']['2764']['current_user_reaction'] ); + } + + // One counts query and one current-user query for the whole page. + $this->assertSame( 2, $summary_queries ); + } + /** * A reaction can be added to a reply in an open thread. * From ff65798c2f9f07bd8d8c3ebf9100e1e799a251d8 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 9 Oct 2026 10:55:05 +0200 Subject: [PATCH 32/34] Comments: Inline the note and reaction types instead of a helper. Drop _wp_get_internal_comment_types() and hardcode 'note' and 'reaction' where they are excluded or checked. Deciding what counts as an internal comment type is better left to a future custom comment types API, since reactions may later apply to public comments too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GEVg3cSmkAtseftqMbo4Zz --- .../includes/class-wp-comments-list-table.php | 4 +- src/wp-admin/includes/comment.php | 13 +---- src/wp-includes/class-wp-comment-query.php | 12 ++--- src/wp-includes/class-wp-query.php | 30 ++--------- src/wp-includes/comment.php | 50 ++----------------- src/wp-includes/link-template.php | 2 +- .../class-wp-rest-comments-controller.php | 12 ++--- .../includes/comment/CommentExists_Test.php | 4 +- .../tests/admin/wpCommentsListTable.php | 2 +- tests/phpunit/tests/comment.php | 13 ----- .../tests/comment/getLastCommentModified.php | 4 +- tests/phpunit/tests/comment/query.php | 2 +- tests/qunit/fixtures/wp-api-generated.js | 4 +- 13 files changed, 33 insertions(+), 119 deletions(-) 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 b4996b0d10ee9..1dff5d26755ce 100644 --- a/src/wp-admin/includes/class-wp-comments-list-table.php +++ b/src/wp-admin/includes/class-wp-comments-list-table.php @@ -107,7 +107,7 @@ public function prepare_items() { $comment_type = ''; - if ( ! empty( $_REQUEST['comment_type'] ) && ! in_array( $_REQUEST['comment_type'], _wp_get_internal_comment_types(), true ) ) { + if ( ! empty( $_REQUEST['comment_type'] ) && ! in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) { $comment_type = $_REQUEST['comment_type']; } @@ -157,7 +157,7 @@ public function prepare_items() { 'number' => $number, 'post_id' => $post_id, 'type' => $comment_type, - 'type__not_in' => _wp_get_internal_comment_types(), + 'type__not_in' => array( 'note', 'reaction' ), '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 cdb8af1efa10d..1cfc93144e978 100644 --- a/src/wp-admin/includes/comment.php +++ b/src/wp-admin/includes/comment.php @@ -223,7 +223,7 @@ function get_comment_to_edit( $id ) { * * @since 2.3.0 * @since 6.9.0 Exclude the 'note' comment type from the count. - * @since 7.2.0 Exclude every internal comment type from the count. + * @since 7.2.0 Exclude the 'reaction' comment type from the count. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -243,16 +243,7 @@ function get_pending_comments_num( $post_id ) { $post_id_array = array_map( 'intval', $post_id_array ); $post_id_in = "'" . implode( "', '", $post_id_array ) . "'"; - $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 - ); + $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 NOT IN ( 'note', 'reaction' ) GROUP BY comment_post_ID", ARRAY_A ); if ( $single ) { if ( empty( $pending ) ) { diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index 095d055c68f78..76367e43ee08a 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -549,7 +549,7 @@ public function get_comments() { * * @since 4.4.0 * @since 6.9.0 Excludes the 'note' comment type, unless 'all' or the 'note' types are requested. - * @since 7.2.0 Excludes every internal comment type, unless 'all' or that type is requested. + * @since 7.2.0 Excludes the 'reaction' comment type, unless 'all' or the 'reaction' types are requested. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -785,14 +785,14 @@ protected function get_comment_ids() { 'NOT IN' => (array) $this->query_vars['type__not_in'], ); - // Exclude internal comment types, unless 'all' types or a specific internal type is explicitly requested. + // Exclude the 'note' and 'reaction' comment types, unless 'all' types or that type explicitly are requested. if ( ! in_array( 'all', $raw_types['IN'], true ) ) { - foreach ( _wp_get_internal_comment_types() as $internal_type ) { + foreach ( array( 'note', 'reaction' ) as $excluded_type ) { if ( - ! in_array( $internal_type, $raw_types['IN'], true ) && - ! in_array( $internal_type, $raw_types['NOT IN'], true ) + ! in_array( $excluded_type, $raw_types['IN'], true ) && + ! in_array( $excluded_type, $raw_types['NOT IN'], true ) ) { - $raw_types['NOT IN'][] = $internal_type; + $raw_types['NOT IN'][] = $excluded_type; } } } diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 60dfd1fea93b0..4f98934468c29 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2801,15 +2801,13 @@ public function get_posts() { // Comments feeds. if ( $this->is_comment_feed && ! $this->is_singular ) { - $internal_types_where = $this->get_comment_feed_internal_types_where(); - if ( $this->is_archive || $this->is_search ) { $cjoin = "JOIN {$wpdb->posts} ON ( {$wpdb->comments}.comment_post_ID = {$wpdb->posts}.ID ) $join "; - $cwhere = "WHERE comment_approved = '1' AND $internal_types_where $where"; + $cwhere = "WHERE comment_approved = '1' AND {$wpdb->comments}.comment_type NOT IN ( 'note', 'reaction' ) $where"; $cgroupby = "{$wpdb->comments}.comment_id"; } else { // Other non-singular, e.g. front. $cjoin = "JOIN {$wpdb->posts} ON ( {$wpdb->comments}.comment_post_ID = {$wpdb->posts}.ID )"; - $cwhere = "WHERE ( post_status = 'publish' OR ( post_status = 'inherit' AND post_type = 'attachment' ) ) AND comment_approved = '1' AND $internal_types_where"; + $cwhere = "WHERE ( post_status = 'publish' OR ( post_status = 'inherit' AND post_type = 'attachment' ) ) AND comment_approved = '1' AND {$wpdb->comments}.comment_type NOT IN ( 'note', 'reaction' )"; $cgroupby = ''; } @@ -3526,7 +3524,7 @@ public function get_posts() { $cjoin = apply_filters_ref_array( 'comment_feed_join', array( '', &$this ) ); /** This filter is documented in wp-includes/class-wp-query.php */ - $cwhere = apply_filters_ref_array( 'comment_feed_where', array( "WHERE comment_post_ID = '{$this->posts[0]->ID}' AND comment_approved = '1' AND " . $this->get_comment_feed_internal_types_where(), &$this ) ); + $cwhere = apply_filters_ref_array( 'comment_feed_where', array( "WHERE comment_post_ID = '{$this->posts[0]->ID}' AND comment_approved = '1' AND {$wpdb->comments}.comment_type NOT IN ( 'note', 'reaction' )", &$this ) ); /** This filter is documented in wp-includes/class-wp-query.php */ $cgroupby = apply_filters_ref_array( 'comment_feed_groupby', array( '', &$this ) ); @@ -3657,28 +3655,6 @@ public function get_posts() { return $this->posts; } - /** - * Builds the SQL condition that keeps internal comment types out of comment feeds. - * - * Notes and reactions are stored approved, so without this condition they - * would be published in the site and post comment feeds. - * - * @since 7.2.0 - * - * @global wpdb $wpdb WordPress database abstraction object. - * - * @return string SQL condition, without a leading `AND`. - */ - private function get_comment_feed_internal_types_where() { - global $wpdb; - - $internal_types = _wp_get_internal_comment_types(); - $placeholders = implode( ', ', array_fill( 0, count( $internal_types ), '%s' ) ); - - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare - return $wpdb->prepare( "{$wpdb->comments}.comment_type NOT IN ( $placeholders )", $internal_types ); - } - /** * Sets up the amount of found posts and the number of pages (if limit clause was used) * for the current query. diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 11f5e5fab6e55..f290c3b27da6a 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -351,26 +351,6 @@ 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. - * - * This is a private helper and not a public API. It may change or be - * replaced once a custom comment types API exists. - * - * @since 7.2.0 - * @access private - * - * @return string[] List of internal comment type slugs. - */ -function _wp_get_internal_comment_types(): array { - return array( 'note', 'reaction' ); -} - /** * Retrieves the IDs of a note's reaction comments. * @@ -455,7 +435,7 @@ function get_default_comment_status( $post_type = 'post', $comment_type = 'comme * @since 1.5.0 * @since 4.7.0 Replaced caching the modified date in a local static variable * with the Object Cache API. - * @since 7.2.0 Internal comment types are excluded from the query. + * @since 7.2.0 The 'note' and 'reaction' comment types are excluded from the query. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -473,30 +453,17 @@ function get_lastcommentmodified( $timezone = 'server' ) { return $comment_modified_date; } - // Exclude internal comment types (notes, reactions, etc.) from the lookup. - $internal_types = _wp_get_internal_comment_types(); - if ( ! empty( $internal_types ) ) { - $placeholders = implode( ', ', array_fill( 0, count( $internal_types ), '%s' ) ); - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare - $type_not_in = $wpdb->prepare( " AND comment_type NOT IN ( $placeholders )", $internal_types ); - } else { - $type_not_in = ''; - } - switch ( $timezone ) { case 'gmt': - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $comment_modified_date = $wpdb->get_var( "SELECT comment_date_gmt FROM $wpdb->comments WHERE comment_approved = '1'{$type_not_in} ORDER BY comment_date_gmt DESC LIMIT 1" ); + $comment_modified_date = $wpdb->get_var( "SELECT comment_date_gmt FROM $wpdb->comments WHERE comment_approved = '1' AND comment_type NOT IN ( 'note', 'reaction' ) ORDER BY comment_date_gmt DESC LIMIT 1" ); break; case 'blog': - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $comment_modified_date = $wpdb->get_var( "SELECT comment_date FROM $wpdb->comments WHERE comment_approved = '1'{$type_not_in} ORDER BY comment_date_gmt DESC LIMIT 1" ); + $comment_modified_date = $wpdb->get_var( "SELECT comment_date FROM $wpdb->comments WHERE comment_approved = '1' AND comment_type NOT IN ( 'note', 'reaction' ) ORDER BY comment_date_gmt DESC LIMIT 1" ); break; case 'server': $add_seconds_server = gmdate( 'Z' ); - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $comment_modified_date = $wpdb->get_var( $wpdb->prepare( "SELECT DATE_ADD(comment_date_gmt, INTERVAL %s SECOND) FROM $wpdb->comments WHERE comment_approved = '1'{$type_not_in} ORDER BY comment_date_gmt DESC LIMIT 1", $add_seconds_server ) ); + $comment_modified_date = $wpdb->get_var( $wpdb->prepare( "SELECT DATE_ADD(comment_date_gmt, INTERVAL %s SECOND) FROM $wpdb->comments WHERE comment_approved = '1' AND comment_type NOT IN ( 'note', 'reaction' ) ORDER BY comment_date_gmt DESC LIMIT 1", $add_seconds_server ) ); break; } @@ -3253,14 +3220,7 @@ 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 ) ) { - $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 ) - ) - ); + $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 ( 'note', 'reaction' )", $post_id ) ); } else { $new = (int) $new; } diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index 65adb2514bbe7..f6bd62e0e3ca5 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -4383,7 +4383,7 @@ function is_avatar_comment_type( $comment_type ) { * @param array $types An array of content types. Default contains 'comment', 'note', * and 'reaction'. */ - $allowed_comment_types = apply_filters( 'get_avatar_comment_types', array_merge( array( 'comment' ), _wp_get_internal_comment_types() ) ); + $allowed_comment_types = apply_filters( 'get_avatar_comment_types', array( 'comment', 'note', 'reaction' ) ); 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 1a5c814ef218f..bc3dbb25e396c 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 @@ -151,7 +151,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 = in_array( $request['type'], _wp_get_internal_comment_types(), true ); + $is_note = in_array( $request['type'], array( 'note', 'reaction' ), true ); $is_edit_context = 'edit' === $request['context']; $protected_params = array( 'author', 'author_exclude', 'author_email', 'type', 'status' ); $forbidden_params = array(); @@ -485,7 +485,7 @@ public function get_item_permissions_check( $request ) { } // 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' ); + $edit_cap = in_array( $comment->comment_type, array( 'note', 'reaction' ), 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', @@ -544,7 +544,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'] ) && in_array( $request['type'], _wp_get_internal_comment_types(), true ); + $is_note = ! empty( $request['type'] ) && in_array( $request['type'], array( 'note', 'reaction' ), true ); if ( ! is_user_logged_in() && $is_note ) { return new WP_Error( @@ -724,7 +724,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_merge( array( 'comment' ), _wp_get_internal_comment_types() ), true ) ) { + if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note', 'reaction' ), true ) ) { return new WP_Error( 'rest_invalid_comment_type', __( 'Cannot create a comment with that type.' ), @@ -983,7 +983,7 @@ public function create_item( $request ) { // Don't check for duplicates or flooding for notes or reactions. $prepared_comment['comment_approved'] = - in_array( $prepared_comment['comment_type'], _wp_get_internal_comment_types(), true ) ? + in_array( $prepared_comment['comment_type'], array( 'note', 'reaction' ), true ) ? '1' : wp_allow_comment( $prepared_comment, true ); @@ -2381,7 +2381,7 @@ protected function prefetch_reaction_summaries( $note_ids ) { * @return bool Whether the comment can be read. */ protected function check_read_permission( $comment, $request ) { - if ( ! in_array( $comment->comment_type, _wp_get_internal_comment_types(), true ) && ! empty( $comment->comment_post_ID ) ) { + if ( ! in_array( $comment->comment_type, array( 'note', 'reaction' ), 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 ) { diff --git a/tests/phpunit/tests/admin/includes/comment/CommentExists_Test.php b/tests/phpunit/tests/admin/includes/comment/CommentExists_Test.php index f1c6dc939d153..10a8fddbbe3d4 100644 --- a/tests/phpunit/tests/admin/includes/comment/CommentExists_Test.php +++ b/tests/phpunit/tests/admin/includes/comment/CommentExists_Test.php @@ -101,7 +101,7 @@ public function test_get_pending_comments_num_excludes_internal_comment_types() ) ); - foreach ( _wp_get_internal_comment_types() as $internal_type ) { + foreach ( array( 'note', 'reaction' ) as $internal_type ) { self::factory()->comment->create( array( 'comment_post_ID' => $post_id, @@ -132,7 +132,7 @@ public function test_get_pending_comments_num_for_multiple_posts_excludes_intern ) ); - foreach ( _wp_get_internal_comment_types() as $internal_type ) { + foreach ( array( 'note', 'reaction' ) as $internal_type ) { self::factory()->comment->create( array( 'comment_post_ID' => $notes_only, diff --git a/tests/phpunit/tests/admin/wpCommentsListTable.php b/tests/phpunit/tests/admin/wpCommentsListTable.php index 960ddd466a5ca..f3693d58cc9ce 100644 --- a/tests/phpunit/tests/admin/wpCommentsListTable.php +++ b/tests/phpunit/tests/admin/wpCommentsListTable.php @@ -227,7 +227,7 @@ public function test_get_views_should_return_views_by_default() { */ public function test_comments_list_table_does_not_show_internal_comment_types( string $comment_type ) { $post_id = self::factory()->post->create(); - foreach ( _wp_get_internal_comment_types() as $internal_type ) { + foreach ( array( 'note', 'reaction' ) as $internal_type ) { self::factory()->comment->create( array( 'comment_post_ID' => $post_id, diff --git a/tests/phpunit/tests/comment.php b/tests/phpunit/tests/comment.php index f0e7db55e5946..474c31be974a9 100644 --- a/tests/phpunit/tests/comment.php +++ b/tests/phpunit/tests/comment.php @@ -2265,19 +2265,6 @@ public function test_wp_delete_comment_deletes_reactions_of_trashed_note() { $this->assertNull( get_comment( $removed_id ), 'A reaction the user removed outlived its note.' ); } - /** - * @ticket 63191 - * - * @covers ::_wp_get_internal_comment_types - */ - public function test_wp_get_internal_comment_types() { - $types = _wp_get_internal_comment_types(); - - $this->assertContains( 'note', $types ); - $this->assertContains( 'reaction', $types ); - $this->assertNotContains( 'comment', $types, 'Discussion comments are not an internal type.' ); - } - /** * @ticket 63191 * diff --git a/tests/phpunit/tests/comment/getLastCommentModified.php b/tests/phpunit/tests/comment/getLastCommentModified.php index f1fdb11c5d83c..20e7119e1188d 100644 --- a/tests/phpunit/tests/comment/getLastCommentModified.php +++ b/tests/phpunit/tests/comment/getLastCommentModified.php @@ -157,7 +157,7 @@ public function test_internal_comment_types_are_excluded( $timezone, $expected ) ) ); - foreach ( _wp_get_internal_comment_types() as $comment_type ) { + foreach ( array( 'note', 'reaction' ) as $comment_type ) { self::factory()->comment->create( array( 'comment_status' => 1, @@ -199,7 +199,7 @@ public function data_internal_comment_types_are_excluded() { * @ticket 63191 */ public function test_only_internal_comment_types_returns_false() { - foreach ( _wp_get_internal_comment_types() as $comment_type ) { + foreach ( array( 'note', 'reaction' ) as $comment_type ) { self::factory()->comment->create( array( 'comment_status' => 1, diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index c6a31bc715ee4..50d762f85cfc2 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5592,7 +5592,7 @@ public function test_internal_comment_types_not_duplicated_in_type__not_in() { $this->assertNotContains( $comments['note'], $found ); $this->assertNotContains( $comments['reaction'], $found ); - foreach ( _wp_get_internal_comment_types() as $internal_type ) { + foreach ( array( 'note', 'reaction' ) as $internal_type ) { $this->assertSame( 1, substr_count( $wpdb->last_query, "'" . $internal_type . "'" ), diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 5d209f1c79820..6026645638297 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -7,7 +7,7 @@ var mockedApiResponse = {}; mockedApiResponse.Schema = { "name": "Test Blog", - "description": "", + "description": false, "url": "http://example.org", "home": "http://example.org", "gmt_offset": "0", @@ -14923,7 +14923,7 @@ mockedApiResponse.CommentModel = { mockedApiResponse.settings = { "title": "Test Blog", - "description": "", + "description": null, "url": "http://example.org", "email": "admin@example.org", "timezone": "", From b08b3a3cf1f77381f676b65f0e4d1575619114f7 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 9 Oct 2026 11:07:48 +0200 Subject: [PATCH 33/34] Tests: Restore the blog description values in the REST API fixture. The previous commit regenerated wp-api-generated.js against a local site with a different tagline, so CI's regenerated fixture no longer matched and the "version-controlled files are not modified" check failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019bw9jxkJwGzejoE6KbwXg2 --- tests/qunit/fixtures/wp-api-generated.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 6026645638297..5d209f1c79820 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -7,7 +7,7 @@ var mockedApiResponse = {}; mockedApiResponse.Schema = { "name": "Test Blog", - "description": false, + "description": "", "url": "http://example.org", "home": "http://example.org", "gmt_offset": "0", @@ -14923,7 +14923,7 @@ mockedApiResponse.CommentModel = { mockedApiResponse.settings = { "title": "Test Blog", - "description": null, + "description": "", "url": "http://example.org", "email": "admin@example.org", "timezone": "", From d4eeff0a2d8df552559117eef678d84346f2f949 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 9 Oct 2026 11:29:04 +0200 Subject: [PATCH 34/34] Tests: Use the reactions ticket number in the note reaction tests. The tests referenced #63191, an unrelated ticket. See #64638. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017FGp5zYv14MKoTuh3RFuWi --- .../includes/comment/CommentExists_Test.php | 4 +- .../tests/admin/wpCommentsListTable.php | 2 +- tests/phpunit/tests/comment.php | 22 +++--- .../tests/comment/getLastCommentModified.php | 4 +- tests/phpunit/tests/comment/query.php | 6 +- tests/phpunit/tests/query/commentFeed.php | 6 +- .../rest-api/rest-comments-controller.php | 78 +++++++++---------- 7 files changed, 61 insertions(+), 61 deletions(-) diff --git a/tests/phpunit/tests/admin/includes/comment/CommentExists_Test.php b/tests/phpunit/tests/admin/includes/comment/CommentExists_Test.php index 10a8fddbbe3d4..5c92c1b65e176 100644 --- a/tests/phpunit/tests/admin/includes/comment/CommentExists_Test.php +++ b/tests/phpunit/tests/admin/includes/comment/CommentExists_Test.php @@ -87,7 +87,7 @@ public function test_invalid_timezone_should_fall_back_on_blog() { * Internal comment types are not awaiting moderation, so they must not be * counted as pending. * - * @ticket 63191 + * @ticket 64638 * * @covers ::get_pending_comments_num */ @@ -117,7 +117,7 @@ public function test_get_pending_comments_num_excludes_internal_comment_types() /** * The array form of the count excludes internal comment types too. * - * @ticket 63191 + * @ticket 64638 * * @covers ::get_pending_comments_num */ diff --git a/tests/phpunit/tests/admin/wpCommentsListTable.php b/tests/phpunit/tests/admin/wpCommentsListTable.php index f3693d58cc9ce..0c0b349f5f422 100644 --- a/tests/phpunit/tests/admin/wpCommentsListTable.php +++ b/tests/phpunit/tests/admin/wpCommentsListTable.php @@ -219,7 +219,7 @@ public function test_get_views_should_return_views_by_default() { * * @ticket 64198 * @ticket 64474 - * @ticket 63191 + * @ticket 64638 * * @dataProvider data_comment_type * diff --git a/tests/phpunit/tests/comment.php b/tests/phpunit/tests/comment.php index 474c31be974a9..c733b32515376 100644 --- a/tests/phpunit/tests/comment.php +++ b/tests/phpunit/tests/comment.php @@ -2036,7 +2036,7 @@ private function create_reaction_on_note( $note_id, $key = '2764' ) { * it would survive as an approved top-level row still carrying the * reactor's identity. * - * @ticket 63191 + * @ticket 64638 * @covers ::wp_delete_comment */ public function test_wp_delete_comment_deletes_note_reactions() { @@ -2073,7 +2073,7 @@ public function test_wp_delete_comment_deletes_note_reactions() { /** * Tests that deleting a note reply takes only that reply's reactions. * - * @ticket 63191 + * @ticket 64638 * @covers ::wp_delete_comment */ public function test_wp_delete_comment_deletes_note_reply_reactions() { @@ -2109,7 +2109,7 @@ public function test_wp_delete_comment_deletes_note_reply_reactions() { * * The reaction cascade must not change how any other comment type behaves. * - * @ticket 63191 + * @ticket 64638 * @covers ::wp_delete_comment */ public function test_wp_delete_comment_still_reparents_non_note_children() { @@ -2144,7 +2144,7 @@ public function test_wp_delete_comment_still_reparents_non_note_children() { * Core cascades a trashed note to its `note` children only, so without this * a reaction stays approved under a trashed note. * - * @ticket 63191 + * @ticket 64638 * @covers ::wp_trash_comment */ public function test_wp_trash_comment_trashes_note_reactions() { @@ -2170,7 +2170,7 @@ public function test_wp_trash_comment_trashes_note_reactions() { /** * Tests that trashing a note reply carries that reply's reactions along. * - * @ticket 63191 + * @ticket 64638 * @covers ::wp_trash_comment */ public function test_wp_trash_comment_trashes_note_reply_reactions() { @@ -2209,7 +2209,7 @@ public function test_wp_trash_comment_trashes_note_reply_reactions() { * A reaction the user already removed is trashed, not deleted, so deleting * its note must take it along too rather than leave it orphaned. * - * @ticket 63191 + * @ticket 64638 * * @covers ::wp_delete_comment */ @@ -2237,7 +2237,7 @@ public function test_wp_delete_comment_deletes_trashed_note_reactions() { * trashed note must take those reactions along, as well as any the user * removed earlier. * - * @ticket 63191 + * @ticket 64638 * * @covers ::wp_delete_comment */ @@ -2266,7 +2266,7 @@ public function test_wp_delete_comment_deletes_reactions_of_trashed_note() { } /** - * @ticket 63191 + * @ticket 64638 * * @covers ::wp_get_note_reaction_ids */ @@ -2296,7 +2296,7 @@ public function test_wp_get_note_reaction_ids_returns_reactions_oldest_first() { } /** - * @ticket 63191 + * @ticket 64638 * * @covers ::wp_get_note_reaction_ids */ @@ -2322,7 +2322,7 @@ public function test_wp_get_note_reaction_ids_filters_by_status() { /** * Only notes carry reactions. * - * @ticket 63191 + * @ticket 64638 * * @covers ::wp_get_note_reaction_ids * @@ -2358,7 +2358,7 @@ public function data_wp_get_note_reaction_ids_non_note_comments() { } /** - * @ticket 63191 + * @ticket 64638 * * @covers ::wp_get_note_reaction_ids */ diff --git a/tests/phpunit/tests/comment/getLastCommentModified.php b/tests/phpunit/tests/comment/getLastCommentModified.php index 20e7119e1188d..b04354f2bd766 100644 --- a/tests/phpunit/tests/comment/getLastCommentModified.php +++ b/tests/phpunit/tests/comment/getLastCommentModified.php @@ -141,7 +141,7 @@ public function test_cache_is_cleared_when_comment_is_trashed() { * Internal comment types are not user-facing discussion, so they must not * move the last comment modified date. * - * @ticket 63191 + * @ticket 64638 * * @dataProvider data_internal_comment_types_are_excluded * @@ -196,7 +196,7 @@ public function data_internal_comment_types_are_excluded() { /** * With nothing but internal comment types stored there is no last modified date. * - * @ticket 63191 + * @ticket 64638 */ public function test_only_internal_comment_types_returns_false() { foreach ( array( 'note', 'reaction' ) as $comment_type ) { diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index 50d762f85cfc2..935630f987415 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5487,7 +5487,7 @@ protected function create_internal_comment_type_test_comments(): array { /** * @ticket 64145 - * @ticket 63191 + * @ticket 64638 * @covers WP_Comment_Query::get_comment_ids * @dataProvider data_internal_comment_type_exclusion * @@ -5572,7 +5572,7 @@ public function data_internal_comment_type_exclusion(): array { /** * @ticket 64145 - * @ticket 63191 + * @ticket 64638 * @covers WP_Comment_Query::get_comment_ids */ public function test_internal_comment_types_not_duplicated_in_type__not_in() { @@ -5603,7 +5603,7 @@ public function test_internal_comment_types_not_duplicated_in_type__not_in() { /** * @ticket 64145 - * @ticket 63191 + * @ticket 64638 * @covers ::get_comment_count */ public function test_get_comment_count_excludes_internal_comment_types() { diff --git a/tests/phpunit/tests/query/commentFeed.php b/tests/phpunit/tests/query/commentFeed.php index df40c1ae76ac3..cc8e809bdf86e 100644 --- a/tests/phpunit/tests/query/commentFeed.php +++ b/tests/phpunit/tests/query/commentFeed.php @@ -88,7 +88,7 @@ public function test_archive_comment_feed_invalid_cache() { /** * @ticket 65613 - * @ticket 63191 + * @ticket 64638 * * @dataProvider data_internal_comment_types * @@ -121,7 +121,7 @@ public function test_main_comment_feed_should_exclude_internal_comment_types( st /** * @ticket 65613 - * @ticket 63191 + * @ticket 64638 * * @dataProvider data_internal_comment_types * @@ -155,7 +155,7 @@ public function test_archive_comment_feed_should_exclude_internal_comment_types( /** * @ticket 65613 - * @ticket 63191 + * @ticket 64638 * * @dataProvider data_internal_comment_types * diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index ec7dc0bc89eef..181a142d363d2 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4306,7 +4306,7 @@ public function data_comment_type_provider() { } /** - * @ticket 63191 + * @ticket 64638 */ public function test_create_reaction() { wp_set_current_user( self::$editor_id ); @@ -4348,7 +4348,7 @@ public function test_create_reaction() { } /** - * @ticket 63191 + * @ticket 64638 */ public function test_create_reaction_invalid_parent() { wp_set_current_user( self::$editor_id ); @@ -4383,7 +4383,7 @@ public function test_create_reaction_invalid_parent() { } /** - * @ticket 63191 + * @ticket 64638 */ public function test_create_reaction_no_parent() { wp_set_current_user( self::$editor_id ); @@ -4408,7 +4408,7 @@ public function test_create_reaction_no_parent() { } /** - * @ticket 63191 + * @ticket 64638 */ public function test_create_reaction_invalid_emoji() { wp_set_current_user( self::$editor_id ); @@ -4443,7 +4443,7 @@ public function test_create_reaction_invalid_emoji() { } /** - * @ticket 63191 + * @ticket 64638 */ public function test_create_reaction_duplicate() { wp_set_current_user( self::$editor_id ); @@ -4491,7 +4491,7 @@ public function test_create_reaction_duplicate() { } /** - * @ticket 63191 + * @ticket 64638 */ public function test_create_different_reactions_on_same_note() { wp_set_current_user( self::$editor_id ); @@ -4545,7 +4545,7 @@ public function test_create_different_reactions_on_same_note() { } /** - * @ticket 63191 + * @ticket 64638 */ public function test_create_reaction_requires_login() { wp_set_current_user( 0 ); @@ -4582,7 +4582,7 @@ public function test_create_reaction_requires_login() { * Each curated reaction emoji is accepted by its hex key: the emoji's * lowercase code points, padded to four digits. * - * @ticket 63191 + * @ticket 64638 * * @dataProvider data_curated_reaction_keys * @@ -4637,7 +4637,7 @@ public function data_curated_reaction_keys() { * Raw emoji bytes must be rejected - clients are expected to normalize * to a curated hex key before submitting. * - * @ticket 63191 + * @ticket 64638 */ public function test_create_reaction_rejects_raw_emoji() { wp_set_current_user( self::$editor_id ); @@ -4676,7 +4676,7 @@ public function test_create_reaction_rejects_raw_emoji() { * to the same note. Trashed reactions are invisible and must not block * re-adding. * - * @ticket 63191 + * @ticket 64638 */ public function test_create_reaction_after_trashing_previous_one() { wp_set_current_user( self::$editor_id ); @@ -4725,7 +4725,7 @@ public function test_create_reaction_after_trashing_previous_one() { /** * A reaction whose parent note belongs to a different post is rejected. * - * @ticket 63191 + * @ticket 64638 */ public function test_create_reaction_on_note_from_different_post() { wp_set_current_user( self::$editor_id ); @@ -4766,7 +4766,7 @@ public function test_create_reaction_on_note_from_different_post() { * submitted as well-formed hex keys, are rejected, as are the slugs an * earlier version of the API accepted and keys that are not lowercase. * - * @ticket 63191 + * @ticket 64638 * * @dataProvider data_uncurated_reaction_keys * @@ -4820,7 +4820,7 @@ public function data_uncurated_reaction_keys() { * around the key must not reach the database, or `reaction_summary` * grouping would split visually identical reactions. * - * @ticket 63191 + * @ticket 64638 */ public function test_create_reaction_stores_canonical_key() { wp_set_current_user( self::$editor_id ); @@ -4860,7 +4860,7 @@ public function test_create_reaction_stores_canonical_key() { /** * A reaction can be sent in the object form of `content`, like any comment. * - * @ticket 63191 + * @ticket 64638 */ public function test_create_reaction_accepts_raw_content_object() { wp_set_current_user( self::$editor_id ); @@ -4902,7 +4902,7 @@ public function test_create_reaction_accepts_raw_content_object() { * Held, spammed or trashed reactions are invisible to the uniqueness check * and the reaction summary, so repeated requests could pile them up. * - * @ticket 63191 + * @ticket 64638 * * @dataProvider data_create_reaction_status * @@ -4979,7 +4979,7 @@ public function data_create_reaction_status() { * request's check but before its own insert — and assert the post-insert * cleanup converges on a single surviving row. * - * @ticket 63191 + * @ticket 64638 */ public function test_concurrent_duplicate_reaction_converges_to_single_row() { wp_set_current_user( self::$editor_id ); @@ -5066,7 +5066,7 @@ static function ( $comment ) { * a competing request has already deleted this request's own row - the * losing side of the same race the test above covers from the winner. * - * @ticket 63191 + * @ticket 64638 */ public function test_concurrent_cleanup_deleting_own_row_still_returns_survivor() { wp_set_current_user( self::$editor_id ); @@ -5171,7 +5171,7 @@ private function create_reaction_for_update_tests( $post_id, $note_id, $user_id, * post and canonical key - and the generic update route re-validates none * of it, so updating a reaction is not allowed at all. * - * @ticket 63191 + * @ticket 64638 */ public function test_update_reaction_content_is_not_allowed() { $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); @@ -5199,7 +5199,7 @@ public function test_update_reaction_content_is_not_allowed() { /** * The reactor's identity must not be reassignable through the update route. * - * @ticket 63191 + * @ticket 64638 */ public function test_update_reaction_author_is_not_allowed() { $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); @@ -5227,7 +5227,7 @@ public function test_update_reaction_author_is_not_allowed() { /** * A reaction must not be movable onto a note on a post the user cannot edit. * - * @ticket 63191 + * @ticket 64638 */ public function test_update_reaction_cannot_move_to_note_on_another_post() { $editable_post = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); @@ -5277,7 +5277,7 @@ public function test_update_reaction_cannot_move_to_note_on_another_post() { /** * Only reactions are locked down; notes stay editable. * - * @ticket 63191 + * @ticket 64638 */ public function test_update_note_is_still_allowed() { $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); @@ -5305,7 +5305,7 @@ public function test_update_note_is_still_allowed() { * counts per emoji hex key, plus the current user's reaction ID as * `current_user_reaction`. * - * @ticket 63191 + * @ticket 64638 */ public function test_note_response_includes_reaction_summary() { wp_set_current_user( self::$editor_id ); @@ -5364,7 +5364,7 @@ public function test_note_response_includes_reaction_summary() { * Reactions are summarized in `reaction_summary`, so they neither change * where the link points nor make a note without replies advertise one. * - * @ticket 63191 + * @ticket 64638 */ public function test_note_children_link_ignores_reactions() { wp_set_current_user( self::$editor_id ); @@ -5420,7 +5420,7 @@ public function test_note_children_link_ignores_reactions() { /** * A reaction may only be added on the current user's own behalf. * - * @ticket 63191 + * @ticket 64638 */ public function test_create_reaction_cannot_be_attributed_to_another_user() { wp_set_current_user( self::$admin_id ); @@ -5472,7 +5472,7 @@ public function test_create_reaction_cannot_be_attributed_to_another_user() { * A reaction stored with `user_id` 0 is invisible to the uniqueness check and * to `reaction_summary`, so it could be added repeatedly and never removed. * - * @ticket 63191 + * @ticket 64638 */ public function test_create_reaction_ignores_request_author_fields() { wp_set_current_user( self::$editor_id ); @@ -5522,7 +5522,7 @@ public function test_create_reaction_ignores_request_author_fields() { /** * Removing a reaction takes it out of the note's summary. * - * @ticket 63191 + * @ticket 64638 */ public function test_delete_reaction() { wp_set_current_user( self::$editor_id ); @@ -5561,7 +5561,7 @@ public function test_delete_reaction() { /** * A user who cannot edit the note's post cannot remove a reaction on it. * - * @ticket 63191 + * @ticket 64638 */ public function test_delete_reaction_requires_edit_permission() { $post_id = self::factory()->post->create(); @@ -5597,7 +5597,7 @@ public function test_delete_reaction_requires_edit_permission() { * Only the user who added a reaction can remove it, even though other * users who can edit the post can edit the note it belongs to. * - * @ticket 63191 + * @ticket 64638 */ public function test_delete_reaction_of_another_user_is_not_allowed() { $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); @@ -5635,7 +5635,7 @@ public function test_delete_reaction_of_another_user_is_not_allowed() { /** * A reaction's author can remove it from a note somebody else wrote. * - * @ticket 63191 + * @ticket 64638 */ public function test_delete_own_reaction_on_another_users_note() { $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); @@ -5673,7 +5673,7 @@ public function test_delete_own_reaction_on_another_users_note() { * A reaction cannot be added to a trashed or spammed note, where it would * escape the trash cascade. * - * @ticket 63191 + * @ticket 64638 * * @dataProvider data_hidden_note_statuses * @@ -5728,7 +5728,7 @@ public function data_hidden_note_statuses() { * reactions from then on, so the server rejects them too, on the root * note and on its replies. * - * @ticket 63191 + * @ticket 64638 * * @dataProvider data_resolved_thread_targets * @@ -5797,7 +5797,7 @@ public function data_resolved_thread_targets() { * status, so a reaction request cannot reveal whether that note is * trashed, spammed or resolved. * - * @ticket 63191 + * @ticket 64638 * * @dataProvider data_other_post_note_states * @@ -5859,7 +5859,7 @@ public function data_other_post_note_states() { * when approved on a public post. Only the reacting user or a user who can * edit the comment can read one. * - * @ticket 63191 + * @ticket 64638 */ public function test_reaction_is_not_publicly_readable() { $post_id = self::factory()->post->create( @@ -5909,7 +5909,7 @@ public function test_reaction_is_not_publicly_readable() { * A later page of notes summarizes its own notes' reactions with the same * two queries as the first page, rather than one query per note. * - * @ticket 63191 + * @ticket 64638 */ public function test_second_page_of_notes_keeps_reaction_summary_queries_bounded() { wp_set_current_user( self::$editor_id ); @@ -5972,7 +5972,7 @@ public function test_second_page_of_notes_keeps_reaction_summary_queries_bounded /** * A reaction can be added to a reply in an open thread. * - * @ticket 63191 + * @ticket 64638 */ public function test_create_reaction_on_reply_in_open_thread() { wp_set_current_user( self::$editor_id ); @@ -6018,7 +6018,7 @@ public function test_create_reaction_on_reply_in_open_thread() { /** * Listing notes returns each note's reaction summary without a per-note query. * - * @ticket 63191 + * @ticket 64638 */ public function test_note_collection_includes_reaction_summary() { wp_set_current_user( self::$editor_id ); @@ -6085,7 +6085,7 @@ public function test_note_collection_includes_reaction_summary() { /** * A note is not readable, and so neither is its reaction summary, without permission. * - * @ticket 63191 + * @ticket 64638 */ public function test_reaction_summary_is_not_exposed_to_logged_out_users() { $note_id = self::factory()->comment->create( @@ -6119,7 +6119,7 @@ public function test_reaction_summary_is_not_exposed_to_logged_out_users() { * Reactions from several users are counted together, and the current user's * own row is the one reported back. * - * @ticket 63191 + * @ticket 64638 */ public function test_reaction_summary_counts_reactions_from_multiple_users() { $note_id = self::factory()->comment->create( @@ -6166,7 +6166,7 @@ public function test_reaction_summary_counts_reactions_from_multiple_users() { /** * A trashed reaction drops out of the summary. * - * @ticket 63191 + * @ticket 64638 */ public function test_reaction_summary_excludes_trashed_reactions() { wp_set_current_user( self::$editor_id );