From fe192435d594173bcc3c5240e31a8d7ca54ff68c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 18 Feb 2026 21:33:53 +0700 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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' ) ); } /**