diff --git a/src/wp-admin/includes/class-wp-comments-list-table.php b/src/wp-admin/includes/class-wp-comments-list-table.php index 1feaeb3283bcd..78ade23824266 100644 --- a/src/wp-admin/includes/class-wp-comments-list-table.php +++ b/src/wp-admin/includes/class-wp-comments-list-table.php @@ -105,7 +105,7 @@ public function prepare_items() { $comment_type = ''; - if ( ! empty( $_REQUEST['comment_type'] ) && 'note' !== $_REQUEST['comment_type'] ) { + if ( ! empty( $_REQUEST['comment_type'] ) && ! in_array( $_REQUEST['comment_type'], wp_get_internal_comment_types(), true ) ) { $comment_type = $_REQUEST['comment_type']; } @@ -155,7 +155,7 @@ public function prepare_items() { 'number' => $number, 'post_id' => $post_id, 'type' => $comment_type, - 'type__not_in' => array( 'note' ), + 'type__not_in' => wp_get_internal_comment_types(), 'orderby' => $orderby, 'order' => $order, 'post_type' => $post_type, diff --git a/src/wp-admin/includes/comment.php b/src/wp-admin/includes/comment.php index ae5ba9d223350..4732d3fed0590 100644 --- a/src/wp-admin/includes/comment.php +++ b/src/wp-admin/includes/comment.php @@ -158,7 +158,16 @@ function get_pending_comments_num( $post_id ) { $post_id_array = array_map( 'intval', $post_id_array ); $post_id_in = "'" . implode( "', '", $post_id_array ) . "'"; - $pending = $wpdb->get_results( "SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM $wpdb->comments WHERE comment_post_ID IN ( $post_id_in ) AND comment_approved = '0' AND comment_type != 'note' GROUP BY comment_post_ID", ARRAY_A ); + $internal_comment_types = wp_get_internal_comment_types(); + $type_placeholders = implode( ', ', array_fill( 0, count( $internal_comment_types ), '%s' ) ); + $pending = $wpdb->get_results( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM $wpdb->comments WHERE comment_post_ID IN ( $post_id_in ) AND comment_approved = '0' AND comment_type NOT IN ( $type_placeholders ) GROUP BY comment_post_ID", + $internal_comment_types + ), + ARRAY_A + ); if ( $single ) { if ( empty( $pending ) ) { diff --git a/src/wp-includes/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 5395997ecd0ef..edcf9cf1ef7c8 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -294,6 +294,22 @@ 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.1.0 + * + * @return string[] List of internal comment type slugs. + */ +function wp_get_internal_comment_types(): array { + return array( 'note', 'reaction' ); +} + /** * Gets the default comment status for a post type. * @@ -344,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. * @@ -361,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; } @@ -2876,7 +2906,14 @@ function wp_update_comment_count_now( $post_id ) { $new = apply_filters( 'pre_wp_update_comment_count_now', null, $old, $post_id ); if ( is_null( $new ) ) { - $new = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type != 'note'", $post_id ) ); + $internal_comment_types = wp_get_internal_comment_types(); + $type_placeholders = implode( ', ', array_fill( 0, count( $internal_comment_types ), '%s' ) ); + $new = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type NOT IN ( $type_placeholders )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + array_merge( array( $post_id ), $internal_comment_types ) + ) + ); } else { $new = (int) $new; } diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index 54b78a028d745..90722de52ef34 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -4349,10 +4349,12 @@ function is_avatar_comment_type( $comment_type ) { * @since 3.0.0 * * @since 6.9.0 The 'note' comment type was added. + * @since 7.1.0 The 'reaction' comment type was added. * - * @param array $types An array of content types. Default contains 'comment' and 'note'. + * @param array $types An array of content types. Default contains 'comment' and the + * internal comment types returned by wp_get_internal_comment_types(). */ - $allowed_comment_types = apply_filters( 'get_avatar_comment_types', array( 'comment', 'note' ) ); + $allowed_comment_types = apply_filters( 'get_avatar_comment_types', array_merge( array( 'comment' ), wp_get_internal_comment_types() ) ); return in_array( $comment_type, (array) $allowed_comment_types, true ); } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php index f462928847c77..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 @@ -24,6 +24,64 @@ 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.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. * @@ -123,7 +181,7 @@ public function register_routes() { * @return true|WP_Error True if the request has read access, error object otherwise. */ public function get_items_permissions_check( $request ) { - $is_note = 'note' === $request['type']; + $is_note = in_array( $request['type'], wp_get_internal_comment_types(), true ); $is_edit_context = 'edit' === $request['context']; $protected_params = array( 'author', 'author_exclude', 'author_email', 'type', 'status' ); $forbidden_params = array(); @@ -330,6 +388,23 @@ 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_map( 'intval', wp_list_pluck( $query_result, '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 +413,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; @@ -437,8 +514,8 @@ public function get_item_permissions_check( $request ) { return $comment; } - // Re-map edit context capabilities when requesting `note` type. - $edit_cap = 'note' === $comment->comment_type ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); + // Re-map edit context capabilities when requesting `note` or `reaction` type. + $edit_cap = in_array( $comment->comment_type, wp_get_internal_comment_types(), true ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( ...$edit_cap ) ) { return new WP_Error( 'rest_forbidden_context', @@ -497,7 +574,7 @@ public function get_item( $request ) { * @return true|WP_Error True if the request has access to create items, error object otherwise. */ public function create_item_permissions_check( $request ) { - $is_note = ! empty( $request['type'] ) && 'note' === $request['type']; + $is_note = ! empty( $request['type'] ) && in_array( $request['type'], wp_get_internal_comment_types(), true ); if ( ! is_user_logged_in() && $is_note ) { return new WP_Error( @@ -657,7 +734,7 @@ public function create_item( $request ) { } // Do not allow comments to be created with a non-core type. - if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note' ), true ) ) { + if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array_merge( array( 'comment' ), wp_get_internal_comment_types() ), true ) ) { return new WP_Error( 'rest_invalid_comment_type', __( 'Cannot create a comment with that type.' ), @@ -665,6 +742,81 @@ public function create_item( $request ) { ); } + // Validate reaction-specific constraints. + if ( ! empty( $request['type'] ) && 'reaction' === $request['type'] ) { + // Reaction parent must be specified. + if ( empty( $request['parent'] ) ) { + return new WP_Error( + 'rest_reaction_parent_required', + __( 'Reactions must have a parent note.' ), + array( 'status' => 400 ) + ); + } + + // 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( + 'rest_reaction_invalid_parent', + __( 'Reactions can only be added to notes.' ), + array( 'status' => 400 ) + ); + } + + /* + * Validate the reaction content. Two shapes are accepted: + * + * - A curated slug (e.g. `heart`) from self::get_note_reaction_emojis(). + * - A lowercase hex-codepoint sequence joined by `-` (e.g. `1f44d` + * for 👍 or `1f468-200d-1f4bb` for 👨‍💻). + * + * Raw emoji bytes are rejected because the comments table is not + * guaranteed to be utf8mb4 across all WordPress installs; clients + * are expected to normalize before submitting. Variation selector + * U+FE0F is dropped on the client so visually-equivalent + * presentations collapse onto a single key. + */ + $valid_slugs = wp_list_pluck( self::get_note_reaction_emojis(), 'value' ); + $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; + + $is_curated_slug = in_array( $emoji_slug, $valid_slugs, true ); + $is_hex_key = (bool) preg_match( '/^[0-9a-f]{2,6}(-[0-9a-f]{2,6}){0,15}$/', $emoji_slug ); + + 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( + 'parent' => $request['parent'], + 'user_id' => get_current_user_id(), + 'type' => 'reaction', + 'status' => 'approve', + ) + ); + + 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 ) + ); + } + } + } + $prepared_comment = $this->prepare_item_for_database( $request ); if ( is_wp_error( $prepared_comment ) ) { return $prepared_comment; @@ -743,9 +895,9 @@ public function create_item( $request ) { ); } - // Don't check for duplicates or flooding for notes. + // Don't check for duplicates or flooding for notes or reactions. $prepared_comment['comment_approved'] = - 'note' === $prepared_comment['comment_type'] ? + in_array( $prepared_comment['comment_type'], wp_get_internal_comment_types(), true ) ? '1' : wp_allow_comment( $prepared_comment, true ); @@ -1212,6 +1364,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 ); @@ -1305,10 +1471,12 @@ protected function prepare_links( $comment ) { } // Embedding children for notes requires `type` and `status` inheritance. - if ( isset( $links['children'] ) && 'note' === $comment->comment_type ) { - $args = array( + if ( isset( $links['children'] ) && in_array( $comment->comment_type, wp_get_internal_comment_types(), true ) ) { + // Notes have reaction children; reactions don't have children of their own. + $child_type = 'note' === $comment->comment_type ? 'reaction' : $comment->comment_type; + $args = array( 'parent' => $comment->comment_ID, - 'type' => $comment->comment_type, + 'type' => $child_type, 'status' => 'all', ); @@ -1619,6 +1787,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' => self::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', + ), + ), + ), + ), ), ); @@ -1909,6 +2124,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.1.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, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + $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, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + $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. * @@ -1919,7 +2223,7 @@ protected function check_read_post_permission( $post, $request ) { * @return bool Whether the comment can be read. */ protected function check_read_permission( $comment, $request ) { - if ( 'note' !== $comment->comment_type && ! empty( $comment->comment_post_ID ) ) { + if ( ! in_array( $comment->comment_type, wp_get_internal_comment_types(), true ) && ! empty( $comment->comment_post_ID ) ) { $post = get_post( $comment->comment_post_ID ); if ( $post ) { if ( $this->check_read_post_permission( $post, $request ) && 1 === (int) $comment->comment_approved ) { @@ -2034,6 +2338,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..9ad0b4b3297c3 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'] ); } /** @@ -4228,9 +4234,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 +4251,527 @@ 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 ); + } + + /** + * 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 ); } }