From fe192435d594173bcc3c5240e31a8d7ca54ff68c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 18 Feb 2026 21:33:53 +0700 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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. *