diff --git a/backport-changelog/7.0/10930.md b/backport-changelog/7.0/10930.md
new file mode 100644
index 00000000000000..d2af26615dcc51
--- /dev/null
+++ b/backport-changelog/7.0/10930.md
@@ -0,0 +1,4 @@
+https://github.com/WordPress/wordpress-develop/pull/10930
+
+* https://github.com/WordPress/gutenberg/pull/75148
+* https://github.com/WordPress/gutenberg/pull/75549
diff --git a/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php b/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php
index f8a19a6b490e30..e92d0570e76060 100644
--- a/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php
+++ b/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php
@@ -499,7 +499,7 @@ public function create_item( $request ) {
* @param string $post_type Post type name.
* @return bool True if post type supports block comments, false otherwise.
*/
- private function check_post_type_supports_notes( $post_type ) {
+ protected function check_post_type_supports_notes( $post_type ) {
$supports = get_all_post_type_supports( $post_type );
if ( ! isset( $supports['editor'] ) ) {
return false;
@@ -613,10 +613,11 @@ protected function check_is_comment_content_allowed( $prepared_comment ) {
}
}
-add_action(
- 'rest_api_init',
- function () {
- $controller = new Gutenberg_REST_Comment_Controller_6_9();
- $controller->register_routes();
- }
-);
+/**
+ * Registers the Gutenberg REST comment controller for WordPress 6.9 compatibility.
+ */
+function gutenberg_register_comment_controller_6_9() {
+ $controller = new Gutenberg_REST_Comment_Controller_6_9();
+ $controller->register_routes();
+}
+add_action( 'rest_api_init', 'gutenberg_register_comment_controller_6_9' );
diff --git a/lib/compat/wordpress-7.0/block-comments.php b/lib/compat/wordpress-7.0/block-comments.php
new file mode 100644
index 00000000000000..c00baee85a0151
--- /dev/null
+++ b/lib/compat/wordpress-7.0/block-comments.php
@@ -0,0 +1,6 @@
+query_vars['type'] ) && '' === $query->query_vars['type'] ) {
+ $query->set( 'type', '' );
+
+ global $wpdb;
+ $clauses['where'] .= " AND {$wpdb->comments}.comment_type != 'note' AND {$wpdb->comments}.comment_type != 'reaction'";
+ }
+
+ return $clauses;
+}
+remove_action( 'comments_clauses', 'exclude_block_comments_from_admin', 10 );
+add_action( 'comments_clauses', 'gutenberg_exclude_block_comments_from_admin_7_1', 10, 2 );
+
+/**
+ * Filter the comment count query to exclude notes and reactions.
+ *
+ * Replaces the 6.9 implementation to also exclude 'reaction' type.
+ *
+ * @param string $query The SQL query string.
+ * @return string The modified SQL query string.
+ */
+function gutenberg_filter_comment_count_query_exclude_block_comments_7_1( $query ) {
+ if ( str_starts_with( $query, 'SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM' ) && str_contains( $query, 'comment_approved' ) ) {
+ if ( ! str_contains( $query, "comment_type != 'note'" ) ) {
+ $query = str_replace( 'comment_approved', "comment_type != 'note' AND comment_type != 'reaction' AND comment_approved", $query );
+ }
+ }
+ return $query;
+}
+remove_filter( 'query', 'gutenberg_filter_comment_count_query_exclude_block_comments' );
+add_filter( 'query', 'gutenberg_filter_comment_count_query_exclude_block_comments_7_1' );
+
+/**
+ * Adjusts the comments list table query so notes and reactions never display.
+ *
+ * Replaces the 6.9 implementation to also handle 'reaction' type.
+ *
+ * @param array $args An array of get_comments() arguments.
+ * @return array Possibly modified arguments for get_comments().
+ */
+function gutenberg_hide_note_from_comment_list_table_7_1( $args ) {
+ if ( ! empty( $_REQUEST['comment_type'] ) && in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) {
+ unset( $args['type'] );
+ }
+ return $args;
+}
+remove_filter( 'comments_list_table_query_args', 'gutenberg_hide_note_from_comment_list_table' );
+add_filter( 'comments_list_table_query_args', 'gutenberg_hide_note_from_comment_list_table_7_1' );
+
+/**
+ * Override comment_count to exclude notes and reactions from the comment count.
+ *
+ * Replaces the 6.9 implementation to also exclude 'reaction' type.
+ *
+ * @param int|null $new The new comment count. Default null.
+ * @param int $old The old comment count.
+ * @param int $post_id Post ID.
+ * @return int|null The modified comment count.
+ */
+function gutenberg_exclude_notes_from_comment_count_7_1( $new_count, $old_count, $post_id ) {
+ global $wpdb;
+ if ( null !== $new_count ) {
+ return $new_count;
+ }
+ $new_count = (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 ) );
+ return $new_count;
+}
+remove_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count', 10 );
+add_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count_7_1', 10, 3 );
diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php
new file mode 100644
index 00000000000000..b8f13c0454d419
--- /dev/null
+++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php
@@ -0,0 +1,574 @@
+is_note_or_reaction( $request['type'] );
+ $is_edit_context = 'edit' === $request['context'];
+
+ if ( ! empty( $request['post'] ) ) {
+ foreach ( (array) $request['post'] as $post_id ) {
+ $post = get_post( $post_id );
+
+ if ( $post && $is_note && ! $this->check_post_type_supports_notes( $post->post_type ) ) {
+ return new WP_Error(
+ 'rest_comment_not_supported_post_type',
+ __( 'Sorry, this post type does not support notes.', 'gutenberg' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ if ( ! empty( $post_id ) && $post && ! $this->check_read_post_permission( $post, $request ) ) {
+ return new WP_Error(
+ 'rest_cannot_read_post',
+ __( 'Sorry, you are not allowed to read the post for this comment.', 'gutenberg' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ } elseif ( 0 === $post_id && ! current_user_can( 'moderate_comments' ) ) {
+ return new WP_Error(
+ 'rest_cannot_read',
+ __( 'Sorry, you are not allowed to read comments without a post.', 'gutenberg' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+ }
+ }
+
+ if ( $is_edit_context && $is_note && ! empty( $request['post'] ) ) {
+ foreach ( (array) $request['post'] as $post_id ) {
+ if ( ! current_user_can( 'edit_post', $post_id ) ) {
+ return new WP_Error(
+ 'rest_forbidden_context',
+ __( 'Sorry, you are not allowed to edit comments.', 'gutenberg' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+ }
+ } elseif ( $is_edit_context && ! current_user_can( 'moderate_comments' ) ) {
+ return new WP_Error(
+ 'rest_forbidden_context',
+ __( 'Sorry, you are not allowed to edit comments.', 'gutenberg' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( ! current_user_can( 'edit_posts' ) ) {
+ $protected_params = array( 'author', 'author_exclude', 'author_email', 'type', 'status' );
+ $forbidden_params = array();
+
+ foreach ( $protected_params as $param ) {
+ if ( 'status' === $param ) {
+ if ( 'approve' !== $request[ $param ] ) {
+ $forbidden_params[] = $param;
+ }
+ } elseif ( 'type' === $param ) {
+ if ( 'comment' !== $request[ $param ] ) {
+ $forbidden_params[] = $param;
+ }
+ } elseif ( ! empty( $request[ $param ] ) ) {
+ $forbidden_params[] = $param;
+ }
+ }
+
+ if ( ! empty( $forbidden_params ) ) {
+ return new WP_Error(
+ 'rest_forbidden_param',
+ /* translators: %s: List of forbidden parameters. */
+ sprintf( __( 'Query parameter not permitted: %s', 'gutenberg' ), implode( ', ', $forbidden_params ) ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+ }
+
+ return true;
+ }
+
+ public function get_item_permissions_check( $request ) {
+ $comment = $this->get_comment( $request['id'] );
+ if ( is_wp_error( $comment ) ) {
+ return $comment;
+ }
+
+ // Re-map edit context capabilities when requesting `note` or `reaction` type.
+ $edit_cap = $this->is_note_or_reaction( $comment->comment_type ) ? 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',
+ __( 'Sorry, you are not allowed to edit comments.', 'gutenberg' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ $post = get_post( $comment->comment_post_ID );
+
+ if ( ! $this->check_read_permission( $comment, $request ) ) {
+ return new WP_Error(
+ 'rest_cannot_read',
+ __( 'Sorry, you are not allowed to read this comment.', 'gutenberg' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( $post && ! $this->check_read_post_permission( $post, $request ) ) {
+ return new WP_Error(
+ 'rest_cannot_read_post',
+ __( 'Sorry, you are not allowed to read the post for this comment.', 'gutenberg' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ public function create_item_permissions_check( $request ) {
+ $is_note = ! empty( $request['type'] ) && $this->is_note_or_reaction( $request['type'] );
+
+ if ( ! is_user_logged_in() && $is_note ) {
+ return new WP_Error(
+ 'rest_comment_login_required',
+ __( 'Sorry, you must be logged in to comment.', 'gutenberg' ),
+ array( 'status' => 401 )
+ );
+ }
+
+ if ( ! is_user_logged_in() ) {
+ if ( get_option( 'comment_registration' ) ) {
+ return new WP_Error(
+ 'rest_comment_login_required',
+ __( 'Sorry, you must be logged in to comment.', 'gutenberg' ),
+ array( 'status' => 401 )
+ );
+ }
+
+ /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */
+ $allow_anonymous = apply_filters( 'rest_allow_anonymous_comments', false, $request );
+
+ if ( ! $allow_anonymous ) {
+ return new WP_Error(
+ 'rest_comment_login_required',
+ __( 'Sorry, you must be logged in to comment.', 'gutenberg' ),
+ array( 'status' => 401 )
+ );
+ }
+ }
+
+ if ( isset( $request['author'] ) && get_current_user_id() !== $request['author'] && ! current_user_can( 'moderate_comments' ) ) {
+ return new WP_Error(
+ 'rest_comment_invalid_author',
+ /* translators: %s: Request parameter. */
+ sprintf( __( "Sorry, you are not allowed to edit '%s' for comments.", 'gutenberg' ), 'author' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( isset( $request['author_ip'] ) && ! current_user_can( 'moderate_comments' ) ) {
+ if ( empty( $_SERVER['REMOTE_ADDR'] ) || $request['author_ip'] !== $_SERVER['REMOTE_ADDR'] ) {
+ return new WP_Error(
+ 'rest_comment_invalid_author_ip',
+ /* translators: %s: Request parameter. */
+ sprintf( __( "Sorry, you are not allowed to edit '%s' for comments.", 'gutenberg' ), 'author_ip' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+ }
+
+ $edit_cap = $is_note ? array( 'edit_post', (int) $request['post'] ) : array( 'moderate_comments' );
+ if ( isset( $request['status'] ) && ! current_user_can( ...$edit_cap ) ) {
+ return new WP_Error(
+ 'rest_comment_invalid_status',
+ /* translators: %s: Request parameter. */
+ sprintf( __( "Sorry, you are not allowed to edit '%s' for comments.", 'gutenberg' ), 'status' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( empty( $request['post'] ) ) {
+ return new WP_Error(
+ 'rest_comment_invalid_post_id',
+ __( 'Sorry, you are not allowed to create this comment without a post.', 'gutenberg' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ $post = get_post( (int) $request['post'] );
+
+ if ( ! $post ) {
+ return new WP_Error(
+ 'rest_comment_invalid_post_id',
+ __( 'Sorry, you are not allowed to create this comment without a post.', 'gutenberg' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ if ( $is_note && ! $this->check_post_type_supports_notes( $post->post_type ) ) {
+ return new WP_Error(
+ 'rest_comment_not_supported_post_type',
+ __( 'Sorry, this post type does not support notes.', 'gutenberg' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ if ( 'draft' === $post->post_status && ! $is_note ) {
+ return new WP_Error(
+ 'rest_comment_draft_post',
+ __( 'Sorry, you are not allowed to create a comment on this post.', 'gutenberg' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ if ( 'trash' === $post->post_status ) {
+ return new WP_Error(
+ 'rest_comment_trash_post',
+ __( 'Sorry, you are not allowed to create a comment on this post.', 'gutenberg' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ if ( ! $this->check_read_post_permission( $post, $request ) ) {
+ return new WP_Error(
+ 'rest_cannot_read_post',
+ __( 'Sorry, you are not allowed to read the post for this comment.', 'gutenberg' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ if ( ! comments_open( $post->ID ) && ! $is_note ) {
+ return new WP_Error(
+ 'rest_comment_closed',
+ __( 'Sorry, comments are closed for this item.', 'gutenberg' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Creates a comment.
+ *
+ * Extends the 6.9 implementation to support 'reaction' comment type
+ * with validation for parent note, valid emoji slugs, and uniqueness.
+ *
+ * @since 7.1.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or error object on failure.
+ */
+ public function create_item( $request ) {
+ if ( ! empty( $request['id'] ) ) {
+ return new WP_Error(
+ 'rest_comment_exists',
+ __( 'Cannot create existing comment.', 'gutenberg' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Allow 'comment', 'note', and 'reaction' types.
+ 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.', 'gutenberg' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Validate reaction-specific requirements.
+ if ( ! empty( $request['type'] ) && 'reaction' === $request['type'] ) {
+ // Validate parent is a note.
+ if ( empty( $request['parent'] ) ) {
+ return new WP_Error(
+ 'rest_comment_invalid_parent',
+ __( 'A reaction must have a parent note.', 'gutenberg' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $parent_comment = get_comment( $request['parent'] );
+ if ( ! $parent_comment || 'note' !== $parent_comment->comment_type ) {
+ return new WP_Error(
+ 'rest_comment_invalid_parent',
+ __( 'A reaction must be attached to a note.', 'gutenberg' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Validate content is a valid emoji slug.
+ $valid_slugs = array( 'heart', 'celebration', 'smile', 'eyes', 'rocket' );
+ $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : '';
+ if ( ! in_array( $emoji_slug, $valid_slugs, true ) ) {
+ return new WP_Error(
+ 'rest_comment_invalid_reaction',
+ __( 'Invalid reaction emoji.', 'gutenberg' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Enforce uniqueness: prevent duplicate emoji per user per note.
+ $existing = get_comments(
+ array(
+ 'parent' => $request['parent'],
+ 'user_id' => get_current_user_id(),
+ 'type' => 'reaction',
+ 'status' => 'any',
+ )
+ );
+
+ foreach ( $existing as $existing_reaction ) {
+ if ( wp_strip_all_tags( $existing_reaction->comment_content ) === $emoji_slug ) {
+ return new WP_Error(
+ 'rest_comment_duplicate_reaction',
+ __( 'You have already reacted with this emoji.', 'gutenberg' ),
+ array( 'status' => 409 )
+ );
+ }
+ }
+ }
+
+ $prepared_comment = $this->prepare_item_for_database( $request );
+ if ( is_wp_error( $prepared_comment ) ) {
+ return $prepared_comment;
+ }
+
+ $prepared_comment['comment_type'] = $request['type'];
+
+ if ( ! isset( $prepared_comment['comment_content'] ) ) {
+ $prepared_comment['comment_content'] = '';
+ }
+
+ // Include note metadata into check_is_comment_content_allowed [backport].
+ if ( isset( $request['meta']['_wp_note_status'] ) ) {
+ $prepared_comment['meta']['_wp_note_status'] = $request['meta']['_wp_note_status'];
+ }
+
+ if ( ! $this->check_is_comment_content_allowed( $prepared_comment ) ) {
+ return new WP_Error(
+ 'rest_comment_content_invalid',
+ __( 'Invalid comment content.', 'gutenberg' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Setting remaining values before wp_insert_comment so we can use wp_allow_comment().
+ if ( ! isset( $prepared_comment['comment_date_gmt'] ) ) {
+ $prepared_comment['comment_date_gmt'] = current_time( 'mysql', true );
+ }
+
+ // Set author data if the user's logged in.
+ $missing_author = empty( $prepared_comment['user_id'] )
+ && empty( $prepared_comment['comment_author'] )
+ && empty( $prepared_comment['comment_author_email'] )
+ && empty( $prepared_comment['comment_author_url'] );
+
+ if ( is_user_logged_in() && $missing_author ) {
+ $user = wp_get_current_user();
+
+ $prepared_comment['user_id'] = $user->ID;
+ $prepared_comment['comment_author'] = $user->display_name;
+ $prepared_comment['comment_author_email'] = $user->user_email;
+ $prepared_comment['comment_author_url'] = $user->user_url;
+ }
+
+ // Honor the discussion setting that requires a name and email address of the comment author.
+ if ( get_option( 'require_name_email' ) ) {
+ if ( empty( $prepared_comment['comment_author'] ) || empty( $prepared_comment['comment_author_email'] ) ) {
+ return new WP_Error(
+ 'rest_comment_author_data_required',
+ __( 'Creating a comment requires valid author name and email values.', 'gutenberg' ),
+ array( 'status' => 400 )
+ );
+ }
+ }
+
+ if ( ! isset( $prepared_comment['comment_author_email'] ) ) {
+ $prepared_comment['comment_author_email'] = '';
+ }
+
+ if ( ! isset( $prepared_comment['comment_author_url'] ) ) {
+ $prepared_comment['comment_author_url'] = '';
+ }
+
+ if ( ! isset( $prepared_comment['comment_agent'] ) ) {
+ $prepared_comment['comment_agent'] = '';
+ }
+
+ $check_comment_lengths = wp_check_comment_data_max_lengths( $prepared_comment );
+
+ if ( is_wp_error( $check_comment_lengths ) ) {
+ $error_code = $check_comment_lengths->get_error_code();
+ return new WP_Error(
+ $error_code,
+ __( 'Comment field exceeds maximum length allowed.', 'gutenberg' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Don't check for duplicates or flooding for notes and reactions.
+ $prepared_comment['comment_approved'] =
+ $this->is_note_or_reaction( $prepared_comment['comment_type'] ) ?
+ '1' :
+ wp_allow_comment( $prepared_comment, true );
+
+ if ( is_wp_error( $prepared_comment['comment_approved'] ) ) {
+ $error_code = $prepared_comment['comment_approved']->get_error_code();
+ $error_message = $prepared_comment['comment_approved']->get_error_message();
+
+ if ( 'comment_duplicate' === $error_code ) {
+ return new WP_Error(
+ $error_code,
+ $error_message,
+ array( 'status' => 409 )
+ );
+ }
+
+ if ( 'comment_flood' === $error_code ) {
+ return new WP_Error(
+ $error_code,
+ $error_message,
+ array( 'status' => 400 )
+ );
+ }
+
+ return $prepared_comment['comment_approved'];
+ }
+
+ /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */
+ $prepared_comment = apply_filters( 'rest_pre_insert_comment', $prepared_comment, $request );
+ if ( is_wp_error( $prepared_comment ) ) {
+ return $prepared_comment;
+ }
+
+ $comment_id = wp_insert_comment( wp_filter_comment( wp_slash( (array) $prepared_comment ) ) );
+
+ if ( ! $comment_id ) {
+ return new WP_Error(
+ 'rest_comment_failed_create',
+ __( 'Creating comment failed.', 'gutenberg' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ if ( isset( $request['status'] ) ) {
+ $this->handle_status_param( $request['status'], $comment_id );
+ }
+
+ $comment = get_comment( $comment_id );
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */
+ do_action( 'rest_insert_comment', $comment, $request, true );
+
+ $schema = $this->get_item_schema();
+
+ if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) {
+ $meta_update = $this->meta->update_value( $request['meta'], $comment_id );
+
+ if ( is_wp_error( $meta_update ) ) {
+ return $meta_update;
+ }
+ }
+
+ $fields_update = $this->update_additional_fields_for_object( $comment, $request );
+
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $context = current_user_can( 'moderate_comments' ) ? 'edit' : 'view';
+ $request->set_param( 'context', $context );
+
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */
+ do_action( 'rest_after_insert_comment', $comment, $request, true );
+
+ $response = $this->prepare_item_for_response( $comment, $request );
+ $response = rest_ensure_response( $response );
+
+ $response->set_status( 201 );
+ $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $comment_id ) ) );
+
+ return $response;
+ }
+
+ /**
+ * Prepares links for the request.
+ *
+ * Extends the 6.9 implementation to also handle 'reaction' type
+ * for children link embedding.
+ *
+ * @since 7.1.0
+ *
+ * @param WP_Comment $comment Comment object.
+ * @return array Links for the given comment.
+ */
+ protected function prepare_links( $comment ) {
+ $links = WP_REST_Comments_Controller::prepare_links( $comment );
+
+ // Embedding children for notes and reactions requires `type` and `status` inheritance.
+ if ( isset( $links['children'] ) && $this->is_note_or_reaction( $comment->comment_type ) ) {
+ $args = array(
+ 'parent' => $comment->comment_ID,
+ 'type' => $comment->comment_type,
+ 'status' => 'all',
+ );
+
+ $rest_url = add_query_arg( $args, rest_url( $this->namespace . '/' . $this->rest_base ) );
+
+ $links['children'] = array(
+ 'href' => $rest_url,
+ 'embeddable' => true,
+ );
+ }
+
+ return $links;
+ }
+
+ /**
+ * Checks if comment content is allowed.
+ *
+ * Extends the 6.9 implementation to also allow reaction content
+ * (emoji slugs are always valid content).
+ *
+ * @since 7.1.0
+ *
+ * @param array $prepared_comment The prepared comment data.
+ * @return bool True if the content is allowed, false otherwise.
+ */
+ protected function check_is_comment_content_allowed( $prepared_comment ) {
+ // Note reactions always have content (the emoji slug).
+ if ( isset( $prepared_comment['comment_type'] ) && 'reaction' === $prepared_comment['comment_type'] ) {
+ return true;
+ }
+
+ return parent::check_is_comment_content_allowed( $prepared_comment );
+ }
+}
+
+/**
+ * Registers the Gutenberg REST comment controller for WordPress 7.1 compatibility.
+ *
+ * Replaces the 6.9 controller registration to use the 7.1 controller
+ * which adds reaction support.
+ */
+function gutenberg_register_comment_controller_7_1() {
+ $controller = new Gutenberg_REST_Comment_Controller_7_1();
+ $controller->register_routes();
+}
+remove_action( 'rest_api_init', 'gutenberg_register_comment_controller_6_9' );
+add_action( 'rest_api_init', 'gutenberg_register_comment_controller_7_1' );
diff --git a/lib/load.php b/lib/load.php
index 783c3f488a7178..ca7a5457042613 100644
--- a/lib/load.php
+++ b/lib/load.php
@@ -72,6 +72,11 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/compat/wordpress-7.0/template-activate.php';
require __DIR__ . '/compat/wordpress-7.0/rest-api.php';
require __DIR__ . '/compat/wordpress-7.0/global-styles.php';
+ require __DIR__ . '/compat/wordpress-7.0/block-comments.php';
+
+ // WordPress 7.1 compat.
+ require __DIR__ . '/compat/wordpress-7.1/block-comments.php';
+ require __DIR__ . '/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php';
// Plugin specific code.
require_once __DIR__ . '/class-wp-rest-global-styles-controller-gutenberg.php';
diff --git a/package-lock.json b/package-lock.json
index c198541dcfb4b1..ab06998fa516a9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -59643,6 +59643,7 @@
"@wordpress/patterns": "file:../patterns",
"@wordpress/plugins": "file:../plugins",
"@wordpress/preferences": "file:../preferences",
+ "@wordpress/primitives": "file:../primitives",
"@wordpress/private-apis": "file:../private-apis",
"@wordpress/reusable-blocks": "file:../reusable-blocks",
"@wordpress/rich-text": "file:../rich-text",
diff --git a/packages/editor/package.json b/packages/editor/package.json
index fe51cef6d9a5a1..600bd54da89c8c 100644
--- a/packages/editor/package.json
+++ b/packages/editor/package.json
@@ -94,6 +94,7 @@
"@wordpress/patterns": "file:../patterns",
"@wordpress/plugins": "file:../plugins",
"@wordpress/preferences": "file:../preferences",
+ "@wordpress/primitives": "file:../primitives",
"@wordpress/private-apis": "file:../private-apis",
"@wordpress/reusable-blocks": "file:../reusable-blocks",
"@wordpress/rich-text": "file:../rich-text",
diff --git a/packages/editor/src/components/collab-sidebar/comments.js b/packages/editor/src/components/collab-sidebar/comments.js
index 72eb46069b807b..e7e365c6dc77e6 100644
--- a/packages/editor/src/components/collab-sidebar/comments.js
+++ b/packages/editor/src/components/collab-sidebar/comments.js
@@ -44,6 +44,7 @@ import { focusCommentThread, getCommentExcerpt } from './utils';
import { useFloatingThread } from './hooks';
import { AddComment } from './add-comment';
import { store as editorStore } from '../../store';
+import ReactionDisplay, { AddReactionButton } from './reaction-display';
const { useBlockElement } = unlock( blockEditorPrivateApis );
const { Menu } = unlock( componentsPrivateApis );
@@ -53,10 +54,12 @@ export function Comments( {
onEditComment,
onAddReply,
onCommentDelete,
+ onToggleReaction,
commentSidebarRef,
reflowComments,
isFloating = false,
commentLastUpdated,
+ reactionsMap,
} ) {
const [ heights, setHeights ] = useState( {} );
const [ boardOffsets, setBoardOffsets ] = useState( {} );
@@ -422,6 +425,7 @@ export function Comments( {
onAddReply={ onAddReply }
onCommentDelete={ handleDelete }
onEditComment={ onEditComment }
+ onToggleReaction={ onToggleReaction }
isSelected={ selectedNote === thread.id }
commentSidebarRef={ commentSidebarRef }
reflowComments={ reflowComments }
@@ -430,6 +434,7 @@ export function Comments( {
setHeights={ setHeights }
setBlockRef={ setBlockRef }
commentLastUpdated={ commentLastUpdated }
+ reactionsMap={ reactionsMap }
onKeyDown={ ( event ) =>
handleThreadNavigation(
event,
@@ -448,6 +453,7 @@ function Thread( {
onEditComment,
onAddReply,
onCommentDelete,
+ onToggleReaction,
isSelected,
commentSidebarRef,
reflowComments,
@@ -456,6 +462,7 @@ function Thread( {
setHeights,
setBlockRef,
commentLastUpdated,
+ reactionsMap,
onKeyDown,
} ) {
const { toggleBlockHighlight, selectBlock, toggleBlockSpotlight } = unlock(
@@ -641,6 +648,8 @@ function Thread( {
}
} }
onDelete={ onCommentDelete }
+ onToggleReaction={ onToggleReaction }
+ reactions={ reactionsMap?.[ thread.id ] }
reflowComments={ reflowComments }
/>
{ isSelected &&
@@ -652,6 +661,8 @@ function Thread( {
isExpanded={ isSelected }
onEdit={ onEditComment }
onDelete={ onCommentDelete }
+ onToggleReaction={ onToggleReaction }
+ reactions={ reactionsMap?.[ reply.id ] }
reflowComments={ reflowComments }
/>
) ) }
@@ -688,6 +699,8 @@ function Thread( {
isExpanded={ isSelected }
onEdit={ onEditComment }
onDelete={ onCommentDelete }
+ onToggleReaction={ onToggleReaction }
+ reactions={ reactionsMap?.[ lastReply.id ] }
reflowComments={ reflowComments }
/>
) }
@@ -763,11 +776,14 @@ const CommentBoard = ( {
isExpanded,
onEdit,
onDelete,
+ onToggleReaction,
+ reactions,
reflowComments,
} ) => {
const [ actionState, setActionState ] = useState( false );
const [ showConfirmDialog, setShowConfirmDialog ] = useState( false );
const actionButtonRef = useRef( null );
+
const handleConfirmDelete = () => {
onDelete( thread );
setActionState( false );
@@ -962,6 +978,27 @@ const CommentBoard = ( {
: thread?.content?.rendered }
) }
+ { isExpanded && (
+
+
+ onToggleReaction?.( {
+ commentId: thread.id,
+ emoji,
+ } )
+ }
+ />
+
+ onToggleReaction?.( {
+ commentId: thread.id,
+ emoji,
+ } )
+ }
+ />
+
+ ) }
{ 'delete' === actionState && (
{
+ if ( ! reactionRecords || reactionRecords.length === 0 ) {
+ return {};
+ }
+
+ const map = {};
+ reactionRecords.forEach( ( reaction ) => {
+ const noteId = reaction.parent;
+ const slug =
+ typeof reaction.content === 'object'
+ ? reaction.content?.raw || reaction.content?.rendered
+ : reaction.content;
+ // Strip HTML tags from rendered content.
+ const cleanSlug = slug?.replace?.( /<[^>]*>/g, '' )?.trim();
+ if ( ! noteId || ! cleanSlug ) {
+ return;
+ }
+
+ if ( ! map[ noteId ] ) {
+ map[ noteId ] = {};
+ }
+ if ( ! map[ noteId ][ cleanSlug ] ) {
+ map[ noteId ][ cleanSlug ] = [];
+ }
+ map[ noteId ][ cleanSlug ].push( {
+ userId: reaction.author,
+ date: reaction.date,
+ reactionId: reaction.id,
+ } );
+ } );
+ return map;
+ }, [ reactionRecords ] );
+
const { getBlockAttributes } = useSelect( blockEditorStore );
const { clientIds } = useSelect( ( select ) => {
const { getClientIdsWithDescendants } = select( blockEditorStore );
@@ -167,10 +213,15 @@ export function useBlockComments( postId ) {
unresolvedSortedThreads,
reflowComments,
commentLastUpdated,
+ reactionsMap,
};
}
-export function useBlockCommentsActions( reflowComments = noop ) {
+export function useBlockCommentsActions(
+ reflowComments = noop,
+ reactionsMap = {}
+) {
+ const registry = useRegistry();
const { createNotice } = useDispatch( noticesStore );
const { saveEntityRecord, deleteEntityRecord } = useDispatch( coreStore );
const { getCurrentPostId } = useSelect( editorStore );
@@ -333,7 +384,65 @@ export function useBlockCommentsActions( reflowComments = noop ) {
}
};
- return { onCreate, onEdit, onDelete };
+ const onToggleReaction = async ( { commentId, emoji } ) => {
+ try {
+ // Get current user from the store.
+ const currentUser =
+ registry.select( coreStore ).getCurrentUser() || {};
+ const userId = currentUser.id;
+
+ // Check if the user already reacted with this emoji.
+ const noteReactions = reactionsMap[ commentId ] || {};
+ const emojiReactions = noteReactions[ emoji ] || [];
+ const existingReaction = emojiReactions.find(
+ ( reaction ) => reaction.userId === userId
+ );
+
+ if ( existingReaction ) {
+ // Remove the reaction by deleting the comment record.
+ await deleteEntityRecord(
+ 'root',
+ 'comment',
+ existingReaction.reactionId,
+ undefined,
+ { throwOnError: true }
+ );
+
+ createNotice( 'snackbar', __( 'Reaction removed.' ), {
+ type: 'snackbar',
+ isDismissible: true,
+ } );
+ } else {
+ // Add a new reaction as a comment record.
+ await saveEntityRecord(
+ 'root',
+ 'comment',
+ {
+ post: getCurrentPostId(),
+ type: 'reaction',
+ parent: commentId,
+ content: emoji,
+ status: 'approve',
+ },
+ { throwOnError: true }
+ );
+
+ createNotice( 'snackbar', __( 'Reaction added.' ), {
+ type: 'snackbar',
+ isDismissible: true,
+ } );
+ }
+ } catch ( error ) {
+ onError( error );
+ }
+ };
+
+ return {
+ onCreate,
+ onEdit,
+ onDelete,
+ onToggleReaction,
+ };
}
export function useEnableFloatingSidebar( enabled = false ) {
diff --git a/packages/editor/src/components/collab-sidebar/index.js b/packages/editor/src/components/collab-sidebar/index.js
index 296e491a296948..cbd9de07493638 100644
--- a/packages/editor/src/components/collab-sidebar/index.js
+++ b/packages/editor/src/components/collab-sidebar/index.js
@@ -40,10 +40,11 @@ function NotesSidebarContent( {
commentSidebarRef,
reflowComments,
commentLastUpdated,
+ reactionsMap,
isFloating = false,
} ) {
- const { onCreate, onEdit, onDelete } =
- useBlockCommentsActions( reflowComments );
+ const { onCreate, onEdit, onDelete, onToggleReaction } =
+ useBlockCommentsActions( reflowComments, reactionsMap );
return (
@@ -113,6 +116,7 @@ function NotesSidebar( { postId } ) {
unresolvedSortedThreads,
reflowComments,
commentLastUpdated,
+ reactionsMap,
} = useBlockComments( postId );
useEnableFloatingSidebar(
showFloatingSidebar &&
@@ -189,6 +193,7 @@ function NotesSidebar( { postId } ) {
) }
@@ -206,6 +211,7 @@ function NotesSidebar( { postId } ) {
commentSidebarRef={ commentSidebarRef }
reflowComments={ reflowComments }
commentLastUpdated={ commentLastUpdated }
+ reactionsMap={ reactionsMap }
styles={ {
backgroundColor,
} }
diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js
new file mode 100644
index 00000000000000..b069ca40935f07
--- /dev/null
+++ b/packages/editor/src/components/collab-sidebar/reaction-display.js
@@ -0,0 +1,298 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf, _n } from '@wordpress/i18n';
+import {
+ Button,
+ __experimentalHStack as HStack,
+ privateApis as componentsPrivateApis,
+} from '@wordpress/components';
+import { SVG, Path } from '@wordpress/primitives';
+import { useSelect } from '@wordpress/data';
+import { useMemo, useState } from '@wordpress/element';
+import { store as coreStore } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import ReactionEmojiPicker, {
+ getEmojiBySlug,
+ getLabelBySlug,
+} from './reaction-emoji-picker';
+import { unlock } from '../../lock-unlock';
+
+const { Menu } = unlock( componentsPrivateApis );
+
+/**
+ * Smiley face icon for the add-reaction button.
+ */
+const smileyIcon = (
+
+);
+
+/**
+ * Get the count of reactions for a specific slug.
+ *
+ * @param {Object} reactions The reactions object (keyed by slug).
+ * @param {string} slug The reaction slug to count.
+ * @return {number} The count of reactions.
+ */
+function getReactionCount( reactions, slug ) {
+ return reactions?.[ slug ]?.length || 0;
+}
+
+/**
+ * Check if the current user has reacted with a specific slug.
+ *
+ * @param {Object} reactions The reactions object (keyed by slug).
+ * @param {string} slug The reaction slug to check.
+ * @param {number} currentUserId The current user's ID.
+ * @return {boolean} Whether the user has reacted.
+ */
+function hasUserReacted( reactions, slug, currentUserId ) {
+ return (
+ reactions?.[ slug ]?.some(
+ ( reaction ) => reaction.userId === currentUserId
+ ) || false
+ );
+}
+
+/**
+ * Get all reaction slugs that have reactions.
+ *
+ * @param {Object} reactions The reactions object (keyed by slug).
+ * @return {string[]} Array of slugs with reactions.
+ */
+function getReactedSlugs( reactions ) {
+ if ( ! reactions ) {
+ return [];
+ }
+ return Object.keys( reactions ).filter(
+ ( slug ) => reactions[ slug ]?.length > 0
+ );
+}
+
+/**
+ * Generate GitHub-style tooltip text for a reaction.
+ *
+ * @param {Object} users Map of user data by ID.
+ * @param {Array} userIds Array of user IDs who reacted.
+ * @param {string} slug The reaction slug.
+ * @return {string} The tooltip text.
+ */
+function getReactionTooltipText( users, userIds, slug ) {
+ const names = userIds
+ .map( ( id ) => users?.[ id ]?.name )
+ .filter( Boolean );
+
+ if ( names.length === 0 ) {
+ return '';
+ }
+
+ const emojiLabel = getLabelBySlug( slug );
+
+ if ( names.length === 1 ) {
+ return sprintf(
+ /* translators: 1: user name, 2: emoji label. */
+ __( '%1$s reacted with %2$s emoji' ),
+ names[ 0 ],
+ emojiLabel
+ );
+ }
+
+ if ( names.length === 2 ) {
+ return sprintf(
+ /* translators: 1: first user name, 2: second user name, 3: emoji label. */
+ __( '%1$s and %2$s reacted with %3$s emoji' ),
+ names[ 0 ],
+ names[ 1 ],
+ emojiLabel
+ );
+ }
+
+ const othersCount = names.length - 2;
+ return sprintf(
+ /* translators: 1: first user name, 2: second user name, 3: number of other users, 4: emoji label. */
+ _n(
+ '%1$s, %2$s, and %3$d other reacted with %4$s emoji',
+ '%1$s, %2$s, and %3$d others reacted with %4$s emoji',
+ othersCount
+ ),
+ names[ 0 ],
+ names[ 1 ],
+ othersCount,
+ emojiLabel
+ );
+}
+
+/**
+ * Display current reactions with counts as pill-shaped buttons.
+ *
+ * @param {Object} props Component props.
+ * @param {Object} props.reactions The reactions object from comment meta.
+ * @param {Function} props.onToggleReaction Callback to toggle a reaction.
+ */
+export default function ReactionDisplay( { reactions, onToggleReaction } ) {
+ const currentUserId = useSelect( ( select ) => {
+ const { getCurrentUser } = select( coreStore );
+ const user = getCurrentUser();
+ return user?.id;
+ }, [] );
+ const reactedSlugs = getReactedSlugs( reactions );
+
+ // Collect all unique user IDs from reactions.
+ const userIdArray = useMemo( () => {
+ if ( ! reactions ) {
+ return [];
+ }
+ const userIdSet = new Set();
+ Object.values( reactions ).forEach( ( reactionList ) => {
+ reactionList?.forEach( ( reaction ) => {
+ if ( reaction.userId ) {
+ userIdSet.add( reaction.userId );
+ }
+ } );
+ } );
+ return Array.from( userIdSet ).sort( ( a, b ) => a - b );
+ }, [ reactions ] );
+
+ // Fetch user data for all users who reacted.
+ const users = useSelect(
+ ( select ) => {
+ if ( ! userIdArray.length ) {
+ return {};
+ }
+ const { getUsers } = select( coreStore );
+ const userList = getUsers( {
+ include: userIdArray,
+ context: 'view',
+ _fields: 'id,name',
+ per_page: -1,
+ } );
+ if ( ! userList ) {
+ return {};
+ }
+ const userData = {};
+ userList.forEach( ( user ) => {
+ userData[ user.id ] = user;
+ } );
+ return userData;
+ },
+ [ userIdArray ]
+ );
+
+ if ( reactedSlugs.length === 0 ) {
+ return null;
+ }
+
+ return (
+
+ { reactedSlugs.map( ( slug ) => {
+ const count = getReactionCount( reactions, slug );
+ const isActive = hasUserReacted(
+ reactions,
+ slug,
+ currentUserId
+ );
+ const emoji = getEmojiBySlug( slug );
+
+ const reactionUserIds = ( reactions?.[ slug ] || [] ).map(
+ ( r ) => r.userId
+ );
+ const tooltipText = getReactionTooltipText(
+ users,
+ reactionUserIds,
+ slug
+ );
+
+ // Use tooltip text when user data is loaded, otherwise fall back to basic label.
+ const buttonLabel = tooltipText
+ ? tooltipText
+ : sprintf(
+ /* translators: 1: emoji, 2: count of reactions */
+ _n(
+ '%1$s, %2$d reaction',
+ '%1$s, %2$d reactions',
+ count
+ ),
+ emoji,
+ count
+ );
+
+ return (
+
+ );
+ } ) }
+
+ );
+}
+
+/**
+ * Standalone add-reaction button with emoji picker dropdown.
+ *
+ * @param {Object} props Component props.
+ * @param {Function} props.onToggleReaction Callback to toggle a reaction.
+ */
+export function AddReactionButton( { onToggleReaction } ) {
+ const [ isOpen, setIsOpen ] = useState( false );
+ return (
+
+ );
+}
diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js
new file mode 100644
index 00000000000000..0fc23a184bfa47
--- /dev/null
+++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js
@@ -0,0 +1,71 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Button, Composite } from '@wordpress/components';
+
+/**
+ * Curated emoji set for reactions.
+ * The `value` slug is used as the storage key in the database to avoid
+ * potential encoding issues with emoji characters.
+ */
+export const REACTION_EMOJIS = [
+ { emoji: '❤️', label: __( 'Heart' ), value: 'heart' },
+ { emoji: '🎉', label: __( 'Celebration' ), value: 'celebration' },
+ { emoji: '😄', label: __( 'Smile' ), value: 'smile' },
+ { emoji: '👀', label: __( 'Eyes' ), value: 'eyes' },
+ { emoji: '🚀', label: __( 'Rocket' ), value: 'rocket' },
+];
+
+/**
+ * Get the emoji character for a given reaction slug.
+ *
+ * @param {string} slug The reaction slug.
+ * @return {string} The emoji character, or the slug if not found.
+ */
+export function getEmojiBySlug( slug ) {
+ return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.emoji ?? slug;
+}
+
+/**
+ * Get the label for a given reaction slug.
+ *
+ * @param {string} slug The reaction slug.
+ * @return {string} The label, or the slug if not found.
+ */
+export function getLabelBySlug( slug ) {
+ return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.label ?? slug;
+}
+
+/**
+ * A selection of emoji buttons for adding reactions.
+ *
+ * @param {Object} props Component props.
+ * @param {Function} props.onSelect Callback when an emoji is selected.
+ */
+export default function ReactionEmojiPicker( { onSelect } ) {
+ return (
+
+ { REACTION_EMOJIS.map( ( { emoji, label, value } ) => (
+ onSelect( value ) }
+ aria-label={ label }
+ className="editor-collab-sidebar-panel__emoji-option"
+ />
+ }
+ >
+ { emoji }
+
+ ) ) }
+
+ );
+}
diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss
index 5a13033159ce92..07373e2ef2ca3b 100644
--- a/packages/editor/src/components/collab-sidebar/style.scss
+++ b/packages/editor/src/components/collab-sidebar/style.scss
@@ -1,6 +1,7 @@
@use "@wordpress/base-styles/colors" as *;
@use "@wordpress/base-styles/mixins" as *;
@use "@wordpress/base-styles/variables" as *;
+@use "sass:math";
.interface-interface-skeleton__sidebar:has(.editor-collab-sidebar) {
box-shadow: none;
@@ -184,3 +185,83 @@
}
}
}
+
+// Emoji Reactions
+.editor-collab-sidebar-panel__reaction-button {
+ gap: $grid-unit-05;
+ border: $border-width solid $gray-300;
+ background-color: $gray-100;
+ font-weight: $font-weight-medium;
+ color: $gray-700;
+ border-radius: math.div($button-size-compact, 2);
+ font-size: $font-size-small;
+
+ &.editor-collab-sidebar-panel__reaction-button[aria-pressed="true"] {
+ background-color: $gray-100;
+ border-color: var(--wp-admin-theme-color);
+ color: inherit;
+
+ &:hover {
+ color: var(--wp-admin-theme-color);
+ background-color: $gray-100;
+ }
+ }
+
+ &:hover {
+ color: var(--wp-admin-theme-color);
+ background-color: $gray-100;
+ }
+}
+
+.editor-collab-sidebar-panel__add-reaction-button {
+ display: inline-flex;
+ margin: 0;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px !important; // Override Button component's size constraints.
+ min-width: 28px !important;
+ padding: 0;
+ border-radius: $radius-round;
+ border: $border-width solid $gray-300;
+ background-color: $white;
+ cursor: pointer;
+
+ &:hover {
+ background-color: $gray-100;
+ }
+
+ &:focus {
+ outline: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color);
+ outline-offset: calc(-1 * var(--wp-admin-border-width-focus));
+ }
+
+ svg {
+ fill: none;
+ color: $gray-700;
+ width: 20px;
+ height: 20px;
+ }
+}
+
+
+.editor-collab-sidebar-panel__add-reaction-popover {
+ padding: $grid-unit-10;
+}
+
+.editor-collab-sidebar-panel .editor-collab-sidebar-panel__add-reaction-popover {
+ min-width: 40px;
+}
+
+.editor-collab-sidebar-panel__emoji-picker {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.editor-collab-sidebar-panel__emoji-option {
+ width: 32px;
+ font-size: 18px;
+ justify-content: center;
+}
+
diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json
index c4c02a2d28a93a..cef98a80a81669 100644
--- a/packages/editor/tsconfig.json
+++ b/packages/editor/tsconfig.json
@@ -32,6 +32,7 @@
{ "path": "../notices" },
{ "path": "../plugins" },
{ "path": "../preferences" },
+ { "path": "../primitives" },
{ "path": "../private-apis" },
{ "path": "../rich-text" },
{ "path": "../url" },
diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php
index 02d2ea8ed39a56..2be8e9082631e1 100644
--- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php
+++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php
@@ -455,4 +455,224 @@ public function data_note_status_provider() {
'reopen' => array( 'reopen' ),
);
}
+
+ /**
+ * Helper to create a note on a post.
+ *
+ * @param int $post_id Post ID.
+ * @param int $user_id User ID.
+ * @return int Note comment ID.
+ */
+ protected function create_note( $post_id, $user_id ) {
+ wp_set_current_user( $user_id );
+ $params = array(
+ 'post' => $post_id,
+ 'content' => 'Test note for reactions',
+ 'author' => $user_id,
+ 'type' => 'note',
+ );
+ $request = new WP_REST_Request( 'POST', '/wp/v2/comments' );
+ $request->add_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( $params ) );
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertSame( 201, $response->get_status(), 'Failed to create note for test setup.' );
+ $data = $response->get_data();
+ return $data['id'];
+ }
+
+ public function test_create_reaction() {
+ wp_set_current_user( self::$editor_id );
+ $post_id = self::factory()->post->create();
+ $note_id = $this->create_note( $post_id, self::$editor_id );
+
+ $params = array(
+ 'post' => $post_id,
+ 'type' => 'reaction',
+ 'parent' => $note_id,
+ 'content' => 'heart',
+ 'author' => self::$editor_id,
+ );
+
+ $request = new WP_REST_Request( 'POST', '/wp/v2/comments' );
+ $request->add_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( $params ) );
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertSame( 201, $response->get_status() );
+
+ $data = $response->get_data();
+ $new_comment = get_comment( $data['id'] );
+ $this->assertSame( 'reaction', $new_comment->comment_type );
+ $this->assertSame( 'heart', $new_comment->comment_content );
+ $this->assertSame( (string) $note_id, $new_comment->comment_parent );
+ $this->assertSame( '1', $new_comment->comment_approved );
+ }
+
+ public function test_cannot_create_reaction_without_parent() {
+ wp_set_current_user( self::$editor_id );
+ $post_id = self::factory()->post->create();
+
+ $params = array(
+ 'post' => $post_id,
+ 'type' => 'reaction',
+ 'content' => 'heart',
+ 'author' => self::$editor_id,
+ );
+
+ $request = new WP_REST_Request( 'POST', '/wp/v2/comments' );
+ $request->add_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( $params ) );
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 );
+ }
+
+ public function test_cannot_create_reaction_with_invalid_emoji() {
+ wp_set_current_user( self::$editor_id );
+ $post_id = self::factory()->post->create();
+ $note_id = $this->create_note( $post_id, self::$editor_id );
+
+ $params = array(
+ 'post' => $post_id,
+ 'type' => 'reaction',
+ 'parent' => $note_id,
+ 'content' => 'invalid_emoji',
+ 'author' => self::$editor_id,
+ );
+
+ $request = new WP_REST_Request( 'POST', '/wp/v2/comments' );
+ $request->add_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( $params ) );
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 );
+ }
+
+ public function test_cannot_create_duplicate_reaction() {
+ wp_set_current_user( self::$editor_id );
+ $post_id = self::factory()->post->create();
+ $note_id = $this->create_note( $post_id, self::$editor_id );
+
+ $params = array(
+ 'post' => $post_id,
+ 'type' => 'reaction',
+ 'parent' => $note_id,
+ 'content' => 'rocket',
+ 'author' => self::$editor_id,
+ );
+
+ // First reaction should succeed.
+ $request = new WP_REST_Request( 'POST', '/wp/v2/comments' );
+ $request->add_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( $params ) );
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertSame( 201, $response->get_status() );
+
+ // Duplicate reaction should fail.
+ $request = new WP_REST_Request( 'POST', '/wp/v2/comments' );
+ $request->add_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( $params ) );
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertErrorResponse( 'rest_comment_duplicate_reaction', $response, 409 );
+ }
+
+ public function test_can_create_different_reactions_on_same_note() {
+ wp_set_current_user( self::$editor_id );
+ $post_id = self::factory()->post->create();
+ $note_id = $this->create_note( $post_id, self::$editor_id );
+
+ $emojis = array( 'heart', 'rocket', 'smile' );
+ foreach ( $emojis as $emoji ) {
+ $params = array(
+ 'post' => $post_id,
+ 'type' => 'reaction',
+ 'parent' => $note_id,
+ 'content' => $emoji,
+ 'author' => self::$editor_id,
+ );
+ $request = new WP_REST_Request( 'POST', '/wp/v2/comments' );
+ $request->add_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( $params ) );
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertSame( 201, $response->get_status() );
+ }
+ }
+
+ public function test_cannot_create_reaction_on_regular_comment() {
+ 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',
+ )
+ );
+
+ $params = array(
+ 'post' => $post_id,
+ 'type' => 'reaction',
+ 'parent' => $comment_id,
+ 'content' => 'heart',
+ 'author' => self::$editor_id,
+ );
+
+ $request = new WP_REST_Request( 'POST', '/wp/v2/comments' );
+ $request->add_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( $params ) );
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 );
+ }
+
+ public function test_create_reaction_requires_login() {
+ wp_set_current_user( 0 );
+ $post_id = self::factory()->post->create();
+
+ $params = array(
+ 'post' => $post_id,
+ 'type' => 'reaction',
+ 'content' => 'heart',
+ );
+
+ $request = new WP_REST_Request( 'POST', '/wp/v2/comments' );
+ $request->add_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( $params ) );
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertErrorResponse( 'rest_comment_login_required', $response, 401 );
+ }
+
+ /**
+ * @dataProvider data_valid_reaction_emojis
+ */
+ public function test_create_reaction_valid_emojis( $emoji ) {
+ wp_set_current_user( self::$editor_id );
+ $post_id = self::factory()->post->create();
+ $note_id = $this->create_note( $post_id, self::$editor_id );
+
+ $params = array(
+ 'post' => $post_id,
+ 'type' => 'reaction',
+ 'parent' => $note_id,
+ 'content' => $emoji,
+ 'author' => self::$editor_id,
+ );
+
+ $request = new WP_REST_Request( 'POST', '/wp/v2/comments' );
+ $request->add_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( $params ) );
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertSame( 201, $response->get_status() );
+ }
+
+ public function data_valid_reaction_emojis() {
+ return array(
+ 'heart' => array( 'heart' ),
+ 'celebration' => array( 'celebration' ),
+ 'smile' => array( 'smile' ),
+ 'eyes' => array( 'eyes' ),
+ 'rocket' => array( 'rocket' ),
+ );
+ }
}
diff --git a/test/e2e/specs/editor/various/block-comments.spec.js b/test/e2e/specs/editor/various/block-comments.spec.js
index 1748488ebf0e45..a0cbd9e5d7d367 100644
--- a/test/e2e/specs/editor/various/block-comments.spec.js
+++ b/test/e2e/specs/editor/various/block-comments.spec.js
@@ -830,6 +830,168 @@ test.describe( 'Block Comments', () => {
await expect( thread ).toBeFocused();
} );
} );
+
+ test.describe( 'Emoji Reactions', () => {
+ test( 'can add an emoji reaction to a note', async ( {
+ page,
+ blockCommentUtils,
+ } ) => {
+ await blockCommentUtils.addBlockWithComment( {
+ type: 'core/paragraph',
+ attributes: { content: 'Testing emoji reactions' },
+ comment: 'Test comment for reactions',
+ } );
+
+ await blockCommentUtils.addReactionToComment( 'Heart' );
+
+ await expect(
+ page
+ .getByRole( 'button', { name: 'Dismiss this notice' } )
+ .filter( { hasText: 'Reaction added.' } )
+ ).toBeVisible();
+
+ // Verify the reaction button appears with count.
+ const reactionButton = page.locator(
+ '.editor-collab-sidebar-panel__reaction-button'
+ );
+ await expect( reactionButton ).toBeVisible();
+ await expect( reactionButton ).toContainText( '1' );
+ } );
+
+ test( 'can remove own emoji reaction by clicking it', async ( {
+ page,
+ blockCommentUtils,
+ } ) => {
+ await blockCommentUtils.addBlockWithComment( {
+ type: 'core/paragraph',
+ attributes: { content: 'Testing reaction removal' },
+ comment: 'Test comment for removing reactions',
+ } );
+
+ // Add a reaction.
+ await blockCommentUtils.addReactionToComment( 'Heart' );
+ await expect(
+ page
+ .getByRole( 'button', { name: 'Dismiss this notice' } )
+ .filter( { hasText: 'Reaction added.' } )
+ ).toBeVisible();
+
+ // Click the reaction to remove it.
+ const reactionButton = page.locator(
+ '.editor-collab-sidebar-panel__reaction-button'
+ );
+ await reactionButton.click();
+
+ await expect(
+ page
+ .getByRole( 'button', { name: 'Dismiss this notice' } )
+ .filter( { hasText: 'Reaction removed.' } )
+ ).toBeVisible();
+
+ // Verify the reaction button is no longer visible.
+ await expect( reactionButton ).toBeHidden();
+ } );
+
+ test( 'can see reaction tooltip on hover', async ( {
+ page,
+ blockCommentUtils,
+ } ) => {
+ await blockCommentUtils.addBlockWithComment( {
+ type: 'core/paragraph',
+ attributes: { content: 'Testing reaction tooltip' },
+ comment: 'Test comment for reaction tooltip',
+ } );
+
+ // Add a reaction.
+ await blockCommentUtils.addReactionToComment( 'Celebration' );
+ await expect(
+ page
+ .getByRole( 'button', { name: 'Dismiss this notice' } )
+ .filter( { hasText: 'Reaction added.' } )
+ ).toBeVisible();
+
+ // Hover over the reaction button to trigger tooltip.
+ const reactionButton = page.locator(
+ '.editor-collab-sidebar-panel__reaction-button'
+ );
+ await reactionButton.hover();
+
+ // Verify the tooltip is visible and contains expected text.
+ const tooltip = page.getByRole( 'tooltip' );
+ await expect( tooltip ).toBeVisible();
+ await expect( tooltip ).toContainText( 'reacted with' );
+ await expect( tooltip ).toContainText( 'Celebration' );
+ } );
+
+ test( 'reaction buttons are keyboard accessible', async ( {
+ page,
+ blockCommentUtils,
+ } ) => {
+ await blockCommentUtils.addBlockWithComment( {
+ type: 'core/paragraph',
+ attributes: { content: 'Testing keyboard accessibility' },
+ comment: 'Test comment for keyboard access',
+ } );
+
+ // Open the emoji picker with keyboard.
+ const addReactionButton = page.getByRole( 'button', {
+ name: 'Add reaction',
+ } );
+ await addReactionButton.focus();
+ await page.keyboard.press( 'Enter' );
+
+ // Verify the picker is visible.
+ const emojiPicker = page.locator(
+ '.editor-collab-sidebar-panel__emoji-picker'
+ );
+ await expect( emojiPicker ).toBeVisible();
+
+ // Navigate with arrow keys and select.
+ const firstEmoji = emojiPicker.getByRole( 'option' ).first();
+ await firstEmoji.focus();
+ await page.keyboard.press( 'ArrowRight' );
+ await page.keyboard.press( 'Enter' );
+
+ await expect(
+ page
+ .getByRole( 'button', { name: 'Dismiss this notice' } )
+ .filter( { hasText: 'Reaction added.' } )
+ ).toBeVisible();
+ } );
+
+ test( 'can add multiple different reactions to same note', async ( {
+ page,
+ blockCommentUtils,
+ } ) => {
+ await blockCommentUtils.addBlockWithComment( {
+ type: 'core/paragraph',
+ attributes: { content: 'Testing multiple reactions' },
+ comment: 'Test comment for multiple reactions',
+ } );
+
+ // Add first reaction.
+ await blockCommentUtils.addReactionToComment( 'Smile' );
+ await expect(
+ page
+ .getByRole( 'button', { name: 'Dismiss this notice' } )
+ .filter( { hasText: 'Reaction added.' } )
+ ).toBeVisible();
+
+ // Add second reaction.
+ await blockCommentUtils.addReactionToComment( 'Rocket' );
+ await expect(
+ page
+ .getByRole( 'button', { name: 'Dismiss this notice' } )
+ .filter( { hasText: 'Reaction added.' } )
+ ).toBeVisible();
+
+ // Verify both reactions are visible.
+ const reactionButtons = page.locator(
+ '.editor-collab-sidebar-panel__reaction-button'
+ );
+ await expect( reactionButtons ).toHaveCount( 2 );
+ } );
+ } );
} );
class BlockCommentUtils {
@@ -903,4 +1065,21 @@ class BlockCommentUtils {
.click();
await this.#page.getByRole( 'menuitem', { name: actionName } ).click();
}
+
+ async addReactionToComment( emoji ) {
+ await this.#page
+ .getByRole( 'button', { name: 'Add reaction' } )
+ .click();
+
+ // Wait for the emoji picker popover to appear.
+ const emojiPicker = this.#page.locator(
+ '.editor-collab-sidebar-panel__emoji-picker'
+ );
+ await expect( emojiPicker ).toBeVisible();
+
+ // Click the specific emoji within the picker.
+ await emojiPicker
+ .getByRole( 'option', { name: new RegExp( emoji, 'i' ) } )
+ .click();
+ }
}