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 ( + + + } + /> + + { + setIsOpen( false ); + onToggleReaction( slug ); + } } + /> + + + ); +} 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(); + } }