diff --git a/backport-changelog/7.2/10930.md b/backport-changelog/7.2/10930.md new file mode 100644 index 00000000000000..60b8f4be8bdd88 --- /dev/null +++ b/backport-changelog/7.2/10930.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/10930 + +* https://github.com/WordPress/gutenberg/pull/83538 diff --git a/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php new file mode 100644 index 00000000000000..001fc2bb2fa28a --- /dev/null +++ b/lib/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php @@ -0,0 +1,1031 @@ + __( 'Aggregated reaction counts for this note, keyed by emoji hex key.', 'gutenberg' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + 'additionalProperties' => array( + 'type' => 'object', + 'properties' => array( + 'count' => array( + 'description' => __( 'Total number of reactions with this emoji.', 'gutenberg' ), + 'type' => 'integer', + ), + 'current_user_reaction' => array( + 'description' => __( 'The current user\'s reaction comment ID for this emoji, or 0 if they have not reacted.', 'gutenberg' ), + 'type' => 'integer', + ), + ), + ), + ); + + return $schema; + } + + /** + * Checks whether the request type is an internal comment type (note or reaction). + * + * @param string $type The comment type from the request. + * @return bool True if the type is one of the internal comment types. + */ + protected function is_note_or_reaction( $type ) { + return in_array( $type, array( 'note', 'reaction' ), true ); + } + + /** + * Checks whether a post type supports notes. + * + * The core comment controller (WordPress 6.9) declares this check as a + * private method, so it cannot be reused from this subclass. The logic is + * mirrored here to keep the note permission checks working. + * + * @param string $post_type Post type name. + * @return bool True if the post type supports notes, false otherwise. + */ + protected function check_post_type_supports_notes( $post_type ) { + $supports = get_all_post_type_supports( $post_type ); + if ( ! isset( $supports['editor'] ) ) { + return false; + } + if ( ! is_array( $supports['editor'] ) ) { + return false; + } + foreach ( $supports['editor'] as $item ) { + if ( ! empty( $item['notes'] ) ) { + return true; + } + } + return false; + } + + public function get_items_permissions_check( $request ) { + $is_note = $this->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; + } + + /** + * Checks if the current user can read the comment. + * + * Core's implementation only excludes the 'note' type from the public read + * path, where an approved comment on a readable post is world-readable. + * Reactions are an internal comment type too, so without this override a + * direct request such as GET /wp/v2/comments/{reaction_id} would expose + * reaction rows - including the reacting user's identity - to any reader of + * the post. Give reactions the same restriction core gives notes: never + * public, readable only by their author or users who can edit the comment. + * + * @since 7.2.0 + * + * @param WP_Comment $comment Comment object. + * @param WP_REST_Request $request Request data to check. + * @return bool True if the user can read the comment, false otherwise. + */ + protected function check_read_permission( $comment, $request ) { + if ( 'reaction' === $comment->comment_type ) { + if ( 0 === get_current_user_id() ) { + return false; + } + + if ( empty( $comment->comment_post_ID ) && ! current_user_can( 'moderate_comments' ) ) { + return false; + } + + if ( ! empty( $comment->user_id ) && get_current_user_id() === (int) $comment->user_id ) { + return true; + } + + return current_user_can( 'edit_comment', $comment->comment_ID ); + } + + return parent::check_read_permission( $comment, $request ); + } + + /** + * Checks if a given request has access to update a comment. + * + * Reactions are immutable: they carry an author, a parent note and a + * canonical emoji hex key that `create_item()` validates as a set, and the + * generic update route re-validates none of it. Allowing updates would + * let anyone who can edit the note's post reattribute a reaction to + * another user, move it to a note on a post they cannot edit, or store a + * duplicate or invalid key. Toggling a reaction off is a delete. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to update the item, error object otherwise. + */ + public function update_item_permissions_check( $request ) { + $comment = $this->get_comment( $request['id'] ); + if ( is_wp_error( $comment ) ) { + return $comment; + } + + if ( 'reaction' === $comment->comment_type ) { + return new WP_Error( + 'rest_comment_update_not_allowed', + __( 'Reactions cannot be edited. Remove the reaction and add a new one instead.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + return parent::update_item_permissions_check( $request ); + } + + /** + * Checks if a given request has access to delete a comment. + * + * Anyone who can edit a note's post can edit the note, and core's delete + * check follows that, but a reaction belongs to the user who added it: + * only they can take it back. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to delete the item, error object otherwise. + */ + public function delete_item_permissions_check( $request ) { + $comment = $this->get_comment( $request['id'] ); + if ( is_wp_error( $comment ) ) { + return $comment; + } + + if ( 'reaction' === $comment->comment_type && get_current_user_id() !== (int) $comment->user_id ) { + return new WP_Error( + 'rest_cannot_delete', + __( 'Sorry, you can only remove your own reactions.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return parent::delete_item_permissions_check( $request ); + } + + 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() ) + ); + } + + // A reaction is always first-person: the uniqueness check and the + // reaction summary key on the current user, so one stored against + // somebody else could never be counted or removed. + if ( + ! empty( $request['type'] ) && 'reaction' === $request['type'] && + isset( $request['author'] ) && get_current_user_id() !== (int) $request['author'] + ) { + return new WP_Error( + 'rest_comment_invalid_author', + __( 'Sorry, you are not allowed to add a reaction on behalf of another user.', 'gutenberg' ), + 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() ) + ); + } + } + + // Notes and reactions require edit access to the post they belong to, + // regardless of whether a status is supplied. Mirrors core's note guard + // in WP_REST_Comments_Controller::create_item_permissions_check(). + if ( $is_note && ! empty( $request['post'] ) && ! current_user_can( 'edit_post', (int) $request['post'] ) ) { + return new WP_Error( + 'rest_cannot_create_note', + __( 'Sorry, you are not allowed to create notes for this post.', 'gutenberg' ), + 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 hex keys, and uniqueness. + * + * @since 7.2.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' plus internal comment types (note, reaction). + if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array_merge( array( 'comment' ), array( 'note', 'reaction' ) ), true ) ) { + return new WP_Error( + 'rest_invalid_comment_type', + __( 'Cannot create a comment with that type.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // The canonical reaction key, populated once validated below so the + // stored content matches what was validated (not the raw input). + $reaction_key = null; + + // 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 ) + ); + } + + // A reaction under a hidden note would escape the trash cascade. + if ( in_array( $parent_comment->comment_approved, array( 'trash', 'spam' ), true ) ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction cannot be added to a trashed or spam note.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Resolving a thread approves its root note, and the editor + // disables reactions from then on; hold stale tabs to that too. + $thread_root = $parent_comment; + $visited = array( (int) $thread_root->comment_ID => true ); + while ( $thread_root->comment_parent ) { + $ancestor = get_comment( $thread_root->comment_parent ); + // Stop at a missing ancestor or a corrupt, cyclic chain. + if ( ! $ancestor || isset( $visited[ (int) $ancestor->comment_ID ] ) ) { + break; + } + $visited[ (int) $ancestor->comment_ID ] = true; + $thread_root = $ancestor; + } + if ( '1' === $thread_root->comment_approved ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction cannot be added to a resolved note.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // The parent note must belong to the post the reaction targets. + if ( ! empty( $request['post'] ) && (int) $parent_comment->comment_post_ID !== (int) $request['post'] ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction must be attached to a note on the same post.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Validate the reaction content: the emoji's Emojibase hex key, + // lowercase code points padded to four digits and joined by `-` + // (e.g. "2764" for ā¤ļø or "1f468-200d-1f4bb" for šŸ‘Øā€šŸ’»). + // Raw emoji bytes are rejected because the comments table is + // not guaranteed to be utf8mb4 across all WordPress installs; + // clients normalize before submitting. + $emoji_key = ''; + + // Accept `content` as a string or `{ raw }`, like prepare_item_for_database(). + if ( isset( $request['content'] ) && is_string( $request['content'] ) ) { + $emoji_key = wp_strip_all_tags( $request['content'] ); + } elseif ( isset( $request['content']['raw'] ) && is_string( $request['content']['raw'] ) ) { + $emoji_key = wp_strip_all_tags( $request['content']['raw'] ); + } + + $is_hex_key = (bool) preg_match( + '/^[0-9a-f]{4,6}(-[0-9a-f]{4,6}){0,15}$/', + $emoji_key + ); + + // A hex key must still be made of assignable Unicode code + // points: reject anything above U+10FFFF or in the UTF-16 surrogate + // range (U+D800–U+DFFF). + if ( $is_hex_key ) { + foreach ( explode( '-', $emoji_key ) as $codepoint ) { + $value = hexdec( $codepoint ); + if ( $value > 0x10FFFF || ( $value >= 0xD800 && $value <= 0xDFFF ) ) { + $is_hex_key = false; + break; + } + } + } + + if ( ! $is_hex_key ) { + return new WP_Error( + 'rest_comment_invalid_reaction', + __( 'Invalid reaction emoji.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Enforce uniqueness: prevent duplicate emoji per user per note. + // Limit to active reactions - trashed reactions are invisible and + // must not block the user from re-adding the same emoji. + $existing = get_comments( + array( + 'parent' => $request['parent'], + 'user_id' => get_current_user_id(), + 'type' => 'reaction', + 'status' => 'approve', + ) + ); + + foreach ( $existing as $existing_reaction ) { + if ( wp_strip_all_tags( $existing_reaction->comment_content ) === $emoji_key ) { + return new WP_Error( + 'rest_comment_duplicate_reaction', + __( 'You have already reacted with this emoji.', 'gutenberg' ), + array( 'status' => 409 ) + ); + } + } + + $reaction_key = $emoji_key; + } + + $prepared_comment = $this->prepare_item_for_database( $request ); + if ( is_wp_error( $prepared_comment ) ) { + return $prepared_comment; + } + + $prepared_comment['comment_type'] = $request['type']; + + // Persist the validated, canonical reaction key rather than the raw + // request content, so stored values stay consistent for grouping and + // counting (e.g. "2764" is stored as "2764"). + if ( null !== $reaction_key ) { + $prepared_comment['comment_content'] = $reaction_key; + } + + 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; + } + + // Pin a reaction to the current user, whatever author details the + // request carried. Author fields alone leave `user_id` at 0, which + // the uniqueness check and the reaction summary both key on. + if ( null !== $reaction_key ) { + $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 ) + ); + } + + // The pre-insert uniqueness check is not atomic, so two concurrent + // requests for the same user/note/emoji can both insert an approved + // row. Converge on a single row deterministically: keep the earliest + // matching reaction (lowest comment ID) and delete any later + // duplicates. Every concurrent request applies the same rule, so they + // all settle on the same surviving row. If this request's own row lost + // the race, repoint the response to the survivor. + if ( null !== $reaction_key ) { + $matching = get_comments( + array( + 'parent' => $request['parent'], + 'user_id' => get_current_user_id(), + 'type' => 'reaction', + 'status' => 'approve', + 'orderby' => 'comment_ID', + 'order' => 'ASC', + ) + ); + $duplicates = array(); + foreach ( $matching as $candidate ) { + if ( wp_strip_all_tags( $candidate->comment_content ) === $reaction_key ) { + $duplicates[] = (int) $candidate->comment_ID; + } + } + + // Repoint whenever any matching row survives, not only when this + // request still sees its own duplicate: a competing request may + // already have deleted this request's row, leaving a single + // survivor that is not `$comment_id`. + if ( ! empty( $duplicates ) ) { + $survivor_id = array_shift( $duplicates ); + foreach ( $duplicates as $duplicate_id ) { + wp_delete_comment( $duplicate_id, true ); + } + $comment_id = $survivor_id; + } + } + + 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; + } + + /** + * Pre-fetched reaction summaries for batch queries. + * + * Populated by get_items() to avoid N+1 queries when returning + * multiple note comments. + * + * @since 7.2.0 + * @var array|null + */ + protected $reaction_summaries = null; + + /** + * Retrieves a collection of comments. + * + * Extends the parent to pre-fetch reaction summaries for all returned + * note comments in a single batch query, avoiding N+1 queries. + * + * @since 7.2.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 get_items( $request ) { + $fields = $this->get_fields_for_response( $request ); + + // Pre-fetch reaction summaries for the notes the parent query actually + // returns, so pagination, ordering, and `rest_comment_query` filters + // all apply. The filter removes itself after the first query. + $prefetch = null; + if ( + ! empty( $request['type'] ) && + 'note' === $request['type'] && + rest_is_field_included( 'reaction_summary', $fields ) + ) { + $prefetch = function ( $comments ) use ( &$prefetch ) { + remove_filter( 'the_comments', $prefetch ); + $note_ids = array(); + foreach ( (array) $comments as $comment ) { + if ( $comment instanceof WP_Comment && 'note' === $comment->comment_type ) { + $note_ids[] = (int) $comment->comment_ID; + } + } + $this->prefetch_reaction_summaries( $note_ids ); + return $comments; + }; + add_filter( 'the_comments', $prefetch ); + } + + $response = parent::get_items( $request ); + + if ( $prefetch ) { + remove_filter( 'the_comments', $prefetch ); + } + $this->reaction_summaries = null; + + return $response; + } + + /** + * Pre-fetches reaction summaries for a set of note IDs. + * + * Runs a single aggregated query to build summaries for all notes, + * avoiding N+1 queries in prepare_item_for_response(). + * + * @since 7.2.0 + * + * @param int[] $note_ids Array of note comment IDs. + */ + protected function prefetch_reaction_summaries( $note_ids ) { + global $wpdb; + + $this->reaction_summaries = array(); + + if ( empty( $note_ids ) ) { + return; + } + + $current_user_id = get_current_user_id(); + + // Query 1: Get aggregated counts per emoji per note. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $counts = $wpdb->get_results( + $wpdb->prepare( + 'SELECT comment_parent, comment_content, COUNT(*) AS reaction_count + FROM ' . $wpdb->comments . ' + WHERE comment_parent IN (' . implode( ',', array_fill( 0, count( $note_ids ), '%d' ) ) . ') + AND comment_type = %s + AND comment_approved = %s + GROUP BY comment_parent, comment_content', + ...array_merge( $note_ids, array( 'reaction', '1' ) ) + ) + ); + + // Query 2: Get the current user's own reaction IDs (if logged in). + $my_reactions = array(); + if ( $current_user_id ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $user_rows = $wpdb->get_results( + $wpdb->prepare( + 'SELECT comment_ID, comment_parent, comment_content + FROM ' . $wpdb->comments . ' + WHERE comment_parent IN (' . implode( ',', array_fill( 0, count( $note_ids ), '%d' ) ) . ') + AND comment_type = %s + AND comment_approved = %s + AND user_id = %d', + ...array_merge( $note_ids, array( 'reaction', '1', $current_user_id ) ) + ) + ); + + if ( $user_rows ) { + foreach ( $user_rows as $row ) { + $key = (int) $row->comment_parent . ':' . wp_strip_all_tags( $row->comment_content ); + $my_reactions[ $key ] = (int) $row->comment_ID; + } + } + } + + // Initialize empty summaries for all note IDs. + foreach ( $note_ids as $note_id ) { + $this->reaction_summaries[ $note_id ] = array(); + } + + if ( ! $counts ) { + return; + } + + foreach ( $counts as $row ) { + $note_id = (int) $row->comment_parent; + $slug = wp_strip_all_tags( $row->comment_content ); + $key = $note_id . ':' . $slug; + + $this->reaction_summaries[ $note_id ][ $slug ] = array( + 'count' => (int) $row->reaction_count, + 'current_user_reaction' => $my_reactions[ $key ] ?? 0, + ); + } + } + + /** + * Prepares a single comment output for response. + * + * Extends the parent to include reaction_summary for note comments. + * + * @since 7.2.0 + * + * @param WP_Comment $item Comment object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $item, $request ) { + $response = parent::prepare_item_for_response( $item, $request ); + $fields = $this->get_fields_for_response( $request ); + + if ( 'note' === $item->comment_type && rest_is_field_included( 'reaction_summary', $fields ) ) { + // Use pre-fetched data if available, otherwise query individually. + if ( null !== $this->reaction_summaries && isset( $this->reaction_summaries[ (int) $item->comment_ID ] ) ) { + $summary = $this->reaction_summaries[ (int) $item->comment_ID ]; + } else { + $this->prefetch_reaction_summaries( array( (int) $item->comment_ID ) ); + $summary = $this->reaction_summaries[ (int) $item->comment_ID ] ?? array(); + // Reset so the next call also queries individually. + $this->reaction_summaries = null; + } + + $data = $response->get_data(); + $data['reaction_summary'] = $summary; + $response->set_data( $data ); + } + + return $response; + } + + /** + * Checks if comment content is allowed. + * + * Extends the 6.9 implementation to also allow reaction content + * (emoji hex keys are always valid content). + * + * @since 7.2.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 hex key). + 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.2 compatibility. + * + * Replaces the 6.9 controller registration to use the 7.2 controller + * which adds reaction support. + */ +function gutenberg_register_comment_controller_7_2() { + $controller = new Gutenberg_REST_Comment_Controller_7_2(); + $controller->register_routes(); +} +add_action( 'rest_api_init', 'gutenberg_register_comment_controller_7_2' ); diff --git a/lib/compat/wordpress-7.2/emojibase-data.php b/lib/compat/wordpress-7.2/emojibase-data.php new file mode 100644 index 00000000000000..cdf4c66c9551cd --- /dev/null +++ b/lib/compat/wordpress-7.2/emojibase-data.php @@ -0,0 +1,30 @@ +query_vars['type'] ) && '' === $query->query_vars['type'] ) { + $query->set( 'type', '' ); + + global $wpdb; + $internal_types = array( 'note', 'reaction' ); + $type_placeholders = implode( ', ', array_fill( 0, count( $internal_types ), '%s' ) ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + $clauses['where'] .= ' AND ' . $wpdb->prepare( "{$wpdb->comments}.comment_type NOT IN ( $type_placeholders )", $internal_types ); + } + + return $clauses; +} +add_action( 'comments_clauses', 'gutenberg_exclude_block_comments_from_admin_7_2', 10, 2 ); + +/** + * Excludes internal comment types from comment feeds. + * + * WP_Query builds the comment feed SQL directly (not via WP_Comment_Query) + * and only hardcodes the 'note' exclusion, so reactions would otherwise + * appear in public comment feeds. + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $cwhere The WHERE clause of the comment feed query. + * @return string The modified WHERE clause. + */ +function gutenberg_exclude_internal_comment_types_from_feed_7_2( $cwhere ) { + global $wpdb; + $internal_types = array( 'note', 'reaction' ); + $type_placeholders = implode( ', ', array_fill( 0, count( $internal_types ), '%s' ) ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + return $cwhere . ' AND ' . $wpdb->prepare( "{$wpdb->comments}.comment_type NOT IN ( $type_placeholders )", $internal_types ); +} +add_filter( 'comment_feed_where', 'gutenberg_exclude_internal_comment_types_from_feed_7_2' ); + +/** + * Filter the comment count query to exclude notes and reactions. + * + * Core hardcodes the 'note' exclusion in this query; this also excludes + * 'reaction'. + * + * @param string $query The SQL query string. + * @return string The modified SQL query string. + */ +function gutenberg_filter_comment_count_query_exclude_block_comments_7_2( $query ) { + if ( str_starts_with( $query, 'SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM' ) && str_contains( $query, 'comment_approved' ) ) { + // Add an exclusion clause for each internal type not already present. + // Core's query already hardcodes the note-only exclusion, so expanding + // per type - rather than bailing when any exclusion exists - ensures + // reactions are excluded too and keeps the filter idempotent if it + // runs more than once. + $type_clauses = array(); + foreach ( array( 'note', 'reaction' ) as $internal_type ) { + $clause = "comment_type != '" . esc_sql( $internal_type ) . "'"; + if ( ! str_contains( $query, $clause ) ) { + $type_clauses[] = $clause; + } + } + if ( ! empty( $type_clauses ) ) { + $query = str_replace( 'comment_approved', implode( ' AND ', $type_clauses ) . ' AND comment_approved', $query ); + } + } + return $query; +} +add_filter( 'query', 'gutenberg_filter_comment_count_query_exclude_block_comments_7_2' ); + +/** + * Adjusts the comments list table query so notes and reactions never display. + * + * Extends core's 'note' guard in WP_Comments_List_Table to the '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_2( $args ) { + if ( ! empty( $_REQUEST['comment_type'] ) && in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) { + unset( $args['type'] ); + } + return $args; +} +add_filter( 'comments_list_table_query_args', 'gutenberg_hide_note_from_comment_list_table_7_2' ); + +/** + * Override comment_count to exclude notes and reactions from the comment count. + * + * Core's default count excludes only 'note'; this also excludes 'reaction'. + * + * @param int|null $new_count The new comment count. Default null. + * @param int $old_count 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_2( $new_count, $old_count, $post_id ) { + global $wpdb; + if ( null !== $new_count ) { + return $new_count; + } + $internal_types = array( 'note', 'reaction' ); + $type_placeholders = implode( ', ', array_fill( 0, count( $internal_types ), '%s' ) ); + $new_count = (int) $wpdb->get_var( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type NOT IN ( $type_placeholders )", + array_merge( array( $post_id ), $internal_types ) + ) + ); + return $new_count; +} +add_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count_7_2', 10, 3 ); + +/** + * Returns the reaction children of a note. + * + * @since 7.2.0 + * + * @param WP_Comment $note The note whose reactions to fetch. + * @param string $status Comment status to match. Default 'any', which + * includes trashed reactions. + * @return int[] Reaction comment IDs. + */ +function gutenberg_get_note_reaction_ids( $note, $status = 'any' ) { + return get_comments( + array( + 'parent' => $note->comment_ID, + 'type' => 'reaction', + 'status' => $status, + 'fields' => 'ids', + 'orderby' => 'comment_ID', + ) + ); +} + +/** + * Permanently deletes a note's reactions along with the note. + * + * `wp_delete_comment()` reparents a deleted comment's children one level up + * rather than deleting them, so reactions would otherwise survive their note + * as approved, orphaned rows still carrying the reactor's identity. Core + * cascades only `note` children (see `wp_trash_comment()`), so reactions need + * their own cascade. + * + * Runs on `delete_comment`, which fires before the reparenting query. + * + * @since 7.2.0 + * + * @param string $comment_id The comment ID as a numeric string. + * @param WP_Comment $comment The comment being deleted. + */ +function gutenberg_delete_note_reactions( $comment_id, $comment ) { + if ( ! $comment instanceof WP_Comment || 'note' !== $comment->comment_type ) { + return; + } + + // Every status: trashing the note, or a REST delete without `force`, + // leaves reactions in the trash. + foreach ( gutenberg_get_note_reaction_ids( $comment ) as $reaction_id ) { + wp_delete_comment( $reaction_id, true ); + } +} +add_action( 'delete_comment', 'gutenberg_delete_note_reactions', 10, 2 ); + +/** + * Trashes a note's reactions along with the note. + * + * Core cascades a trashed note to its `note` children only, so reactions + * would otherwise stay approved under a trashed note. Replies are covered + * because core trashes each one, which fires this action again. + * + * Restoring the note does not bring its reactions back: core's + * `wp_untrash_comment()` restores no children of any type, so restoring + * children is left to a cascade that covers every child type together. + * + * @since 7.2.0 + * + * @param string $comment_id The comment ID as a numeric string. + * @param WP_Comment $comment The trashed comment. + */ +function gutenberg_trash_note_reactions( $comment_id, $comment ) { + if ( ! $comment instanceof WP_Comment || 'note' !== $comment->comment_type ) { + return; + } + + foreach ( gutenberg_get_note_reaction_ids( $comment, 'approve' ) as $reaction_id ) { + wp_trash_comment( $reaction_id ); + } +} +add_action( 'trashed_comment', 'gutenberg_trash_note_reactions', 10, 2 ); diff --git a/lib/load.php b/lib/load.php index 27e3f29806c3da..1024e42c68175b 100644 --- a/lib/load.php +++ b/lib/load.php @@ -96,6 +96,9 @@ function gutenberg_get_experiment_default( $name ) { require __DIR__ . '/compat/wordpress-7.2/class-gutenberg-rest-templates-controller-7-2.php'; require __DIR__ . '/compat/wordpress-7.2/view-config-api.php'; require __DIR__ . '/compat/wordpress-7.2/class-gutenberg-rest-view-config-controller-7-2.php'; + require __DIR__ . '/compat/wordpress-7.2/note-reactions.php'; + require __DIR__ . '/compat/wordpress-7.2/emojibase-data.php'; + require __DIR__ . '/compat/wordpress-7.2/class-gutenberg-rest-comment-controller-7-2.php'; require __DIR__ . '/compat/wordpress-7.2/rest-api.php'; require __DIR__ . '/compat/wordpress-7.2/edit-root-attachment.php'; require __DIR__ . '/compat/wordpress-7.2/notes.php'; diff --git a/package-lock.json b/package-lock.json index f3bbf4cc606be4..c0133562f9d5c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20159,6 +20159,33 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/emojibase": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/emojibase/-/emojibase-17.0.0.tgz", + "integrity": "sha512-bXdpf4HPY3p41zK5swVKZdC/VynsMZ4LoLxdYDE+GucqkFwzcM1GVc4ODfYAlwoKaf2U2oNNUoOO78N96ovpBA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/milesjohnson" + } + }, + "node_modules/emojibase-data": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/emojibase-data/-/emojibase-data-17.0.0.tgz", + "integrity": "sha512-Yvgb5AWoHViHV/gq1qr5ZAarcBip+B27/ZLRsUJkbgAEaLlZ/fof9g882LTpmEpyhBNEC0m2SEmItljHsTygjA==", + "license": "MIT", + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/milesjohnson" + }, + "peerDependencies": { + "emojibase": "*" + } + }, "node_modules/emojilib": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", @@ -46259,6 +46286,7 @@ "babel-plugin-inline-json-import": "^0.3.2", "chalk": "^4.1.1", "cross-spawn": "^7.0.6", + "emojibase-data": "^17.0.0", "esbuild": "^0.27.2", "fast-glob": "^3.2.7", "magic-string": "^0.30.21", diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 82c03ae4bf50b1..592b5a569a7ef8 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -6,6 +6,11 @@ - Raise the minimum required Node.js version to `>=22.13.0`, for stable `require(esm)` support ([#84313](https://github.com/WordPress/gutenberg/pull/84313)). +### New Features + +- Notes: allow reacting to a note with an emoji. Reactions display as pill buttons on the note with per-emoji counts, backed by a new `reaction` comment type. The add-reaction trigger shows on a selected note, fading in on hover or focus in its top corner, so a note with no reactions costs no vertical space for the option ([#76767](https://github.com/WordPress/gutenberg/pull/76767)). +- Notes: the add-reaction button now opens a full searchable emoji picker with a per-user skin tone preference, powered by a self-hosted Emojibase dataset configured via the `noteEmojibaseUrl` editor setting ([#78176](https://github.com/WordPress/gutenberg/pull/78176)). + ### Enhancements - Migrate the classic-theme template selector to `@wordpress/ui` `SelectControl` ([#84345](https://github.com/WordPress/gutenberg/pull/84345)). diff --git a/packages/editor/src/components/collab-sidebar/README.md b/packages/editor/src/components/collab-sidebar/README.md index d239a61a5074e5..b36da71328e3e4 100644 --- a/packages/editor/src/components/collab-sidebar/README.md +++ b/packages/editor/src/components/collab-sidebar/README.md @@ -24,6 +24,16 @@ collab-sidebar/ ā”œā”€ā”€ note-indicator-toolbar.jsx NoteAvatarIndicator - toolbar participants avatars ā”œā”€ā”€ floating-container.jsx FloatingContainer - stack wrapper that applies `top` in floating mode │ +ā”œā”€ā”€ reactions/ emoji reactions on a note +│ ā”œā”€ā”€ use-reaction.ts useReaction( note ) - the note's `reaction_summary` + toggleReaction +│ ā”œā”€ā”€ reaction-display.tsx ReactionDisplay - reaction pills with reactor-name tooltips +│ ā”œā”€ā”€ add-reaction-button.tsx AddReactionButton - the add-reaction trigger for the picker +│ ā”œā”€ā”€ emoji-picker.tsx EmojiPicker - Autocomplete trigger + popup grid over the Emojibase dataset +│ ā”œā”€ā”€ skin-tone-picker.tsx SkinTonePicker - default skin tone preference Menu +│ ā”œā”€ā”€ emojibase-data.ts Emojibase dataset loading, labels, and settings +│ ā”œā”€ā”€ frequent-emojis.ts useFrequentEmojis - persisted "Frequently used" section +│ └── reaction-emojis.ts curated reaction set and storage keys +│ ā”œā”€ā”€ hooks.js useNoteThreads, useNoteActions, useNoteSelection, usePickNote, useNoteFocus, useFloatingBoard, useEnableFloatingSidebar ā”œā”€ā”€ utils.js focusNoteThread, getNoteExcerpt, sanitizeNoteContent, calculateNotePositions, getAvatarBorderColor ā”œā”€ā”€ board-store.js createBoardStore - DOM measurement for the floating layout diff --git a/packages/editor/src/components/collab-sidebar/note-thread.jsx b/packages/editor/src/components/collab-sidebar/note-thread.jsx index 6aeef3577d6435..7d899629e881ef 100644 --- a/packages/editor/src/components/collab-sidebar/note-thread.jsx +++ b/packages/editor/src/components/collab-sidebar/note-thread.jsx @@ -206,6 +206,10 @@ export function NoteThread( { } const allReplies = note?.reply || []; + // Resolving is a thread-level state: it lives on the root note, and a + // resolved thread is an archived conversation. Replies carry their own + // status, so reaction controls have to read the root's. + const isThreadResolved = 'approved' === note.status; const lastReply = allReplies.length > 0 ? allReplies[ allReplies.length - 1 ] : undefined; const restReplies = allReplies.length > 0 ? allReplies.slice( 0, -1 ) : []; @@ -297,6 +301,7 @@ export function NoteThread( { onEditNote={ onEditNote } onDeleteNote={ onDeleteNote } onResolve={ handleResolve } + isThreadResolved={ isThreadResolved } /> { isSelected && allReplies.map( ( reply ) => ( @@ -307,6 +312,7 @@ export function NoteThread( { isSelected={ isSelected } onEditNote={ onEditNote } onDeleteNote={ onDeleteNote } + isThreadResolved={ isThreadResolved } /> ) ) } { ! isSelected && restReplies.length > 0 && ( @@ -344,6 +350,7 @@ export function NoteThread( { isSelected={ false } onEditNote={ onEditNote } onDeleteNote={ onDeleteNote } + isThreadResolved={ isThreadResolved } /> ) } { isSelected && ( diff --git a/packages/editor/src/components/collab-sidebar/note.jsx b/packages/editor/src/components/collab-sidebar/note.jsx index a4e4ada8a2b506..d4cd8b7b2d914b 100644 --- a/packages/editor/src/components/collab-sidebar/note.jsx +++ b/packages/editor/src/components/collab-sidebar/note.jsx @@ -6,10 +6,16 @@ import { } from '@wordpress/components'; // eslint-disable-next-line @wordpress/use-recommended-components -- Intentional early adoption of the new Menu, pending WordPress/gutenberg#76135. import { Button as UIButton, Menu } from '@wordpress/ui'; +import { ThemeProvider } from '@wordpress/theme'; import { __, _x, sprintf } from '@wordpress/i18n'; import { moreVertical, published } from '@wordpress/icons'; import { NoteCard } from './note-card'; import { NoteForm } from './note-form'; +import ReactionDisplay, { + getReactedHexKeys, +} from './reactions/reaction-display'; +import { AddReactionButton } from './reactions/add-reaction-button'; +import { useReaction } from './reactions/use-reaction'; function NoteActionsMenu( { items, buttonRef } ) { return ( @@ -50,7 +56,9 @@ export function Note( { onEditNote, onDeleteNote, onResolve, + isThreadResolved = false, } ) { + const { reactions, toggleReaction } = useReaction( note ); const [ actionState, setActionState ] = useState( null ); const actionButtonRef = useRef( null ); @@ -77,6 +85,11 @@ export function Note( { }, [ rawContent ] ); const canResolve = note.parent === 0; + const hasReactions = getReactedHexKeys( reactions ).length > 0; + // Not while editing: the trigger floats over the note's corner, which + // during an edit is the form's own text field. Not on a resolved thread + // either, where reactions can no longer be toggled. + const canReact = isSelected && actionState !== 'edit' && ! isThreadResolved; const metaStatus = note.meta?._wp_note_status; const isResolutionNote = metaStatus === 'resolved' || metaStatus === 'reopen'; @@ -209,9 +222,58 @@ export function Note( { - { body } +
+ { body } + { ( hasReactions || canReact ) && ( + // The editor sets `cornerRadius="none"`, but reactions + // read as badges rather than controls, so the row opts + // into the pill shape the Design System's `pronounced` + // preset gives a small Button. + +
+ { /* + * Reactions are information about the note, so + * unlike its actions they stay visible once the + * thread is deselected. + */ } + + { /* + * Trails the pills, in the same tree position + * whether or not there are any: a different + * position once the first reaction lands would + * remount the open picker's trigger and drop + * focus to the body. + */ } + { canReact && ( + + ) } + +
+
+ ) } +
{ actionState === 'delete' && ( void; +} + +/** + * Standalone add-reaction button, opening the searchable emoji picker + * with its "Frequently used" section seeded from the curated set. + * + * @param props Component props. + * @param props.reactions The note's reaction summary, used to mark the + * emoji the current user has already reacted with. + * @param props.onToggleReaction Callback to toggle a reaction. + */ +export function AddReactionButton( { + reactions, + onToggleReaction, +}: AddReactionButtonProps ) { + const reactedHexKeys = Object.keys( reactions ?? {} ).filter( ( hexKey ) => + hasUserReacted( reactions, hexKey ) + ); + + return ( + + } + reactedHexKeys={ reactedHexKeys } + onSelect={ ( emoji ) => onToggleReaction( emojiToHexKey( emoji ) ) } + /> + ); +} diff --git a/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx new file mode 100644 index 00000000000000..aafc468132db11 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reactions/emoji-picker.tsx @@ -0,0 +1,576 @@ +import type { ReactElement, ReactNode } from 'react'; +import clsx from 'clsx'; +import { __, _n, _x, sprintf } from '@wordpress/i18n'; +import { + Autocomplete, + Icon, + Input, + InputLayout, + VisuallyHidden, +} from '@wordpress/ui'; +import { search } from '@wordpress/icons'; +import { useEffect, useMemo, useRef, useState } from '@wordpress/element'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { store as preferencesStore } from '@wordpress/preferences'; +import { + detectLocale, + normalizeHexcode, + useEmojibaseConfig, + useEmojibaseData, +} from './emojibase-data'; +import type { EmojibaseEntry } from './emojibase-data'; +import { useFrequentEmojis } from './frequent-emojis'; +import { emojiToHexKey, getCuratedLabel } from './reaction-emojis'; +import SkinTonePicker, { applySkinTone } from './skin-tone-picker'; + +/** + * A category bucket of emoji records keyed by its Emojibase `group`. + */ +interface EmojiGroup { + key: number; + emojis: EmojibaseEntry[]; +} + +/** + * One grid cell, as handed to `Autocomplete.Root` through `items`. + */ +interface EmojiOption { + // Unique per section, since an emoji can appear in "Frequently used" too. + key: string; + // The emoji character, with the user's skin tone applied. + value: string; + label: string; + // Normalized hexcode of the base record, used for usage tracking. + hexKey: string; +} + +/** + * A category section of the grid while browsing. + */ +interface EmojiOptionGroup { + key: string; + label: string; + items: EmojiOption[]; +} + +interface EmojiPickerProps { + /** + * The button that opens the picker, passed to `Autocomplete.Trigger` + * as its `render` element. + */ + trigger: ReactElement< Record< string, unknown > >; + /** + * Whether the trigger is disabled. + */ + disabled?: boolean; + /** + * Accessible name for the picker popup. + */ + label: string; + /** + * Hex keys of the emoji the current user has already reacted with, + * marked in the grid. + */ + reactedHexKeys?: string[]; + onSelect: ( emoji: string ) => void; +} + +/** + * Preference key (in the `core` scope) storing the user's default emoji + * skin tone: 0 (default yellow) through 5 (dark), matching Emojibase + * `tone` values. + */ +export const SKIN_TONE_PREFERENCE_KEY = 'emojiPickerSkinTone'; + +const COLUMNS = 6; + +const EMPTY_HEX_KEYS: string[] = []; + +/* + * Unicode's Component group holds the skin-tone swatches and hair + * modifiers, which only ever combine with another emoji. They are not + * pickable on their own, so the group is dropped rather than shown as a + * row of bare swatches. + */ +const COMPONENT_GROUP = 2; + +/** + * Unicode's own name for an emoji group, taken verbatim from the + * `# group:` lines of `emoji-test.txt`. Emojibase indexes the same groups + * by `group` / `order` but ships its English names lowercased and + * pluralized ("smileys & emotion", "components"), and cases the other + * locales inconsistently, so the headings are translated here rather than + * read from its `messages.json`. + * + * Resolved per render so the strings are looked up after the editor's + * translations have loaded. + * + * @param key Emojibase `group` key. + * @return The category heading, or an empty string for an unknown group. + */ +export function getGroupLabel( key: number ): string { + switch ( key ) { + case 0: + return __( 'Smileys & Emotion' ); + case 1: + return __( 'People & Body' ); + case 3: + return __( 'Animals & Nature' ); + case 4: + return __( 'Food & Drink' ); + case 5: + return __( 'Travel & Places' ); + case 6: + return __( 'Activities' ); + case 7: + return __( 'Objects' ); + case 8: + return __( 'Symbols' ); + case 9: + return __( 'Flags' ); + default: + return ''; + } +} + +/** + * Group emoji records by their Emojibase `group` key, preserving + * Emojibase's natural ordering (which follows Unicode). Entries with no + * `group`, and the Component group, are skipped. + * + * @param data Emoji records from `data.json`. + * @return Ordered category buckets. + */ +export function groupEmojis( data: EmojibaseEntry[] ): EmojiGroup[] { + const buckets = new Map< number, EmojibaseEntry[] >(); + for ( const entry of data ) { + if ( + typeof entry.group !== 'number' || + entry.group === COMPONENT_GROUP + ) { + continue; + } + if ( ! buckets.has( entry.group ) ) { + buckets.set( entry.group, [] ); + } + buckets.get( entry.group )!.push( entry ); + } + return Array.from( buckets.entries() ) + .sort( ( a, b ) => a[ 0 ] - b[ 0 ] ) + .map( ( [ key, emojis ] ) => ( { key, emojis } ) ); +} + +/** + * Slice a flat list of emoji into rows of `COLUMNS` items so the grid + * keeps a stable column count even as the visible list shrinks during + * search. + * + * @param emojis Emoji records or options. + * @return Rows of up to `COLUMNS` emoji each. + */ +export function chunkRows< T >( emojis: T[] ): T[][] { + const rows: T[][] = []; + for ( let i = 0; i < emojis.length; i += COLUMNS ) { + rows.push( emojis.slice( i, i + COLUMNS ) ); + } + return rows; +} + +/** + * Case-insensitive search over emoji labels and Emojibase tags. Returns + * the unfiltered list when the query is empty. + * + * @param emojis Emoji records. + * @param query Search query. + * @return Matching emoji records. + */ +export function searchEmojis( + emojis: EmojibaseEntry[], + query: string +): EmojibaseEntry[] { + const trimmed = query.trim().toLowerCase(); + if ( ! trimmed ) { + return emojis; + } + return emojis.filter( ( entry ) => { + /* + * Match both the curated and the Emojibase label, so searching + * either name finds the emoji. + */ + const curated = getCuratedLabel( normalizeHexcode( entry.hexcode ) ); + if ( curated && curated.toLowerCase().includes( trimmed ) ) { + return true; + } + if ( entry.label && entry.label.toLowerCase().includes( trimmed ) ) { + return true; + } + if ( Array.isArray( entry.tags ) ) { + for ( const tag of entry.tags ) { + if ( tag.toLowerCase().includes( trimmed ) ) { + return true; + } + } + } + return false; + } ); +} + +/** + * Searchable emoji picker: a trigger button opening an autocomplete popup + * with the search field on top of the emoji grid. Emoji data and labels + * come from the per-locale Emojibase files at `noteEmojibaseUrl`; UI chrome + * strings go through `@wordpress/i18n`. Without a dataset (no URL + * configured, or the fetch failed) the popup shows an error message. + * + * @param props Component props. + * @param props.trigger The button that opens the picker. + * @param props.disabled Whether the trigger is disabled. + * @param props.label Accessible name for the popup. + * @param props.reactedHexKeys Hex keys of the user's own reactions. + * @param props.onSelect Called with the selected emoji character. + */ +export default function EmojiPicker( { + trigger, + disabled = false, + label, + reactedHexKeys = EMPTY_HEX_KEYS, + onSelect, +}: EmojiPickerProps ) { + const [ isOpen, setIsOpen ] = useState( false ); + /* + * Every note renders a trigger, so hold the dataset fetch until the + * user reaches for one: hovering, focusing, or opening it warms the + * data, ideally before the popup opens. + */ + const [ isWarm, setIsWarm ] = useState( false ); + const warm = () => setIsWarm( true ); + const { baseUrl } = useEmojibaseConfig(); + const [ locale ] = useState( detectLocale ); + const { + data: dataset, + isLoading, + error, + retry, + } = useEmojibaseData( isWarm ? baseUrl : null, locale ); + const data = useMemo( () => dataset ?? [], [ dataset ] ); + const [ query, setQuery ] = useState( '' ); + + /* + * Announce once when a pending load fills the grid. A cached dataset + * renders straight away, so it needs no announcement. + */ + const [ hasJustLoaded, setHasJustLoaded ] = useState( false ); + const wasLoadingRef = useRef( isLoading ); + useEffect( () => { + if ( wasLoadingRef.current && ! isLoading && ! error ) { + setHasJustLoaded( true ); + } + wasLoadingRef.current = isLoading; + }, [ isLoading, error ] ); + + const { frequentKeys, recordUse } = useFrequentEmojis(); + const skinTone = useSelect( + ( select ) => + select( preferencesStore ).get( + 'core', + SKIN_TONE_PREFERENCE_KEY + ) ?? 0, + [] + ); + const { set: setPreference } = useDispatch( preferencesStore ); + + const groups = useMemo( () => groupEmojis( data ), [ data ] ); + + // Resolves stored frequently-used hex keys back to full records. + const recordByHexKey = useMemo( () => { + const map = new Map< string, EmojibaseEntry >(); + for ( const entry of data ) { + if ( typeof entry.group === 'number' ) { + map.set( normalizeHexcode( entry.hexcode ), entry ); + } + } + return map; + }, [ data ] ); + + const isSearching = !! query.trim(); + + /* + * The items handed to `Autocomplete.Root`: category groups while + * browsing, a flat list while searching. Filtering stays ours + * (`filter={ null }`), since it matches curated labels and Emojibase + * tags, so these are already the visible results. + */ + const items = useMemo( (): EmojiOptionGroup[] | EmojiOption[] => { + const toOption = ( + entry: EmojibaseEntry, + prefix: string + ): EmojiOption => { + /* + * The variant is shown and selected; the base record still + * drives search, usage, and the grid key. + */ + const display = applySkinTone( entry, skinTone ); + return { + key: `${ prefix }-${ entry.hexcode }`, + value: display.emoji, + label: + getCuratedLabel( normalizeHexcode( display.hexcode ) ) || + display.label || + '', + hexKey: normalizeHexcode( entry.hexcode ), + }; + }; + + if ( isSearching ) { + /* + * One flat grid of results, as in the macOS picker: + * per-category sections would scatter a few hits under + * mostly-empty headers. + */ + return groups + .flatMap( ( group ) => searchEmojis( group.emojis, query ) ) + .map( ( entry ) => toOption( entry, 'search' ) ); + } + + // Hidden during search, where it would duplicate the category hits. + const frequent = frequentKeys + .map( ( key ) => recordByHexKey.get( key ) ) + .filter( ( entry ): entry is EmojibaseEntry => Boolean( entry ) ); + + return [ + { + key: 'frequent', + label: __( 'Frequently used' ), + items: frequent.map( ( entry ) => + toOption( entry, 'frequent' ) + ), + }, + ...groups.map( ( group ) => ( { + key: String( group.key ), + label: getGroupLabel( group.key ), + items: group.emojis.map( ( entry ) => + toOption( entry, String( group.key ) ) + ), + } ) ), + ].filter( ( group ) => group.items.length > 0 ); + }, [ groups, isSearching, query, frequentKeys, recordByHexKey, skinTone ] ); + + /** + * Render grid rows of emoji cells, recording usage on selection. + * + * @param options Emoji options to lay out. + * @return The rendered rows. + */ + const renderRows = ( options: EmojiOption[] ) => + chunkRows( options ).map( ( row, rowIndex ) => ( + + { row.map( ( option ) => { + const isReacted = reactedHexKeys.includes( + emojiToHexKey( option.value ) + ); + return ( + { + recordUse( option.hexKey ); + setIsOpen( false ); + onSelect( option.value ); + } } + > + + + + + ); + } ) } + + ) ); + + const loadFailed = !! error || ! baseUrl; + let status: ReactNode = null; + if ( isLoading ) { + status = __( 'Loading…' ); + } else if ( loadFailed ) { + status = __( 'Couldn’t load emojis.' ); + } else if ( hasJustLoaded && ! isSearching && groups.length > 0 ) { + // An empty dataset is left to `Autocomplete.Empty`. + const emojiCount = groups.reduce( + ( total, group ) => total + group.emojis.length, + 0 + ); + status = ( + + { sprintf( + /* translators: %d: number of emojis available to pick. */ + _n( + '%d emoji available.', + '%d emojis available.', + emojiCount + ), + emojiCount + ) } + + ); + } else if ( isSearching && items.length > 0 ) { + // No results are left to `Autocomplete.Empty`. + status = ( + + { sprintf( + /* translators: %d: number of emojis matching the search. */ + _n( '%d emoji found.', '%d emojis found.', items.length ), + items.length + ) } + + ); + } + + return ( + { + setIsOpen( nextOpen ); + if ( nextOpen ) { + warm(); + // A failed load would otherwise stick until reload. + if ( error ) { + retry(); + } + } + } } + // Start each opening from the full grid. + onOpenChangeComplete={ ( nextOpen: boolean ) => { + if ( ! nextOpen ) { + setQuery( '' ); + } + } } + // Groups while browsing, a flat list while searching. Base UI takes + // either at runtime, but its overloads can't take the union. + items={ items as readonly EmojiOption[] } + filter={ null } + // Enter picks the top hit once the user has typed. + autoHighlight + value={ query } + onValueChange={ ( value: string, { reason } ) => { + // Picking a cell would otherwise fill the search field. + if ( reason === 'item-press' ) { + return; + } + setQuery( value ); + // The search result count replaces the load announcement. + setHasJustLoaded( false ); + } } + > + + + } + > +
+ + + + + } + suffix={ + isSearching ? ( + + + + ) : undefined + } + /> + } + /> + + + setPreference( + 'core', + SKIN_TONE_PREFERENCE_KEY, + tone + ) + } + /> +
+ { /* + * The arrow keys in the search field drive the grid, so + * keep its scroller out of the Tab order. Browsers would + * otherwise make it a Tab stop, since none of the cells are + * tabbable, and a screen reader would read out every emoji. + */ } +
+ { status } + + { isLoading || loadFailed + ? null + : __( 'No emoji found.' ) } + + + { isSearching + ? renderRows( items as EmojiOption[] ) + : ( group: EmojiOptionGroup ) => ( + + + { group.label } + + { renderRows( group.items ) } + + ) } + +
+
+
+ ); +} diff --git a/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts b/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts new file mode 100644 index 00000000000000..e271ed9a00d7ef --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts @@ -0,0 +1,420 @@ +import { useCallback, useEffect, useState } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +// @ts-expect-error - No type declarations available for @wordpress/block-editor +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { getCuratedLabel } from './reaction-emojis'; + +/* + * Emojibase loading shared by the picker and the reaction pills (labels + * only). + */ + +/** + * Emojibase configuration read from the block editor settings. + */ +export interface EmojibaseConfig { + // Null when the site serves no dataset. + baseUrl: string | null; +} + +/** + * Read the Emojibase configuration from the block editor settings. The + * plugin sets it server-side; npm consumers supply it themselves. + * + * @return The Emojibase base URL. + */ +export function useEmojibaseConfig(): EmojibaseConfig { + const baseUrl = useSelect( + ( select ) => + ( + select( blockEditorStore ).getSettings() as Record< + string, + unknown + > + ).noteEmojibaseUrl, + [] + ); + return { baseUrl: typeof baseUrl === 'string' && baseUrl ? baseUrl : null }; +} + +/** + * A single skin-tone variant of an Emojibase emoji record. + */ +export interface EmojibaseSkin { + hexcode: string; + emoji: string; + label?: string; + // One Fitzpatrick tone, or an array for mixed-tone combinations. + tone?: number | number[]; +} + +/** + * The subset of an Emojibase record the picker and pills read. + */ +export interface EmojibaseEntry { + hexcode: string; + emoji: string; + label?: string; + tags?: string[]; + group?: number; + order?: number; + skins?: EmojibaseSkin[]; +} + +/** + * A loaded Emojibase dataset for a single locale. + */ +export interface EmojibaseDataset { + data: EmojibaseEntry[]; +} + +/** + * Loading state for a requested Emojibase dataset. + */ +export interface EmojibaseDataState { + data: EmojibaseEntry[] | null; + isLoading: boolean; + error: Error | null; +} + +export interface EmojibaseDataResult extends EmojibaseDataState { + // Refetch after a failed load. + retry: () => void; +} + +/* + * Locales Emojibase ships translated labels for. Must stay in sync with + * `LOCALES` in `tools/build-scripts/copy-emojibase-data.mjs`. + */ +export const EMOJIBASE_LOCALES = new Set( [ + 'bn', + 'da', + 'de', + 'en', + 'en-gb', + 'es', + 'es-mx', + 'et', + 'fi', + 'fr', + 'hi', + 'hu', + 'it', + 'ja', + 'ko', + 'lt', + 'ms', + 'nb', + 'nl', + 'pl', + 'pt', + 'ru', + 'sv', + 'th', + 'uk', + 'vi', + 'zh', + 'zh-hant', +] ); + +/** + * Map a BCP-47 / WordPress locale string to the closest Emojibase locale, + * falling back to English. Tries the full tag (e.g. `pt-br`) first, then + * the language portion (`pt`), then `en`. + * + * @param raw The user-facing locale, e.g. `pt-BR` or `fr_FR`. + * @return An Emojibase locale key. + */ +export function resolveEmojibaseLocale( raw: string ): string { + if ( typeof raw !== 'string' || ! raw ) { + return 'en'; + } + const normalized = raw.toLowerCase().replace( '_', '-' ); + if ( EMOJIBASE_LOCALES.has( normalized ) ) { + return normalized; + } + /* + * Must precede the language-portion fallback: `zh` is itself a + * supported locale and would otherwise swallow these. + */ + if ( [ 'zh-tw', 'zh-hk', 'zh-mo' ].includes( normalized ) ) { + return 'zh-hant'; + } + const lang = normalized.split( '-' )[ 0 ]; + if ( EMOJIBASE_LOCALES.has( lang ) ) { + return lang; + } + return 'en'; +} + +/** + * Detect the active locale for the picker. Prefers `` (set by + * WordPress to the site language) and falls back to `navigator.language`. + * + * @return A locale key Emojibase ships data for. + */ +export function detectLocale(): string { + if ( typeof document !== 'undefined' && document.documentElement?.lang ) { + return resolveEmojibaseLocale( document.documentElement.lang ); + } + if ( typeof navigator !== 'undefined' && navigator.language ) { + return resolveEmojibaseLocale( navigator.language ); + } + return 'en'; +} + +// Module-level cache so opening the picker twice doesn't refetch. +const dataCache = new Map< string, EmojibaseDataset >(); +const inflight = new Map< string, Promise< EmojibaseDataset > >(); + +/** + * Fetch and cache the Emojibase `data.json` for a given locale. Resolves + * with `{ data }` or rejects on a network error so callers can render an + * error/empty state. Category headings come from Unicode's own group + * names in the picker, so `messages.json` is never fetched. + * + * @param baseUrl Same-origin base URL for the emojibase-data + * directory (e.g. plugin's `build/emojibase-data`). + * @param locale Emojibase locale key. + * @return Loaded dataset. + */ +export function loadEmojibaseData( + baseUrl: string, + locale: string +): Promise< EmojibaseDataset > { + const cacheKey = `${ baseUrl }|${ locale }`; + if ( dataCache.has( cacheKey ) ) { + return Promise.resolve( dataCache.get( cacheKey ) as EmojibaseDataset ); + } + if ( inflight.has( cacheKey ) ) { + return inflight.get( cacheKey ) as Promise< EmojibaseDataset >; + } + const promise = fetch( `${ baseUrl }/${ locale }/data.json` ) + .then( ( r ) => { + if ( ! r.ok ) { + throw new Error( `Failed to load ${ locale }/data.json` ); + } + return r.json(); + } ) + .then( ( data: unknown ) => { + // Anything but an array would throw in the picker's render. + if ( ! Array.isArray( data ) ) { + throw new Error( `Invalid ${ locale }/data.json` ); + } + const value: EmojibaseDataset = { + data: data as EmojibaseEntry[], + }; + dataCache.set( cacheKey, value ); + inflight.delete( cacheKey ); + return value; + } ) + .catch( ( error ) => { + inflight.delete( cacheKey ); + throw error; + } ); + inflight.set( cacheKey, promise ); + return promise; +} + +/** + * React hook that fetches Emojibase data for a given base URL and locale. + * + * @param baseUrl Same-origin URL pointing at the emojibase data dir. + * @param locale Emojibase locale key. + * @return Loading state for the dataset. + */ +export function useEmojibaseData( + baseUrl: string | null, + locale: string +): EmojibaseDataResult { + const [ attempt, setAttempt ] = useState( 0 ); + const retry = useCallback( () => setAttempt( ( n ) => n + 1 ), [] ); + const [ state, setState ] = useState< EmojibaseDataState >( () => { + const cached = dataCache.get( `${ baseUrl }|${ locale }` ); + return { + data: cached?.data || null, + // Nothing loads without a URL, so never report it as pending. + isLoading: !! baseUrl && ! cached, + error: null, + }; + } ); + + useEffect( () => { + if ( ! baseUrl ) { + return; + } + let cancelled = false; + const cached = dataCache.get( `${ baseUrl }|${ locale }` ); + if ( cached ) { + setState( { + data: cached.data, + isLoading: false, + error: null, + } ); + return; + } + setState( ( prev ) => ( { ...prev, isLoading: true, error: null } ) ); + loadEmojibaseData( baseUrl, locale ) + .then( ( { data } ) => { + if ( cancelled ) { + return; + } + setState( { + data, + isLoading: false, + error: null, + } ); + } ) + .catch( ( error ) => { + if ( cancelled ) { + return; + } + setState( { + data: null, + isLoading: false, + error, + } ); + } ); + return () => { + cancelled = true; + }; + }, [ baseUrl, locale, attempt ] ); + + return { ...state, retry }; +} + +/** + * Normalize an Emojibase hexcode (`2764-FE0F`) to the reaction storage key + * form: lowercase, four-digit padded, U+FE0F stripped (`2764`). Must agree + * with `emojiToHexKey()` in `reaction-emojis.ts`. + * + * @param hexcode Emojibase hexcode. + * @return Normalized hex key. + */ +export function normalizeHexcode( hexcode: string ): string { + return hexcode + .toLowerCase() + .split( '-' ) + .filter( ( part ) => part !== 'fe0f' ) + .map( ( part ) => part.padStart( 4, '0' ) ) + .join( '-' ); +} + +// One label map per `baseUrl|locale`, built lazily from the dataset. +const labelMapCache = new Map< string, Map< string, string > >(); + +/** + * Build (and cache) a normalized-hex-key to label Map for a dataset, with + * the curated reactions keeping their own labels. + * + * @param cacheKey `baseUrl|locale` cache key. + * @param data Emojibase emoji records. + * @return Map from hex key to label. + */ +function buildLabelMap( + cacheKey: string, + data: EmojibaseEntry[] +): Map< string, string > { + const existing = labelMapCache.get( cacheKey ); + if ( existing ) { + return existing; + } + const map = new Map< string, string >(); + const indexEntry = ( hexcode: string, label: string ) => { + const hexKey = normalizeHexcode( hexcode ); + map.set( hexKey, getCuratedLabel( hexKey ) || label ); + }; + for ( const entry of data ) { + if ( ! entry.hexcode || ! entry.label ) { + continue; + } + indexEntry( entry.hexcode, entry.label ); + // Non-default skin tones are stored under their own hex keys. + if ( Array.isArray( entry.skins ) ) { + for ( const skin of entry.skins ) { + if ( skin.hexcode && skin.label ) { + indexEntry( skin.hexcode, skin.label ); + } + } + } + } + labelMapCache.set( cacheKey, map ); + return map; +} + +/** + * Look up the label for a stored hex-key reaction from the already + * loaded dataset, without triggering a fetch. + * + * @param hexKey Normalized reaction hex key, e.g. `1f44d`. + * @param baseUrl Same-origin URL of the Emojibase dataset directory. + * @return The label, or null when unknown/not loaded. + */ +export function getCachedEmojiLabel( + hexKey: string, + baseUrl: string | null +): string | null { + if ( ! baseUrl ) { + return null; + } + const cacheKey = `${ baseUrl }|${ detectLocale() }`; + const cached = dataCache.get( cacheKey ); + if ( ! cached ) { + return null; + } + return buildLabelMap( cacheKey, cached.data ).get( hexKey ) || null; +} + +/** + * Resolve the label for a hex-key reaction so its pill tooltip reads like a + * curated one. A loaded dataset resolves from the module cache; otherwise + * the fetch waits for `load`, since the dataset is ~775KB and should not be + * pulled just because a note carries a reaction. Until then the + * emoji character stands in, which assistive technology announces by its + * Unicode name. + * + * @param hexKey Normalized reaction hex key, e.g. `1f44d`. + * @param enabled Whether resolution should run (false for curated + * emoji, which have their own labels). + * @param load Whether to fetch the dataset when it isn't cached. + * @return The resolved label, or null while unresolved. + */ +export function useEmojiLabel( + hexKey: string, + enabled: boolean, + load: boolean +): string | null { + const { baseUrl } = useEmojibaseConfig(); + const [ label, setLabel ] = useState< string | null >( () => + enabled ? getCachedEmojiLabel( hexKey, baseUrl ) : null + ); + + useEffect( () => { + if ( ! enabled || ! load || label || ! baseUrl ) { + return; + } + const locale = detectLocale(); + let cancelled = false; + loadEmojibaseData( baseUrl, locale ) + .then( ( { data } ) => { + if ( cancelled ) { + return; + } + const resolved = buildLabelMap( + `${ baseUrl }|${ locale }`, + data + ).get( hexKey ); + if ( resolved ) { + setLabel( resolved ); + } + } ) + .catch( () => { + // Leave the emoji character as the fallback label. + } ); + return () => { + cancelled = true; + }; + }, [ hexKey, enabled, load, label, baseUrl ] ); + + return label; +} diff --git a/packages/editor/src/components/collab-sidebar/reactions/frequent-emojis.ts b/packages/editor/src/components/collab-sidebar/reactions/frequent-emojis.ts new file mode 100644 index 00000000000000..240dd3ce2ac519 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reactions/frequent-emojis.ts @@ -0,0 +1,149 @@ +import { useCallback } from '@wordpress/element'; +import { useSelect, useRegistry } from '@wordpress/data'; +import { store as preferencesStore } from '@wordpress/preferences'; +import { REACTION_EMOJIS, emojiToHexKey } from './reaction-emojis'; + +/** + * A recorded frequently-used emoji: its normalized hex key and how many + * times the user has picked it. + */ +export interface FrequentEmojiEntry { + key: string; + count: number; +} + +/** + * Preference key (`core` scope) storing frequently used emoji as + * `{ key, count }` entries, sorted by count with recency breaking ties and + * capped at `MAX_FREQUENT_EMOJIS`. + */ +export const FREQUENT_EMOJIS_PREFERENCE_KEY = 'emojiPickerFrequentEmojis'; + +// Five full rows of the picker's 6-column grid. +export const MAX_FREQUENT_EMOJIS = 30; + +/** + * Seeds the frequently-used section before any picks. Never persisted: + * appended at display time, and they fall away as usage fills the list. + */ +export const DEFAULT_FREQUENT_EMOJI_KEYS = REACTION_EMOJIS.map( ( entry ) => + emojiToHexKey( entry.emoji ) +); + +/** + * Drop malformed entries from a stored preference value so a corrupt + * or legacy value can never break the picker. + * + * @param entries The raw preference value. + * @return Sanitized entries. + */ +function sanitizeEntries( entries: unknown ): FrequentEmojiEntry[] { + if ( ! Array.isArray( entries ) ) { + return []; + } + return entries.filter( + ( entry ): entry is FrequentEmojiEntry => + !! entry && + typeof entry.key === 'string' && + !! entry.key && + typeof entry.count === 'number' && + Number.isFinite( entry.count ) && + entry.count > 0 + ); +} + +/** + * Record one more use of `key`, placing it ahead of entries with the same + * or a lower count. Over the cap, the least used entry other than the one + * just recorded is discarded, so a new pick always lands. + * + * @param entries Current `{ key, count }` entries. + * @param key Normalized hex key of the picked emoji. + * @return The updated entries. + */ +export function recordEmojiUse( + entries: unknown, + key: string +): FrequentEmojiEntry[] { + const list = sanitizeEntries( entries ); + if ( ! key || typeof key !== 'string' ) { + return list; + } + const count = ( list.find( ( e ) => e.key === key )?.count || 0 ) + 1; + const next = list.filter( ( e ) => e.key !== key ); + const index = next.findIndex( ( e ) => e.count <= count ); + next.splice( index === -1 ? next.length : index, 0, { key, count } ); + while ( next.length > MAX_FREQUENT_EMOJIS ) { + // Evict from the tail, skipping the just-recorded key. + let evictIndex = next.length - 1; + if ( next[ evictIndex ].key === key ) { + evictIndex -= 1; + } + next.splice( evictIndex, 1 ); + } + return next; +} + +/** + * Hex keys for the "Frequently used" section: recorded usage first, padded + * with unrecorded curated defaults, capped at `MAX_FREQUENT_EMOJIS`. + * + * @param entries Stored `{ key, count }` entries. + * @param defaultKeys Hex keys used to seed the list before usage fills it. + * @return Ordered hex keys. + */ +export function getFrequentEmojiKeys( + entries: unknown, + defaultKeys: string[] = DEFAULT_FREQUENT_EMOJI_KEYS +): string[] { + const keys = sanitizeEntries( entries ).map( ( entry ) => entry.key ); + const seen = new Set( keys ); + for ( const key of defaultKeys ) { + if ( ! seen.has( key ) ) { + keys.push( key ); + seen.add( key ); + } + } + return keys.slice( 0, MAX_FREQUENT_EMOJIS ); +} + +/** + * The persisted frequently-used list and a `recordUse` callback. Usage is + * stored per user in the preferences store, so it follows them across + * sessions and browsers. + * + * @return The display-ready hex keys and the usage recorder. + */ +export function useFrequentEmojis(): { + frequentKeys: string[]; + recordUse: ( key: string ) => void; +} { + const registry = useRegistry(); + const frequentKeys = useSelect( + ( select ) => + getFrequentEmojiKeys( + select( preferencesStore ).get( + 'core', + FREQUENT_EMOJIS_PREFERENCE_KEY + ) + ), + [] + ); + const recordUse = useCallback( + ( key: string ) => { + // Read at call time so back-to-back picks don't clobber. + const entries = registry + .select( preferencesStore ) + .get( 'core', FREQUENT_EMOJIS_PREFERENCE_KEY ); + registry + .dispatch( preferencesStore ) + .set( + 'core', + FREQUENT_EMOJIS_PREFERENCE_KEY, + recordEmojiUse( entries, key ) + ); + }, + [ registry ] + ); + return { frequentKeys, recordUse }; +} diff --git a/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx b/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx new file mode 100644 index 00000000000000..9850f88a5e303b --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reactions/reaction-display.tsx @@ -0,0 +1,453 @@ +import type { MouseEvent, ReactNode } from 'react'; +import { __, sprintf, _n } from '@wordpress/i18n'; +/* + * `Button` is pending Design System review (WordPress/gutenberg#76135); + * used here for its pill shape and quiet neutral treatment. + */ +// eslint-disable-next-line @wordpress/use-recommended-components +import { Button, Stack, Tooltip } from '@wordpress/ui'; +import { useState, useCallback } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +import { getCuratedLabel, hexKeyToEmoji } from './reaction-emojis'; +import { useEmojiLabel } from './emojibase-data'; + +interface ReactionSummaryEntry { + count: number; + // The current user's reaction comment ID, used to delete it again; + // 0 when they have not reacted with this emoji. + current_user_reaction: number; +} + +/** + * The reaction summary keyed by emoji hex key. + */ +export type ReactionSummary = Record< string, ReactionSummaryEntry >; + +/** + * A comment record as returned by the reactions REST query. + */ +interface ReactionComment { + author_name: string; + content: string | { raw?: string; rendered?: string }; +} + +/** + * Get the count of reactions for a specific emoji. + * + * @param reactions The reactions summary (keyed by hex key). + * @param hexKey The reaction hex key to count. + * @return The count of reactions. + */ +function getReactionCount( + reactions: ReactionSummary | null | undefined, + hexKey: string +): number { + return reactions?.[ hexKey ]?.count || 0; +} + +/** + * Check if the current user has reacted with a specific emoji. + * + * @param reactions The reactions summary (keyed by hex key). + * @param hexKey The reaction hex key to check. + * @return Whether the user has reacted. + */ +export function hasUserReacted( + reactions: ReactionSummary | null | undefined, + hexKey: string +): boolean { + return ( reactions?.[ hexKey ]?.current_user_reaction ?? 0 ) > 0; +} + +/** + * Get all reaction hex keys that have reactions. + * + * @param reactions The reactions summary (keyed by hex key). + * @return Array of hex keys with reactions. + */ +export function getReactedHexKeys( + reactions: ReactionSummary | null | undefined +): string[] { + if ( ! reactions ) { + return []; + } + return Object.keys( reactions ).filter( + ( hexKey ) => reactions[ hexKey ]?.count > 0 + ); +} + +/** + * Generate GitHub-style tooltip text from user names. + * + * @param names Array of user display names. + * @param emojiLabel The emoji label. + * @return The tooltip text. + */ +function formatReactionTooltip( names: string[], emojiLabel: string ): string { + if ( names.length === 1 ) { + return sprintf( + /* translators: 1: user name, 2: emoji label. */ + __( '%1$s reacted with %2$s' ), + 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' ), + 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', + '%1$s, %2$s, and %3$d others reacted with %4$s', + othersCount + ), + names[ 0 ], + names[ 1 ], + othersCount, + emojiLabel + ); +} + +const REACTIONS_PER_PAGE = 100; + +// A note with more reactions than this is not worth walking page by page just +// to name them; the pill falls back to its count-based label instead. +const MAX_REACTION_PAGES = 10; + +/** + * Fetches every reaction on a note, across every emoji. + * + * The REST collection cannot be filtered by reaction hex key, so the whole set + * has to come back before it can be grouped. Walks the pages rather than + * reading only the first one, which would drop reactors on a busy note. + * + * @param noteId The parent note comment ID. + * @return All reactions on the note, or `null` if there are more than the + * walk is willing to fetch. + */ +async function fetchNoteReactions( + noteId: number +): Promise< ReactionComment[] | null > { + const reactions: ReactionComment[] = []; + + for ( let page = 1; page <= MAX_REACTION_PAGES; page++ ) { + let batch: ReactionComment[]; + try { + batch = await apiFetch< ReactionComment[] >( { + path: addQueryArgs( '/wp/v2/comments', { + parent: noteId, + type: 'reaction', + status: 'all', + page, + per_page: REACTIONS_PER_PAGE, + _fields: 'author_name,content', + } ), + } ); + } catch ( error ) { + // A full last page (exactly 100, 200, ... reactions) sends the + // walk one page past the end, which the endpoint rejects. + if ( + page > 1 && + ( error as { code?: string } )?.code === + 'rest_comment_invalid_page_number' + ) { + return reactions; + } + throw error; + } + + reactions.push( ...batch ); + + if ( batch.length < REACTIONS_PER_PAGE ) { + return reactions; + } + } + + return null; +} + +/* + * Module-level cache of reactor names per note, grouped by hex key. One fetch + * returns every emoji on the note, so all its pills share it, along with any + * request still in flight. Resolves to `null` when the note has more + * reactions than the walk will fetch. + */ +const reactionNamesCache = new Map< + number, + Promise< Record< string, string[] > | null > +>(); + +/** + * Groups reactor names by the hex key each reaction stores. + * + * @param reactions Reaction comments on one note. + * @return Reactor names keyed by hex key. + */ +function groupReactionNames( + reactions: ReactionComment[] +): Record< string, string[] > { + const grouped: Record< string, string[] > = {}; + for ( const reaction of reactions ) { + const content = + typeof reaction.content === 'object' + ? reaction.content?.raw || reaction.content?.rendered + : reaction.content; + const hexKey = content?.replace?.( /<[^>]*>/g, '' )?.trim(); + if ( hexKey ) { + grouped[ hexKey ] = [ + ...( grouped[ hexKey ] ?? [] ), + reaction.author_name, + ]; + } + } + return grouped; +} + +/** + * Resolves the reactor names on a note, fetching them at most once. + * + * @param noteId The parent note comment ID. + * @return Reactor names keyed by hex key, or `null` for a note too busy to + * name every reactor. + */ +function getReactionNames( + noteId: number +): Promise< Record< string, string[] > | null > { + let names = reactionNamesCache.get( noteId ); + if ( ! names ) { + const request = fetchNoteReactions( noteId ).then( + ( reactions ) => reactions && groupReactionNames( reactions ) + ); + // A failed request is not worth keeping; let the next hover retry. + request.catch( () => { + if ( reactionNamesCache.get( noteId ) === request ) { + reactionNamesCache.delete( noteId ); + } + } ); + reactionNamesCache.set( noteId, request ); + names = request; + } + return names; +} + +/** + * Drop the cached reactor names for a note, so the next tooltip refetches + * them. + * + * @param noteId The parent note comment ID. + */ +export function invalidateReactionNames( noteId: number ): void { + reactionNamesCache.delete( noteId ); +} + +interface ReactionButtonProps { + noteId: number; + hexKey: string; + count: number; + isActive: boolean; + emoji: string; + emojiLabel?: string; + disabled?: boolean; + onToggleReaction: ( hexKey: string ) => void; +} + +/** + * A single reaction pill button that lazy-loads user names on hover. + * + * @param props Component props. + * @param props.noteId The parent note comment ID. + * @param props.hexKey The emoji hex key. + * @param props.count The reaction count. + * @param props.isActive Whether the current user reacted. + * @param props.emoji The emoji character. + * @param props.emojiLabel The emoji label, if known (curated reactions + * only). + * @param props.disabled Whether the reaction can no longer be toggled + * (the thread is resolved). + * @param props.onToggleReaction Callback to toggle a reaction. + */ +function ReactionButton( { + noteId, + hexKey, + count, + isActive, + emoji, + emojiLabel, + disabled = false, + onToggleReaction, +}: ReactionButtonProps ) { + const [ names, setNames ] = useState< string[] | null >( null ); + // Hover or keyboard focus, which gates the Emojibase fetch below. + const [ isReached, setIsReached ] = useState( false ); + /* + * An emoji outside the curated set has no label of its own, so resolve + * one from Emojibase; until it arrives the emoji character stands in. + */ + const resolvedLabel = useEmojiLabel( hexKey, ! emojiLabel, isReached ); + const label = emojiLabel || resolvedLabel || emoji; + /* + * Derived, not stored: the names and label requests race, and a stored + * string would freeze whichever resolved first. + */ + const tooltipText = + names && names.length > 0 ? formatReactionTooltip( names, label ) : ''; + + const fetchReactionNames = useCallback( () => { + setIsReached( true ); + /* + * A miss on a pill that already listed names means it was + * invalidated; drop the stale list rather than show it against + * the new count while refetching. + */ + if ( ! reactionNamesCache.has( noteId ) ) { + setNames( null ); + } + getReactionNames( noteId ) + .then( ( grouped ) => { + // A truncated walk would drop reactors, and a partial name + // list reads as complete. Keep the count-based label instead. + if ( grouped ) { + setNames( grouped[ hexKey ] ?? [] ); + } + } ) + .catch( () => { + // Silently fall back to count-based label. + } ); + }, [ noteId, hexKey ] ); + + const defaultLabel = sprintf( + /* translators: 1: emoji label, 2: count of reactions */ + _n( '%1$s, %2$s reaction', '%1$s, %2$s reactions', count ), + label, + count.toLocaleString() + ); + + const accessibleLabel = tooltipText || defaultLabel; + + return ( + // Per the design, a reaction someone else left is quiet text with no + // border or fill, and the current user's own is the brand outline: + // a blue ring and count on a transparent pill. Neither variant + // carries a solid fill, so a row of pills never reads as a dark bar + // competing with the note it belongs to. + + ) => { + event.stopPropagation(); + // When removing the last reaction for this emoji, + // the button will disappear. Move focus to the + // parent note to prevent focus loss. + if ( isActive && count === 1 ) { + ( event.target as HTMLElement ) + .closest< HTMLElement >( + '.editor-collab-sidebar-panel__thread' + ) + ?.focus(); + } + setNames( null ); + onToggleReaction( hexKey ); + } } + onMouseEnter={ fetchReactionNames } + onFocus={ fetchReactionNames } + /> + } + > + + { emoji } + + { count.toLocaleString() } + + { accessibleLabel } + + ); +} + +interface ReactionDisplayProps { + noteId: number; + reactions: ReactionSummary | null | undefined; + disabled?: boolean; + onToggleReaction: ( hexKey: string ) => void; + children?: ReactNode; +} + +/** + * Display current reactions with counts as pill-shaped buttons. + * + * @param props Component props. + * @param props.noteId The parent note comment ID. + * @param props.reactions The reaction summary (keyed by hex key). + * @param props.disabled Whether reactions can no longer be toggled + * (the thread is resolved). + * @param props.onToggleReaction Callback to toggle a reaction. + * @param props.children Rendered after the last pill, inside the same + * wrapping row, so a trailing control follows + * the pills onto whichever line they end on. + */ +export default function ReactionDisplay( { + noteId, + reactions, + disabled = false, + onToggleReaction, + children, +}: ReactionDisplayProps ) { + const reactedHexKeys = getReactedHexKeys( reactions ); + + if ( reactedHexKeys.length === 0 && ! children ) { + return null; + } + + return ( + // `sm`: at `xs` two adjacent outlined pills read as one shape. + + { reactedHexKeys.map( ( hexKey ) => { + const count = getReactionCount( reactions, hexKey ); + const isActive = hasUserReacted( reactions, hexKey ); + + return ( + + ); + } ) } + { children } + + ); +} diff --git a/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts b/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts new file mode 100644 index 00000000000000..82e5eb5e51f35f --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reactions/reaction-emojis.ts @@ -0,0 +1,126 @@ +import { _x } from '@wordpress/i18n'; + +/** + * A single curated reaction emoji. + */ +export interface CuratedEmoji { + emoji: string; + label: string; +} + +/** + * Curated emoji set for reactions, seeding "Frequently used" and naming + * these emoji with their own translated labels. Lowercase, like the + * Emojibase names around them, since they also appear mid-sentence + * ("Adam reacted with heart"). + */ +export const REACTION_EMOJIS: CuratedEmoji[] = [ + { emoji: 'ā¤ļø', label: _x( 'heart', 'emoji reaction' ) }, + { emoji: 'šŸŽ‰', label: _x( 'celebration', 'emoji reaction' ) }, + { emoji: 'šŸ˜„', label: _x( 'smile', 'emoji reaction' ) }, + { emoji: 'šŸ‘€', label: _x( 'eyes', 'emoji reaction' ) }, + { emoji: 'šŸš€', label: _x( 'rocket', 'emoji reaction' ) }, +]; + +/** + * Reactions storage format: every reaction, curated or not, is stored as + * lowercase hex code points joined by `-`, e.g. `2764` or `1f44d`. U+FE0F + * is stripped so both presentations of an emoji share one key, and each + * code point is padded to four digits to match the Emojibase `hexcode` + * field. + * + * ASCII keys sidestep utf8/utf8mb4 portability issues on the comments + * table and group stably in the `reaction_summary` aggregation. + */ + +const HEX_KEY_RE = /^[0-9a-f]{4,6}(-[0-9a-f]{4,6})*$/; + +const VARIATION_SELECTOR = '\u{FE0F}'; +const EMOJI_RE = /^\p{Emoji}$/u; +const EMOJI_PRESENTATION_RE = /^\p{Emoji_Presentation}$/u; +const SKIN_TONE_RE = /^[\u{1F3FB}-\u{1F3FF}]$/u; + +/** + * Convert an emoji to its lowercase hex-codepoint sequence, stripping + * U+FE0F so equivalent presentations collapse to one key. + * + * Code points are padded to four digits as Emojibase writes them (`00a9`, + * not `a9`); unpadded, the emoji below U+1000 such as Ā© and the keycaps + * would never match a dataset entry. + * + * @param emoji The emoji character. + * @return Lowercase hex codepoints joined by `-`. + */ +export function emojiToHexKey( emoji: string ): string { + if ( typeof emoji !== 'string' || ! emoji ) { + return ''; + } + return Array.from( emoji.replace( /\u{FE0F}/gu, '' ) ) + .map( ( c ) => + ( c.codePointAt( 0 ) as number ).toString( 16 ).padStart( 4, '0' ) + ) + .join( '-' ); +} + +/** + * Whether a code point needs U+FE0F to render as a colour emoji. + * + * Text-presentation emoji such as ā¤ and the keycap digits draw as + * monochrome without it, and ZWJ sequences are only recognized fully + * qualified. A skin-tone modifier already forces emoji presentation, so + * adding the selector there would break the sequence apart. + * + * @param char The code point. + * @param next The code point that follows it, if any. + * @return Whether the variation selector is required. + */ +function needsVariationSelector( + char: string, + next: string | undefined +): boolean { + return ( + EMOJI_RE.test( char ) && + ! EMOJI_PRESENTATION_RE.test( char ) && + ! ( next !== undefined && SKIN_TONE_RE.test( next ) ) + ); +} + +/** + * Convert a hex-codepoint sequence back to its emoji character, + * restoring the variation selectors that `emojiToHexKey()` stripped. + * + * @param hexKey Lowercase hex codepoints joined by `-`. + * @return The emoji character, or the input on parse failure. + */ +export function hexKeyToEmoji( hexKey: string ): string { + if ( typeof hexKey !== 'string' || ! HEX_KEY_RE.test( hexKey ) ) { + return hexKey; + } + try { + const chars = hexKey + .split( '-' ) + .map( ( p ) => String.fromCodePoint( parseInt( p, 16 ) ) ); + return chars + .map( ( char, index ) => + needsVariationSelector( char, chars[ index + 1 ] ) + ? char + VARIATION_SELECTOR + : char + ) + .join( '' ); + } catch { + return hexKey; + } +} + +/** + * The translated label of a curated reaction, so the full picker and the + * pills name it the same way the quick reactions do. + * + * @param hexKey Normalized hex key, e.g. `2764`. + * @return The curated label, or undefined for any other emoji. + */ +export function getCuratedLabel( hexKey: string ): string | undefined { + return REACTION_EMOJIS.find( + ( entry ) => emojiToHexKey( entry.emoji ) === hexKey + )?.label; +} diff --git a/packages/editor/src/components/collab-sidebar/reactions/skin-tone-picker.tsx b/packages/editor/src/components/collab-sidebar/reactions/skin-tone-picker.tsx new file mode 100644 index 00000000000000..494ebd52cd89f2 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reactions/skin-tone-picker.tsx @@ -0,0 +1,137 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; +// eslint-disable-next-line @wordpress/use-recommended-components -- Intentional early adoption of the new Menu, pending WordPress/gutenberg#76135. +import { Menu, Tooltip } from '@wordpress/ui'; +import type { EmojibaseEntry, EmojibaseSkin } from './emojibase-data'; + +/** + * A selectable skin tone swatch. + */ +interface SkinToneOption { + tone: number; + emoji: string; + label: string; +} + +interface SkinTonePickerProps { + value: number; + onChange: ( tone: number ) => void; +} + +/** + * The six skin tones in display order. Tone `0` is the default yellow + * presentation, listed first so it is an explicit choice rather than the + * absence of one; 1-5 match the Emojibase `tone` values. Every swatch uses + * the same exemplar emoji so only the tone differs. + */ +export const SKIN_TONES: SkinToneOption[] = [ + { tone: 0, emoji: 'āœ‹', label: __( 'Default skin tone' ) }, + { tone: 1, emoji: 'āœ‹šŸ»', label: __( 'Light skin tone' ) }, + { tone: 2, emoji: 'āœ‹šŸ¼', label: __( 'Medium-light skin tone' ) }, + { tone: 3, emoji: 'āœ‹šŸ½', label: __( 'Medium skin tone' ) }, + { tone: 4, emoji: 'āœ‹šŸ¾', label: __( 'Medium-dark skin tone' ) }, + { tone: 5, emoji: 'āœ‹šŸæ', label: __( 'Dark skin tone' ) }, +]; + +/** + * The record to display for an emoji at a given skin tone. Falls back to + * the base entry for tone 0, for emoji without variants, and for + * mixed-tone variants, which a single-tone preference cannot produce. + * + * @param entry Emojibase emoji record. + * @param tone Selected tone, 0 (default) through 5. + * @return The record to render: a skin variant or the base entry. + */ +export function applySkinTone( + entry: EmojibaseEntry, + tone: number +): EmojibaseEntry | EmojibaseSkin { + if ( ! tone || ! Array.isArray( entry.skins ) ) { + return entry; + } + return entry.skins.find( ( skin ) => skin.tone === tone ) || entry; +} + +/** + * Skin tone selector: a toggle showing the selected tone, opening a menu + * of the six tones as a radio group. + * + * @param props Component props. + * @param props.value The selected tone, 0–5. + * @param props.onChange Called with the newly selected tone. + */ +export default function SkinTonePicker( { + value, + onChange, +}: SkinTonePickerProps ) { + const current = + SKIN_TONES.find( ( option ) => option.tone === value ) || + SKIN_TONES[ 0 ]; + const triggerLabel = sprintf( + // translators: %s: the selected skin tone, e.g. "Medium skin tone". + __( 'Skin tone: %s' ), + current.label + ); + + return ( + + { /* + * A `@wordpress/components` tooltip renders behind this + * `@wordpress/ui` popover, so the toggle uses the ui Tooltip. + */ } + + + } + /> + } + > + + + { triggerLabel } + + } + > + onChange( tone ) } + > + + { __( 'Choose your default skin tone' ) } + + { SKIN_TONES.map( ( { tone, emoji, label } ) => ( + + ) ) } + + + + ); +} diff --git a/packages/editor/src/components/collab-sidebar/reactions/test/emoji-picker.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/reactions/test/emoji-picker.jsdom.test.tsx new file mode 100644 index 00000000000000..a61a17ebae6a08 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reactions/test/emoji-picker.jsdom.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import '@wordpress/preferences'; +import EmojiPicker from '../emoji-picker'; +import type * as EmojibaseData from '../emojibase-data'; + +const DATASET = [ + { hexcode: '1F600', emoji: 'šŸ˜€', label: 'grinning face', group: 0 }, + { + hexcode: '1F603', + emoji: '😃', + label: 'grinning face with big eyes', + group: 0, + }, + { hexcode: '1F436', emoji: '🐶', label: 'dog face', group: 3 }, +]; + +vi.mock( import( '../emojibase-data' ), async ( importOriginal ) => { + const actual = ( await importOriginal() ) as typeof EmojibaseData; + return { + ...actual, + useEmojibaseConfig: () => ( { baseUrl: 'https://example.org/' } ), + useEmojibaseData: () => ( { + data: DATASET, + isLoading: false, + error: null, + retry: () => {}, + } ), + }; +} ); + +describe( 'EmojiPicker', () => { + it( 'announces the number of search results', async () => { + render( + } + label="Add reaction" + onSelect={ () => {} } + /> + ); + + fireEvent.click( screen.getByRole( 'combobox', { name: 'React' } ) ); + + const search = await screen.findByRole( 'combobox', { + name: 'Search emoji', + } ); + fireEvent.change( search, { target: { value: 'grinning' } } ); + + await waitFor( () => + expect( + screen + .getAllByRole( 'status' ) + .map( ( region ) => region.textContent ) + ).toContain( '2 emojis found.' ) + ); + } ); +} ); diff --git a/packages/editor/src/components/collab-sidebar/reactions/test/emojibase-data.jsdom.test.ts b/packages/editor/src/components/collab-sidebar/reactions/test/emojibase-data.jsdom.test.ts new file mode 100644 index 00000000000000..aaca8793cf343b --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reactions/test/emojibase-data.jsdom.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { loadEmojibaseData, useEmojibaseData } from '../emojibase-data'; + +const ENTRY = { hexcode: '1F600', emoji: 'šŸ˜€', label: 'grinning face' }; + +function mockFetchResponses( ...bodies: Array< unknown | Error > ) { + const fetchMock = vi.fn(); + for ( const body of bodies ) { + fetchMock.mockImplementationOnce( () => + body instanceof Error + ? Promise.reject( body ) + : Promise.resolve( { + ok: true, + json: () => Promise.resolve( body ), + } ) + ); + } + vi.stubGlobal( 'fetch', fetchMock ); + return fetchMock; +} + +describe( 'loadEmojibaseData', () => { + afterEach( () => { + vi.unstubAllGlobals(); + } ); + + it( 'rejects a response that is not an array', async () => { + mockFetchResponses( { not: 'an array' } ); + + await expect( + loadEmojibaseData( 'https://example.com/shape', 'en' ) + ).rejects.toThrow( 'Invalid en/data.json' ); + } ); +} ); + +describe( 'useEmojibaseData', () => { + afterEach( () => { + vi.unstubAllGlobals(); + } ); + + it( 'loads the dataset again when retried after a failure', async () => { + const fetchMock = mockFetchResponses( new Error( 'Network down' ), [ + ENTRY, + ] ); + + const { result } = renderHook( () => + useEmojibaseData( 'https://example.com/retry', 'en' ) + ); + + await waitFor( () => expect( result.current.error ).not.toBeNull() ); + expect( result.current.data ).toBeNull(); + + act( () => result.current.retry() ); + + await waitFor( () => + expect( result.current.data ).toEqual( [ ENTRY ] ) + ); + expect( result.current.error ).toBeNull(); + expect( fetchMock ).toHaveBeenCalledTimes( 2 ); + } ); +} ); diff --git a/packages/editor/src/components/collab-sidebar/reactions/test/reaction-display.jsdom.test.tsx b/packages/editor/src/components/collab-sidebar/reactions/test/reaction-display.jsdom.test.tsx new file mode 100644 index 00000000000000..02e72497470843 --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reactions/test/reaction-display.jsdom.test.tsx @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import apiFetch from '@wordpress/api-fetch'; +import ReactionDisplay, { invalidateReactionNames } from '../reaction-display'; + +vi.mock( import( '@wordpress/api-fetch' ) ); + +const NOTE_ID = 7; + +const REACTIONS = [ + { author_name: 'Ada', content: { raw: '2764' } }, + { author_name: 'Grace', content: { raw: '1f44d' } }, + { author_name: 'Linus', content: { raw: '2764' } }, +]; + +function renderPills() { + render( + {} } + /> + ); + return screen.getAllByRole( 'button' ); +} + +describe( 'ReactionDisplay', () => { + beforeEach( () => { + invalidateReactionNames( NOTE_ID ); + vi.mocked( apiFetch ).mockReset(); + vi.mocked( apiFetch ).mockResolvedValue( REACTIONS ); + } ); + + it( 'marks a pill pressed only when the current user reacted with it', () => { + const [ heart, thumbsUp ] = renderPills(); + + expect( heart ).toHaveAttribute( 'aria-pressed', 'false' ); + expect( thumbsUp ).toHaveAttribute( 'aria-pressed', 'true' ); + } ); + + it( 'fetches the reactions on a note once for all of its pills', async () => { + const [ heart, thumbsUp ] = renderPills(); + + fireEvent.focus( heart ); + fireEvent.focus( thumbsUp ); + + await waitFor( () => + expect( heart ).toHaveAccessibleName( + expect.stringContaining( 'Ada and Linus' ) + ) + ); + await waitFor( () => + expect( thumbsUp ).toHaveAccessibleName( + expect.stringContaining( 'Grace' ) + ) + ); + expect( apiFetch ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'refetches after the note is invalidated', async () => { + const [ heart ] = renderPills(); + + fireEvent.focus( heart ); + await waitFor( () => expect( apiFetch ).toHaveBeenCalledTimes( 1 ) ); + + invalidateReactionNames( NOTE_ID ); + fireEvent.focus( heart ); + + await waitFor( () => expect( apiFetch ).toHaveBeenCalledTimes( 2 ) ); + } ); +} ); diff --git a/packages/editor/src/components/collab-sidebar/reactions/test/use-reaction.jsdom.test.ts b/packages/editor/src/components/collab-sidebar/reactions/test/use-reaction.jsdom.test.ts new file mode 100644 index 00000000000000..db76fa6a3e840c --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reactions/test/use-reaction.jsdom.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { applyReactionDelta, useReaction } from '../use-reaction'; + +const mockDispatch = vi.hoisted( () => vi.fn() ); +vi.mock( import( '@wordpress/data' ), async ( importOriginal ) => ( { + ...( await importOriginal() ), + useDispatch: mockDispatch, + useSelect: ( () => ( { + getEntityRecord: () => undefined, + getCurrentPostId: () => 1, + } ) ) as never, +} ) ); +vi.mock( import( '@wordpress/api-fetch' ), () => ( { + default: vi.fn( () => Promise.reject( new Error() ) ) as never, +} ) ); + +// The editor store pulls in viewport listeners that jsdom can't run, and +// `applyReactionDelta` never touches it. +vi.mock( import( '../../../../store' ), () => ( { store: {} } ) as never ); + +describe( 'applyReactionDelta', () => { + it( 'records the added reaction as the current user reaction', () => { + const note = { + id: 1, + reaction_summary: { 2764: { count: 1, current_user_reaction: 0 } }, + }; + + expect( + applyReactionDelta( note, '2764', { added: 99 } ).reaction_summary + ).toEqual( { 2764: { count: 2, current_user_reaction: 99 } } ); + } ); + + it( 'ignores an added reaction that is already counted', () => { + const note = { + id: 1, + reaction_summary: { 2764: { count: 2, current_user_reaction: 99 } }, + }; + + expect( applyReactionDelta( note, '2764', { added: 99 } ) ).toBe( + note + ); + } ); + + it( 'clears the current user reaction when one is removed', () => { + const note = { + id: 1, + reaction_summary: { 2764: { count: 2, current_user_reaction: 99 } }, + }; + + expect( + applyReactionDelta( note, '2764', { removed: 99 } ).reaction_summary + ).toEqual( { + 2764: { count: 1, current_user_reaction: 0 }, + } ); + } ); + + it( 'drops the entry when the last reaction is removed', () => { + const note = { + id: 1, + reaction_summary: { 2764: { count: 1, current_user_reaction: 99 } }, + }; + + expect( + applyReactionDelta( note, '2764', { removed: 99 } ).reaction_summary + ).toEqual( {} ); + } ); + + it( 'ignores a removal another refresh already folded in', () => { + // Someone else's reaction remains after a refresh cleared ours. + const note = { + id: 1, + reaction_summary: { 2764: { count: 1, current_user_reaction: 0 } }, + }; + + expect( applyReactionDelta( note, '2764', { removed: 99 } ) ).toBe( + note + ); + } ); +} ); + +describe( 'useReaction', () => { + it( 'ignores a repeat toggle while the first is still pending', async () => { + let resolveSave: ( value: { id: number } ) => void = () => {}; + const saveEntityRecord = vi.fn( + () => + new Promise< { id: number } >( ( resolve ) => { + resolveSave = resolve; + } ) + ); + mockDispatch.mockReturnValue( { + createNotice: vi.fn(), + saveEntityRecord, + deleteEntityRecord: vi.fn(), + receiveEntityRecords: vi.fn(), + } ); + + const { result } = renderHook( () => + useReaction( { id: 7, reaction_summary: {} } ) + ); + + let first: Promise< void > = Promise.resolve(); + await act( async () => { + first = result.current.toggleReaction( '2764' ); + await result.current.toggleReaction( '2764' ); + } ); + expect( saveEntityRecord ).toHaveBeenCalledTimes( 1 ); + + await act( async () => { + resolveSave( { id: 99 } ); + await first; + } ); + } ); +} ); diff --git a/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts b/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts new file mode 100644 index 00000000000000..453e73a77ebf2c --- /dev/null +++ b/packages/editor/src/components/collab-sidebar/reactions/use-reaction.ts @@ -0,0 +1,239 @@ +import { __ } from '@wordpress/i18n'; +import { useCallback } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as noticesStore } from '@wordpress/notices'; +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +import { decodeEntities } from '@wordpress/html-entities'; +import { store as editorStore } from '../../../store'; +import { invalidateReactionNames } from './reaction-display'; +import type { ReactionSummary } from './reaction-display'; + +/** + * The parts of a note comment record that reactions read. + */ +export interface ReactionTarget { + id: number; + reaction_summary?: ReactionSummary | null; +} + +/** + * Folds a completed reaction toggle into a cached note record. + * + * Used to keep `reaction_summary` usable when the refetch that would + * normally replace it fails: without it, the next toggle reads a stale + * `current_user_reaction` and takes the wrong branch. + * + * @param note The cached note record. + * @param hexKey The reaction hex key that changed. + * @param change The comment ID of the current user's reaction that was + * added or removed. + * @return The note with an updated `reaction_summary`. + */ +export function applyReactionDelta< T extends ReactionTarget >( + note: T, + hexKey: string, + change: { added: number } | { removed: number } +): T { + const summary: ReactionSummary = { ...( note.reaction_summary || {} ) }; + const entry = summary[ hexKey ]; + + if ( 'added' in change ) { + // Concurrent adds converge server-side on one surviving row, so a + // repeated ID is already counted. + if ( entry?.current_user_reaction === change.added ) { + return note; + } + summary[ hexKey ] = { + count: ( entry?.count || 0 ) + 1, + current_user_reaction: change.added, + }; + } else { + // Another toggle's refresh may already have dropped the reaction; + // decrementing again would hide someone else's. + if ( entry?.current_user_reaction !== change.removed ) { + return note; + } + const count = entry.count - 1; + if ( count > 0 ) { + summary[ hexKey ] = { count, current_user_reaction: 0 }; + } else { + delete summary[ hexKey ]; + } + } + + return { ...note, reaction_summary: summary }; +} + +/* + * Per-note count of landed reaction mutations. Each toggle refetches the + * whole `reaction_summary`, so a refresh issued before a later mutation + * landed would overwrite that mutation's result; the counter lets it tell. + * Module-level so every `useReaction` instance shares it. + */ +const reactionMutationCounts = new Map< number, number >(); + +/* + * Toggles still in flight, keyed by note and emoji. A second click reads + * the same summary snapshot and would repeat the request, so it is + * ignored until the first one lands. Shared by the picker and the pills. + */ +const pendingToggles = new Set< string >(); + +/** + * A note's reactions and a callback to toggle one. + * + * `reaction_summary` is computed server-side and cached on the note's + * entity record, so the summary the note carries is the source of truth + * for whether the current user has already reacted with an emoji. + * + * @param note The note comment record. + * @return The note's reaction summary and the toggle callback. + */ +export function useReaction( note: ReactionTarget ) { + const { id: noteId, reaction_summary: reactions } = note; + const { createNotice } = useDispatch( noticesStore ); + const { saveEntityRecord, deleteEntityRecord, receiveEntityRecords } = + useDispatch( coreStore ); + const { getEntityRecord } = useSelect( coreStore ); + const { getCurrentPostId } = useSelect( editorStore ); + + const toggleReaction = useCallback( + async ( hexKey: string ) => { + const toggleKey = `${ noteId }:${ hexKey }`; + if ( pendingToggles.has( toggleKey ) ) { + return; + } + pendingToggles.add( toggleKey ); + + const entry = reactions?.[ hexKey ]; + const myReactionId = entry?.current_user_reaction || undefined; + let addedReactionId: number | undefined; + + try { + if ( myReactionId ) { + // Force-delete the reaction comment rather than + // trashing it (the WP REST default). Reactions + // don't have a trash workflow, and a trashed + // reaction would otherwise linger in `wp_comments` + // indefinitely each time the user toggles it off. + await deleteEntityRecord( + 'root', + 'comment', + myReactionId, + { force: true }, + { throwOnError: true } + ); + } else { + // Add a new reaction as a comment record. + const saved = await saveEntityRecord( + 'root', + 'comment', + { + post: getCurrentPostId(), + type: 'reaction', + parent: noteId, + content: hexKey, + status: 'approve', + }, + { throwOnError: true } + ); + addedReactionId = saved?.id; + } + } catch ( error ) { + const { message, code } = ( error ?? {} ) as { + message?: string; + code?: string; + }; + createNotice( + 'error', + message && code !== 'unknown_error' + ? decodeEntities( message ) + : __( 'An error occurred while performing an update.' ), + { type: 'snackbar', isDismissible: true } + ); + pendingToggles.delete( toggleKey ); + return; + } + + // The note's reactor lists changed, so its pill tooltips refetch. + invalidateReactionNames( noteId ); + + // Mutating a reaction comment doesn't invalidate the cached + // `reaction_summary`, so a subsequent toggle would read stale + // `current_user_reaction` data and route into the wrong + // branch (deleting an already-removed comment). + // + // The mutation has landed, so fold its known effect into the + // cached record first. That keeps the next toggle correct even + // if the refetch below never succeeds. + const mutationCount = + ( reactionMutationCounts.get( noteId ) ?? 0 ) + 1; + reactionMutationCounts.set( noteId, mutationCount ); + + const cached = getEntityRecord( 'root', 'comment', noteId ) as + ReactionTarget | undefined; + const change = myReactionId + ? { removed: myReactionId } + : addedReactionId && { added: addedReactionId }; + if ( cached && change ) { + receiveEntityRecords( 'root', 'comment', [ + applyReactionDelta( cached, hexKey, change ), + ] ); + } + // The cached summary now reflects this toggle, so the next one + // reads the right state. + pendingToggles.delete( toggleKey ); + + // Then refetch the parent note (1 record) for the authoritative + // summary, which also picks up other users' reactions. + // `receiveEntityRecords` with no `query` arg updates the + // per-record cache, which the list selector reads through by ID + // - so the LIST view picks up the fresh `reaction_summary` + // without re-fetching every other note on the post. Only the + // summary is requested and merged, so the view-context response + // can't replace the cached edit-context fields like + // `content.raw`, which seeds the edit form. + try { + const refreshed = await apiFetch< { + reaction_summary: ReactionSummary; + } >( { + path: addQueryArgs( `/wp/v2/comments/${ noteId }`, { + _fields: 'id,reaction_summary', + } ), + } ); + // A newer mutation landed after this snapshot was requested; + // its own refresh will carry the authoritative summary. + if ( reactionMutationCounts.get( noteId ) !== mutationCount ) { + return; + } + const latest = getEntityRecord( 'root', 'comment', noteId ); + if ( latest ) { + receiveEntityRecords( 'root', 'comment', [ + { + ...latest, + reaction_summary: refreshed.reaction_summary, + }, + ] ); + } + } catch { + // The toggle itself succeeded and the local delta above + // already keeps this note's reactions consistent, so there + // is nothing to report; the next load reconciles the rest. + } + }, + [ + noteId, + reactions, + createNotice, + deleteEntityRecord, + saveEntityRecord, + getCurrentPostId, + getEntityRecord, + receiveEntityRecords, + ] + ); + + return { reactions, toggleReaction }; +} diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 04f984e1d9cbc5..a3ecee5d00db29 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -96,8 +96,10 @@ } &:focus { - outline: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color); - outline-offset: calc((-1 * var(--wp-admin-border-width-focus))); + outline: + var(--wp-admin-border-width-focus) solid + var(--wp-admin-theme-color); + outline-offset: calc(( -1 * var(--wp-admin-border-width-focus) )); } &.is-floating { @@ -157,6 +159,11 @@ .editor-collab-sidebar-panel__note-content { overflow-wrap: break-word; + // Keep clear of the add-reaction trigger floating in the note's top + // corner. Reserved on every note, selected or not, so the text does not + // rewrap under the reader as a note is selected or gains its first + // reaction. + padding-right: $button-size-small + $grid-unit-10; p { margin-top: 0; @@ -318,6 +325,243 @@ mark.wp-note { transition: background-color 0.1s ease-in-out; } +// Emoji Reactions +// +// The add-reaction trigger and the reaction pills are Design System +// Buttons: the pill shape comes from the row's `pronounced` corner +// radius, the neutral minimal treatment marks the trigger and reactions +// left by others, and the brand outline marks the current user's own. +// Only the row layout and the emoji's own metrics live here. + +// The note's text and, below it, the reaction row. Anchors the trigger while +// it floats, so it lands on the note itself rather than on whatever else the +// card carries underneath. +.editor-collab-sidebar-panel__note-body { + position: relative; + display: flex; + flex-direction: column; + gap: $grid-unit-10; +} + +.editor-collab-sidebar-panel__reactions { + // Until the note has a reaction to show, the trigger is the whole row, and + // it floats in the note's top corner rather than making every note taller + // for an option nobody has taken up. Once a reaction lands, the row drops + // into the flow and the trigger trails the pills. + // + // The top corner lines up with the Resolve and Actions buttons in the + // header above, so the trigger reads as one more action in that column + // rather than a control parked wherever the text happened to end. The + // note's text keeps a safe area on that side (see `__note-content`), so + // nothing runs underneath it. + &.is-floating { + position: absolute; + right: 0; + // Centered on the first line of text, so a one-line note reads as a + // single row with its trigger. The row shares the text's font and + // line height, so its own `lh` is the text's line box. + top: calc((1lh - #{$button-size-small}) / 2); + } +} + +// While it floats alone, the trigger stays out of the way until the note is +// hovered, so reacting costs no standing real estate. Once the note has +// reactions, the trigger trails the pills and stays visible as part of the set. +// +// Only where a pointer can hover: a touch device would otherwise be left +// with a control it has no way to reveal. +@media (hover: hover) { + .editor-collab-sidebar-panel__reactions.is-floating + .editor-collab-sidebar-panel__add-reaction-button { + // Fading rather than removing keeps the button in the tab order and + // keeps the action row from reflowing as the pointer moves over it. + opacity: 0; + transition: opacity 0.1s ease-out; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + } + + // Scoped to the note under the pointer, not the thread around it: a + // reaction belongs to one note, so a thread-wide reveal would offer the + // option on every note in it at once. + .editor-collab-sidebar-panel__note:hover + .editor-collab-sidebar-panel__add-reaction-button, + // Reveal on the trigger itself rather than on the thread: the thread is + // `tabIndex={ 0 }`, so a thread-level `:focus-within` would hold for as + // long as the note is selected and the fade would never take effect. + .editor-collab-sidebar-panel__reactions + .editor-collab-sidebar-panel__add-reaction-button:focus, + // While the picker is open the pointer is over the popup, not the + // note, and focus has moved into the popup's own portal. + .editor-collab-sidebar-panel__reactions + .editor-collab-sidebar-panel__add-reaction-button[data-popup-open] { + opacity: 1; + } +} + +.editor-collab-sidebar-panel__reaction-button { + // The glyph and its count are one unit, closer than the Button's + // default gap between an icon and a label. + gap: $grid-unit-05; + // A count is at most a few characters, so the Button's four-character + // minimum would leave the pill padded out around it. + min-width: 0; +} + +// Opening the picker with a pointer leaves DOM focus on the trigger once +// it closes, parking a focus ring on the note at rest. Keep the ring for +// keyboard focus and while the picker is open, and drop it otherwise. +.editor-collab-sidebar-panel__add-reaction-button:focus:not(:focus-visible):not([data-popup-open]) { + outline: none; +} + +.editor-collab-sidebar-panel__reaction-button-emoji { + // Render the emoji glyph larger than the button's text size so it + // visually balances with the add-reaction icon that trails the pills. + font-size: 16px; + line-height: 1; +} + +// The picker is the `Autocomplete.Popup` itself, which supplies the +// surface, radius, and elevation; the rules below own its layout. +.editor-collab-sidebar-panel__picker { + // Grid geometry from the design: six 40px cells with a 4px gap and + // 16px padding on both sides. + --emoji-picker-cell-size: var(--wpds-dimension-size-lg); + --emoji-picker-gap: var(--wpds-dimension-gap-xs); + --emoji-picker-padding: var(--wpds-dimension-padding-lg); + --emoji-picker-grid-width: calc(var(--emoji-picker-cell-size) * 6 + var(--emoji-picker-gap) * 5); + + display: flex; + flex-direction: column; + // Sized by the grid viewport, whose max-content width includes any + // scrollbar gutter, so the six columns never overflow into it. + width: max-content; + height: 360px; + font-family: var(--wpds-typography-font-family-body); + font-size: var(--wpds-typography-font-size-sm); + color: var(--wpds-color-foreground-content-neutral); +} + +// Search field with the skin-tone toggle beside it. +.editor-collab-sidebar-panel__picker-search { + display: flex; + align-items: center; + gap: var(--wpds-dimension-gap-sm); + // The bottom padding keeps the scrolling grid, and the heading stuck + // to its top, clear of the input's focus ring. + padding: var(--emoji-picker-padding) var(--emoji-picker-padding) var(--wpds-dimension-padding-sm); + // Stretch to the grid's width rather than widening the picker. + contain: inline-size; +} + +.editor-collab-sidebar-panel__picker-input { + flex: 1; + min-width: 0; +} + +.editor-collab-sidebar-panel__skin-tone-toggle { + flex-shrink: 0; + // Fixed square, so the toggle doesn't grow with its emoji content. + width: var(--emoji-picker-cell-size); + padding: 0; + font-size: 24px; + line-height: 1; + justify-content: center; +} + +.editor-collab-sidebar-panel__skin-tone-swatch { + font-size: 18px; + line-height: 1; +} + +.editor-collab-sidebar-panel__picker-viewport { + flex: 1; + overflow: auto; + // Overlay scrollbars reserve nothing; classic ones get an equal gutter + // on both edges, so the grid stays centered either way. + scrollbar-gutter: stable both-edges; + padding-inline: var(--emoji-picker-padding); + // A keyboard-scrolled cell stays clear of the sticky category heading. + scroll-padding-block: var(--wpds-dimension-size-md) 0; +} + +// Hold the grid's width even while it is empty (loading, or no search +// results) so the popup doesn't resize and reposition. +.editor-collab-sidebar-panel__picker-list { + min-inline-size: var(--emoji-picker-grid-width); +} + +// The last row gets the same padding as the sides. +.editor-collab-sidebar-panel__picker-list { + padding-block-end: var(--emoji-picker-padding); +} + +// While browsing, the category heading spaces the grid from the input. +// Search results have no heading, so pad them instead: the full picker +// padding below the input, with the search row's bottom padding. +.editor-collab-sidebar-panel__picker-list.is-searching { + padding-block-start: calc(var(--emoji-picker-padding) - var(--wpds-dimension-padding-sm)); +} + +.editor-collab-sidebar-panel__picker-group + .editor-collab-sidebar-panel__picker-group { + margin-block-start: var(--emoji-picker-padding); +} + +// The Design System group label (small, uppercase, muted), pinned while +// its category scrolls beneath it. +.editor-collab-sidebar-panel__picker-category { + position: sticky; + top: 0; + // Rows scroll behind it, so match the `Autocomplete.Popup` surface. + background-color: var(--wpds-color-background-surface-neutral-strong); + z-index: 1; + // Align the heading with the grid's first column. + padding-inline: 0; +} + +.editor-collab-sidebar-panel__picker-row { + display: grid; + grid-template-columns: repeat(6, var(--emoji-picker-cell-size)); + gap: var(--emoji-picker-gap); + // Skip offscreen rows so the ~330-row list mounts quickly; the + // intrinsic-size hint matches the real row height to keep scroll + // geometry accurate. + content-visibility: auto; + contain-intrinsic-size: auto var(--emoji-picker-cell-size); +} + +// The row's grid track sets the cell width; the Design System item +// supplies the flex centering, radius, cursor, and the highlight that +// follows the arrow keys and pointer. +.editor-collab-sidebar-panel__picker-emoji { + justify-content: center; + height: var(--emoji-picker-cell-size); + padding: 0; + + // The brand outline marks the user's own reactions, as on the pills. + &.is-reacted { + box-shadow: inset 0 0 0 var(--wpds-border-width-xs) var(--wpds-color-stroke-interactive-brand); + } +} + +.editor-collab-sidebar-panel__picker-emoji-label { + font-size: 24px; +} + +// Forced colors repaint text in a system color, which turns color emoji into +// flat monochrome glyphs and drops skin tones. Opt the picker glyphs out, but +// not the buttons around them, so their borders still follow the system +// colors; skin tones can't be told apart by shape. Reaction pills keep the +// user's preference. +.editor-collab-sidebar-panel__picker-emoji-label, +.editor-collab-sidebar-panel__skin-tone-toggle-emoji, +.editor-collab-sidebar-panel__skin-tone-swatch { + forced-color-adjust: none; +} + // Mention chips, both in the note input and the rendered note content. A // mention is a non-interactive `span` carrying the mentioned user's ID in a // `user-N` class, styled as a chip. diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index 3541ae94064f76..49fd04fac6a7db 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -86,6 +86,7 @@ const BLOCK_EDITOR_SETTINGS = [ 'isRTL', 'locale', 'maxWidth', + 'noteEmojibaseUrl', 'postContentAttributes', 'postsPerPage', 'readOnly', diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 6594782ba26cd9..2abe7cf2472e36 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -8,6 +8,7 @@ ### New Features +- `Autocomplete`: Add a `Trigger` subcomponent that opens the popup from a button, for pickers that put the input inside the popup ([#83538](https://github.com/WordPress/gutenberg/pull/83538)). - Add `Avatar.Root`, `Avatar.Image`, and `Avatar.Fallback` for profile images with customizable fallbacks, circle and square shapes, a neutral border, five sizes from 16px to 64px, and a reduced-motion-aware image fade. ([#84145](https://github.com/WordPress/gutenberg/pull/84145)) - `Breadcrumb`: Add `ButtonItem` ancestors for hierarchy selection, current-only trails, and customizable `CurrentItem` `aria-current` ([#84156](https://github.com/WordPress/gutenberg/pull/84156)). diff --git a/packages/ui/src/form/primitives/autocomplete/index.ts b/packages/ui/src/form/primitives/autocomplete/index.ts index 30cf49e502e066..163d9b7122cd73 100644 --- a/packages/ui/src/form/primitives/autocomplete/index.ts +++ b/packages/ui/src/form/primitives/autocomplete/index.ts @@ -17,5 +17,6 @@ export { Root } from './root'; export { Row } from './row'; export { Separator } from './separator'; export { Status } from './status'; +export { Trigger } from './trigger'; export { useFilteredItems } from './use-filtered-items'; export { Value } from './value'; diff --git a/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx b/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx index 9e7e9ef79f2a6a..1dbadfa67d06e8 100644 --- a/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx @@ -43,6 +43,7 @@ const meta: Meta< typeof Autocomplete.Root > = { 'Autocomplete.Value': Autocomplete.Value, 'Autocomplete.Empty': Autocomplete.Empty, 'Autocomplete.Status': Autocomplete.Status, + 'Autocomplete.Trigger': Autocomplete.Trigger, 'Autocomplete.Clear': Autocomplete.Clear, }, argTypes: { diff --git a/packages/ui/src/form/primitives/autocomplete/trigger.tsx b/packages/ui/src/form/primitives/autocomplete/trigger.tsx new file mode 100644 index 00000000000000..d6dd73c3d3671e --- /dev/null +++ b/packages/ui/src/form/primitives/autocomplete/trigger.tsx @@ -0,0 +1,15 @@ +import { Autocomplete as _Autocomplete } from '@base-ui/react/autocomplete'; +import { forwardRef } from '@wordpress/element'; +import type { AutocompleteTriggerProps } from './types'; + +/** + * A button that opens the popup. Use it when `Autocomplete.Input` lives + * inside `Autocomplete.Popup`, such as a picker opened from a toolbar + * button. The button is unstyled, so pass a `render` element for its look. + */ +export const Trigger = forwardRef< + HTMLButtonElement, + AutocompleteTriggerProps +>( function UnforwardedTrigger( props, ref ) { + return <_Autocomplete.Trigger ref={ ref } { ...props } />; +} ); diff --git a/packages/ui/src/form/primitives/autocomplete/types.ts b/packages/ui/src/form/primitives/autocomplete/types.ts index 15b08b1a6eaebc..bb1d3f86733cf4 100644 --- a/packages/ui/src/form/primitives/autocomplete/types.ts +++ b/packages/ui/src/form/primitives/autocomplete/types.ts @@ -132,4 +132,8 @@ export type AutocompleteStatusProps = ComponentProps< children?: React.ReactNode; }; +export type AutocompleteTriggerProps = ComponentProps< + typeof _Autocomplete.Trigger +>; + export type AutocompleteValueProps = _Autocomplete.Value.Props; diff --git a/phpunit/emoji-picker-data-test.php b/phpunit/emoji-picker-data-test.php new file mode 100644 index 00000000000000..89e699a2e8a8f2 --- /dev/null +++ b/phpunit/emoji-picker-data-test.php @@ -0,0 +1,34 @@ + true ) ); + + $this->assertTrue( $settings['existing'] ); + if ( is_dir( gutenberg_dir_path() . 'build/emojibase-data' ) ) { + $this->assertStringEndsWith( + 'build/emojibase-data', + $settings['noteEmojibaseUrl'] + ); + } else { + // No copied data, so the picker must not be pointed at a 404. + $this->assertArrayNotHasKey( 'noteEmojibaseUrl', $settings ); + } + } +} 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 a51da00daf540c..5a3b8ba22f11f6 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,1025 @@ 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', + // Open, like the editor creates it: an approved note is resolved. + 'status' => 'hold', + ); + $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' => '2764', + '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( '2764', $new_comment->comment_content ); + $this->assertSame( (string) $note_id, $new_comment->comment_parent ); + $this->assertSame( '1', $new_comment->comment_approved ); + } + + /** + * The comments API accepts `content` as a string or as `{ raw }`, so a + * reaction submitted either way must validate and store the same key. + * + * @dataProvider data_reaction_raw_content_slugs + * + * @param string $slug The reaction slug to submit as `content.raw`. + */ + public function test_create_reaction_with_raw_content( $slug ) { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create(); + $note_id = $this->create_note( $post_id, self::$editor_id ); + wp_set_current_user( 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( + array( + 'post' => $post_id, + 'type' => 'reaction', + 'parent' => $note_id, + 'content' => array( 'raw' => $slug ), + 'author' => self::$editor_id, + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status() ); + $reaction = get_comment( $response->get_data()['id'] ); + $this->assertSame( $slug, $reaction->comment_content ); + } + + public function data_reaction_raw_content_slugs() { + return array( + 'curated emoji' => array( '2764' ), + 'other emoji' => array( '1f44d' ), + ); + } + + /** + * Reactions are an internal comment type and must not be world-readable, + * even when approved on a public post. Only the reacting user or a user who + * can edit the comment may read a reaction via GET /wp/v2/comments/{id}. + */ + public function test_reaction_not_publicly_readable() { + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_author' => self::$editor_id, + ) + ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + $reaction_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_approved' => 1, + 'comment_parent' => $note_id, + 'comment_content' => '2764', + 'user_id' => self::$editor_id, + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $reaction_id ); + + // Logged-out users cannot read the reaction. + wp_set_current_user( 0 ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401 ); + + // A subscriber without edit access cannot read the reaction. + wp_set_current_user( self::$subscriber_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); + + // The reacting user can read their own reaction. + wp_set_current_user( self::$editor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( $reaction_id, $response->get_data()['id'] ); + + // An administrator who can edit the comment can read the reaction. + wp_set_current_user( self::$admin_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + wp_delete_post( $post_id, true ); + } + + /** + * A reaction whose parent note belongs to a different post is rejected. + */ + public function test_cannot_create_reaction_on_note_from_different_post() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create(); + $other_post_id = self::factory()->post->create(); + $note_id = $this->create_note( $other_post_id, self::$editor_id ); + + // `create_note()` flips the current user; reset for the reaction request. + wp_set_current_user( self::$editor_id ); + + $params = array( + 'post' => $post_id, + 'type' => 'reaction', + 'parent' => $note_id, + 'content' => '2764', + '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 ); + } + + /** + * A reaction created under a hidden note would never be reached by the + * trash cascade, so the note must be live. + * + * @dataProvider data_hidden_note_statuses + * + * @param string $status Status to move the parent note to. + */ + public function test_cannot_create_reaction_on_hidden_note( $status ) { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + wp_set_comment_status( $note_id, $status ); + + wp_set_current_user( 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( + array( + 'post' => $post_id, + 'type' => 'reaction', + 'parent' => $note_id, + 'content' => '2764', + 'author' => self::$editor_id, + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); + } + + /** + * Data provider for test_cannot_create_reaction_on_hidden_note(). + * + * @return array[] + */ + public function data_hidden_note_statuses() { + return array( + 'trash' => array( 'trash' ), + 'spam' => array( 'spam' ), + ); + } + + /** + * Creating a note requires edit access to the target post. + */ + public function test_cannot_create_note_without_edit_post_capability() { + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + + wp_set_current_user( self::$subscriber_id ); + + $params = array( + 'post' => $post_id, + 'content' => 'Subscriber note attempt', + '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->assertErrorResponse( 'rest_cannot_create_note', $response, rest_authorization_required_code() ); + } + + /** + * @dataProvider data_invalid_reaction_inputs + * + * @param string $parent_kind One of 'none', 'note', or 'comment' - what the + * reaction's `parent` field references. + * @param string|array $content The reaction storage key to submit, + * as a string or as `{ raw }`. + * @param bool $authenticate Whether to set the current user before posting. + * @param string $error_code Expected WP_Error code on the REST response. + * @param int $status Expected HTTP status code. + */ + public function test_cannot_create_reaction_with_invalid_input( $parent_kind, $content, $authenticate, $error_code, $status ) { + if ( $authenticate ) { + wp_set_current_user( self::$editor_id ); + } else { + wp_set_current_user( 0 ); + } + + $post_id = self::factory()->post->create(); + + $params = array( + 'post' => $post_id, + 'type' => 'reaction', + 'content' => $content, + ); + if ( $authenticate ) { + $params['author'] = self::$editor_id; + } + + if ( 'note' === $parent_kind ) { + $params['parent'] = $this->create_note( $post_id, self::$editor_id ); + } elseif ( 'comment' === $parent_kind ) { + $params['parent'] = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'comment', + ) + ); + } + + // `create_note()` flips the current user; reset for the actual reaction request. + if ( $authenticate ) { + wp_set_current_user( 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( $error_code, $response, $status ); + } + + public function data_invalid_reaction_inputs() { + return array( + 'no parent' => array( 'none', '2764', true, 'rest_comment_invalid_parent', 400 ), + 'parent is a regular comment' => array( 'comment', '2764', true, 'rest_comment_invalid_parent', 400 ), + 'content is not in emoji list' => array( 'note', 'invalid_emoji', true, 'rest_comment_invalid_reaction', 400 ), + // Hex storage keys are accepted (see + // `test_can_create_reaction_with_hex_key()`), but only when every + // segment is an assignable Unicode code point. + 'hex above U+10FFFF' => array( 'note', '110000', true, 'rest_comment_invalid_reaction', 400 ), + 'hex in surrogate range' => array( 'note', 'd800', true, 'rest_comment_invalid_reaction', 400 ), + 'hex with a bad segment' => array( 'note', '1f468-200d-dfff', true, 'rest_comment_invalid_reaction', 400 ), + 'uppercase hex key' => array( 'note', '1F44D', true, 'rest_comment_invalid_reaction', 400 ), + 'unpadded hex key' => array( 'note', 'a9', true, 'rest_comment_invalid_reaction', 400 ), + 'emoji name instead of hex' => array( 'note', 'heart', true, 'rest_comment_invalid_reaction', 400 ), + 'anonymous user' => array( 'none', '2764', false, 'rest_comment_login_required', 401 ), + 'raw content not a key' => array( 'note', array( 'raw' => 'invalid_emoji' ), true, 'rest_comment_invalid_reaction', 400 ), + 'content without raw' => array( 'note', array( 'rendered' => '2764' ), true, 'rest_comment_invalid_reaction', 400 ), + ); + } + + /** + * Every reaction is submitted as a lowercase hex code-point sequence, + * which the reaction pill decodes back into the emoji. Those keys must + * round-trip through create. + * + * @dataProvider data_valid_reaction_hex_keys + * + * @param string $slug The hex storage key to submit. + */ + public function test_can_create_reaction_with_hex_key( $slug ) { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create(); + $note_id = $this->create_note( $post_id, self::$editor_id ); + wp_set_current_user( 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( + array( + 'post' => $post_id, + 'type' => 'reaction', + 'parent' => $note_id, + 'content' => $slug, + 'author' => self::$editor_id, + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status() ); + $reaction = get_comment( $response->get_data()['id'] ); + $this->assertSame( $slug, $reaction->comment_content ); + } + + public function data_valid_reaction_hex_keys() { + return array( + 'single code point' => array( '1f44d' ), + 'ZWJ sequence' => array( '1f468-200d-1f4bb' ), + 'padded key' => array( '00a9' ), + ); + } + + 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' => '1f680', + '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 ); + } + + /** + * The pre-insert uniqueness check is not atomic. Simulate a concurrent + * request winning the race - inserting the same reaction after this + * request's check but before its own insert - and assert the post-insert + * cleanup converges on a single surviving row. + */ + public function test_concurrent_duplicate_reaction_converges_to_single_row() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create(); + $note_id = $this->create_note( $post_id, self::$editor_id ); + + // Insert a competing reaction while the request is mid-flight (after + // its pre-insert check, before its own insert). + $injected = false; + $inject = function ( $prepared ) use ( $note_id, $post_id, &$injected ) { + if ( ! $injected && isset( $prepared['comment_type'] ) && 'reaction' === $prepared['comment_type'] ) { + $injected = true; + wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'reaction', + 'comment_content' => '2764', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + ) + ); + } + return $prepared; + }; + add_filter( 'rest_pre_insert_comment', $inject ); + + try { + $params = array( + 'post' => $post_id, + 'type' => 'reaction', + 'parent' => $note_id, + 'content' => '2764', + '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() ); + } finally { + remove_filter( 'rest_pre_insert_comment', $inject ); + } + + // Exactly one approved heart reaction should remain for this user/note. + $remaining = get_comments( + array( + 'parent' => $note_id, + 'user_id' => self::$editor_id, + 'type' => 'reaction', + 'status' => 'approve', + ) + ); + $hearts = array_values( + array_filter( + $remaining, + function ( $comment ) { + return '2764' === wp_strip_all_tags( $comment->comment_content ); + } + ) + ); + $this->assertCount( 1, $hearts, 'Concurrent duplicate reactions should converge to a single row.' ); + + // The response must reference the surviving (earliest) row. + $this->assertSame( (int) $hearts[0]->comment_ID, $response->get_data()['id'] ); + } + + /** + * @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() { + // The five quick reactions: heart, celebration, smile, eyes, rocket. + return array( + 'heart' => array( '2764' ), + 'celebration' => array( '1f389' ), + 'smile' => array( '1f604' ), + 'eyes' => array( '1f440' ), + 'rocket' => array( '1f680' ), + ); + } + + public function test_note_response_includes_reaction_summary() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create(); + $note_id = $this->create_note( $post_id, self::$editor_id ); + + // Add a reaction from the current user. + $params = array( + 'post' => $post_id, + 'type' => 'reaction', + 'parent' => $note_id, + 'content' => '2764', + '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 ); + $reaction_id = $response->get_data()['id']; + + // Fetch the note and verify reaction_summary is included. + $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'reaction_summary', $data ); + $this->assertArrayHasKey( '2764', $data['reaction_summary'] ); + $this->assertSame( 1, $data['reaction_summary']['2764']['count'] ); + $this->assertSame( $reaction_id, $data['reaction_summary']['2764']['current_user_reaction'] ); + $this->assertArrayNotHasKey( 'reacted', $data['reaction_summary']['2764'] ); + $this->assertArrayNotHasKey( 'my_reaction_id', $data['reaction_summary']['2764'] ); + } + + public function test_reaction_summary_shows_not_reacted_for_other_user() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create(); + $note_id = $this->create_note( $post_id, self::$editor_id ); + + // Add a reaction from the editor. + $params = array( + 'post' => $post_id, + 'type' => 'reaction', + 'parent' => $note_id, + 'content' => '2764', + '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 ) ); + rest_get_server()->dispatch( $request ); + + // Switch to admin user and fetch the note. + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $note_id ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'reaction_summary', $data ); + $this->assertSame( 1, $data['reaction_summary']['2764']['count'] ); + $this->assertSame( 0, $data['reaction_summary']['2764']['current_user_reaction'] ); + } + + /** + * The batch prefetch must cover the notes on the requested page, not the + * first page, or every note falls back to its own summary queries. + */ + public function test_second_page_of_notes_keeps_reaction_summary_queries_bounded() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create(); + $note_ids = array(); + for ( $i = 0; $i < 4; $i++ ) { + $note_ids[] = $this->create_note( $post_id, self::$editor_id ); + } + foreach ( $note_ids as $note_id ) { + $this->create_reaction( $post_id, $note_id, self::$editor_id ); + } + + $summary_queries = 0; + $count_queries = static function ( $query ) use ( &$summary_queries ) { + if ( str_contains( $query, "comment_type = 'reaction'" ) ) { + ++$summary_queries; + } + return $query; + }; + add_filter( 'query', $count_queries ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + $request->set_param( 'post', $post_id ); + $request->set_param( 'type', 'note' ); + $request->set_param( 'status', 'all' ); + $request->set_param( 'context', 'edit' ); + $request->set_param( 'per_page', 2 ); + $request->set_param( 'page', 2 ); + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'query', $count_queries ); + + $data = $response->get_data(); + $this->assertCount( 2, $data ); + foreach ( $data as $note ) { + $this->assertSame( 1, $note['reaction_summary']['2764']['count'] ); + $this->assertGreaterThan( 0, $note['reaction_summary']['2764']['current_user_reaction'] ); + } + // One counts query and one current-user query for the whole page. + $this->assertSame( 2, $summary_queries ); + } + + public function test_notes_and_reactions_excluded_from_comment_count() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create(); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => 1, + ) + ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'reaction', + 'comment_parent' => $note_id, + 'comment_content' => '2764', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + ) + ); + + // Recount from the database so the assertion exercises the + // filtered `wp_update_comment_count_now()` query directly rather + // than the incremental adjustments comment creation applied. + wp_update_comment_count_now( $post_id ); + clean_post_cache( $post_id ); + + $this->assertSame( + '1', + get_post( $post_id )->comment_count, + 'Only the regular comment should be counted; notes and reactions must be excluded.' + ); + } + + /** + * Creates an approved reaction row directly, bypassing REST validation. + * + * @param int $post_id Post the parent note belongs to. + * @param int $note_id Parent note comment ID. + * @param int $user_id Reacting user ID. + * @param string $slug Reaction storage slug. + * @return int Reaction comment ID. + */ + protected function create_reaction( $post_id, $note_id, $user_id, $slug = '2764' ) { + return wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'reaction', + 'comment_content' => $slug, + 'comment_approved' => 1, + 'user_id' => $user_id, + ) + ); + } + + /** + * Reactions are validated as a set on create and the generic update route + * re-validates none of it, so updating one is not allowed at all. + */ + public function test_cannot_update_reaction() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + $reaction_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $reaction_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'content' => '1f680' ) ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_update_not_allowed', $response, 403 ); + $this->assertSame( '2764', get_comment( $reaction_id )->comment_content ); + } + + /** + * Only the user who added a reaction can remove it, even though other + * editors of the post can edit the note it belongs to. + */ + public function test_cannot_delete_another_users_reaction() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + $reaction_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/comments/' . $reaction_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_delete', $response, 403 ); + $this->assertNotNull( get_comment( $reaction_id ), 'Another user removed the reaction.' ); + } + + /** + * The reaction's author can still remove it. + */ + public function test_can_delete_own_reaction() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$admin_id ); + $reaction_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/comments/' . $reaction_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNull( get_comment( $reaction_id ) ); + } + + /** + * Resolving approves the thread's root note and the editor disables + * reactions from then on, so the server rejects them too, on the root + * note and on its replies. + * + * @dataProvider data_resolved_thread_targets + * + * @param bool $on_reply Whether to react to a reply rather than the root note. + */ + public function test_cannot_create_reaction_on_resolved_thread( $on_reply ) { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + $target = $note_id; + if ( $on_reply ) { + $target = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + ) + ); + } + wp_set_comment_status( $note_id, 'approve' ); + + wp_set_current_user( 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( + array( + 'post' => $post_id, + 'type' => 'reaction', + 'parent' => $target, + 'content' => '2764', + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); + } + + /** + * Data provider for test_cannot_create_reaction_on_resolved_thread(). + * + * @return array[] + */ + public function data_resolved_thread_targets() { + return array( + 'root note' => array( false ), + 'reply' => array( true ), + ); + } + + /** + * Notes remain editable - only reactions are locked down. + */ + public function test_can_still_update_note() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $note_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'content' => 'Edited note' ) ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * `wp_delete_comment()` reparents children rather than deleting them, so + * without a cascade the reactions would outlive their note as orphan rows. + */ + public function test_permanently_deleting_note_deletes_its_reactions() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + $reaction_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); + $other_id = $this->create_reaction( $post_id, $note_id, self::$author_id, '1f680' ); + + wp_delete_comment( $note_id, true ); + + $this->assertNull( get_comment( $reaction_id ), 'Reaction outlived its note.' ); + $this->assertNull( get_comment( $other_id ), 'Reaction outlived its note.' ); + $this->assertEmpty( + get_comments( + array( + 'post_id' => $post_id, + 'type' => 'reaction', + 'status' => 'all', + ) + ), + 'Reaction rows remained after permanent cleanup.' + ); + } + + /** + * Deleting a reply must take that reply's own reactions with it. + */ + public function test_permanently_deleting_note_reply_deletes_its_reactions() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + $reply_id = wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'note', + 'comment_content' => 'A reply', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + ) + ); + + $note_reaction_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); + $reply_reaction_id = $this->create_reaction( $post_id, $reply_id, self::$editor_id ); + + wp_delete_comment( $reply_id, true ); + + $this->assertNull( get_comment( $reply_reaction_id ), 'Reply reaction outlived its reply.' ); + $this->assertNotNull( get_comment( $note_reaction_id ), 'Root note reaction should be untouched.' ); + } + + /** + * Core cascades a trashed note to its `note` children only, so + * reactions need their own trash cascade. + */ + public function test_trashing_note_trashes_its_reactions() { + if ( ! EMPTY_TRASH_DAYS ) { + $this->markTestSkipped( 'Trash is disabled; trashing force-deletes.' ); + } + + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + $reaction_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); + + wp_trash_comment( $note_id ); + $this->assertSame( 'trash', wp_get_comment_status( $reaction_id ), 'Reaction stayed approved under a trashed note.' ); + } + + /** + * Trashing a note trashes its reactions, so permanently deleting it + * afterwards must still find and delete them. + */ + public function test_permanently_deleting_trashed_note_deletes_its_reactions() { + if ( ! EMPTY_TRASH_DAYS ) { + $this->markTestSkipped( 'Trash is disabled; trashing force-deletes.' ); + } + + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = $this->create_note( $post_id, self::$editor_id ); + $reaction_id = $this->create_reaction( $post_id, $note_id, self::$editor_id ); + $removed_id = $this->create_reaction( $post_id, $note_id, self::$author_id, '1f680' ); + wp_trash_comment( $removed_id ); + + wp_trash_comment( $note_id ); + wp_delete_comment( $note_id, true ); + + $this->assertNull( get_comment( $reaction_id ), 'A reaction trashed with its note outlived it.' ); + $this->assertNull( get_comment( $removed_id ), 'A previously removed reaction outlived its note.' ); + } + + /** + * A reaction may only be added on the current user's own behalf, even by + * a moderator. + */ + public function test_cannot_create_reaction_for_another_user() { + wp_set_current_user( self::$admin_id ); + $post_id = self::factory()->post->create(); + $note_id = $this->create_note( $post_id, self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '2764', + 'type' => 'reaction', + 'author' => self::$editor_id, + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_comment_invalid_author', $response, 403 ); + $this->assertEmpty( + get_comments( + array( + 'parent' => $note_id, + 'type' => 'reaction', + 'status' => 'all', + ) + ), + 'No reaction should have been stored.' + ); + } + + /** + * Author fields in the request must not detach a reaction from its user, + * which would hide it from the uniqueness check and the summary. + */ + public function test_create_reaction_ignores_request_author_fields() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create(); + $note_id = $this->create_note( $post_id, self::$editor_id ); + + $body = wp_json_encode( + array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '2764', + 'type' => 'reaction', + 'author_name' => 'Someone Else', + 'author_email' => 'someone@example.com', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( $body ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status() ); + $reaction = get_comment( $response->get_data()['id'] ); + $this->assertSame( (string) self::$editor_id, $reaction->user_id, 'The reaction should belong to the current user.' ); + $this->assertNotSame( 'someone@example.com', $reaction->comment_author_email ); + + $duplicate = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $duplicate->add_header( 'Content-Type', 'application/json' ); + $duplicate->set_body( $body ); + + $this->assertErrorResponse( 'rest_comment_duplicate_reaction', rest_get_server()->dispatch( $duplicate ), 409 ); + } + + /** + * The cleanup in create_item() must repoint to the surviving row even when + * a competing request has already deleted this request's own row - the + * losing side of the same race the sibling test covers from the winner. + */ + public function test_concurrent_cleanup_deleting_own_row_still_returns_survivor() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create(); + $note_id = $this->create_note( $post_id, self::$editor_id ); + + $survivor_id = 0; + $deleted = false; + + // Stand in for the competing request's cleanup: it keeps the earliest + // row and deletes this request's later one, which lands first. + $race = function ( $comment_id, $comment ) use ( &$survivor_id, &$deleted ) { + if ( ! $deleted && 'reaction' === $comment->comment_type && (int) $comment_id !== $survivor_id ) { + $deleted = true; + wp_delete_comment( $comment_id, true ); + } + }; + + // Insert the competing row after this request's pre-insert uniqueness + // check has passed, so it takes the earlier ID. Arm the cleanup only + // once that row exists, so its own insert does not trigger it. + $inject = function ( $prepared ) use ( $note_id, $post_id, $race, &$survivor_id ) { + if ( ! $survivor_id && isset( $prepared['comment_type'] ) && 'reaction' === $prepared['comment_type'] ) { + $survivor_id = wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'reaction', + 'comment_content' => '2764', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + ) + ); + add_action( 'wp_insert_comment', $race, 10, 2 ); + } + return $prepared; + }; + add_filter( 'rest_pre_insert_comment', $inject ); + + try { + $params = array( + 'post' => $post_id, + 'type' => 'reaction', + 'parent' => $note_id, + 'content' => '2764', + '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 ); + } finally { + remove_filter( 'rest_pre_insert_comment', $inject ); + remove_action( 'wp_insert_comment', $race, 10 ); + } + + $this->assertTrue( $deleted, 'The race injection did not delete this request\'s row.' ); + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( $survivor_id, $response->get_data()['id'], 'Response did not repoint to the surviving row.' ); + } } diff --git a/phpunit/note-reactions-feed-test.php b/phpunit/note-reactions-feed-test.php new file mode 100644 index 00000000000000..c61cdd2f259f68 --- /dev/null +++ b/phpunit/note-reactions-feed-test.php @@ -0,0 +1,75 @@ +post->create( array( 'post_status' => 'publish' ) ); + self::$comment_id = $factory->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => '1', + ) + ); + $note_id = $factory->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => '1', + ) + ); + self::$reaction_id = $factory->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_parent' => $note_id, + 'comment_type' => 'reaction', + 'comment_content' => '2764', + 'comment_approved' => '1', + ) + ); + } + + /** + * Returns the comment IDs in the current feed query. + * + * @return int[] + */ + private function get_feed_comment_ids() { + global $wp_query; + return array_map( 'intval', wp_list_pluck( $wp_query->comments, 'comment_ID' ) ); + } + + public function test_site_comment_feed_excludes_reactions() { + $this->go_to( '/?feed=comments-rss2' ); + + $ids = $this->get_feed_comment_ids(); + $this->assertContains( self::$comment_id, $ids ); + $this->assertNotContains( self::$reaction_id, $ids ); + } + + public function test_post_comment_feed_excludes_reactions() { + $this->go_to( '/?feed=rss2&p=' . self::$post_id ); + + $ids = $this->get_feed_comment_ids(); + $this->assertContains( self::$comment_id, $ids ); + $this->assertNotContains( self::$reaction_id, $ids ); + } +} diff --git a/storybook/components-manifest.yml b/storybook/components-manifest.yml index 581923a869a84d..5bc3671576c88b 100644 --- a/storybook/components-manifest.yml +++ b/storybook/components-manifest.yml @@ -168,6 +168,13 @@ - className - render - style + - name: Autocomplete.Trigger + props: + - className + - disabled + - nativeButton + - render + - style - name: Autocomplete.Value props: - children diff --git a/test/e2e/specs/editor/various/block-notes-utils.js b/test/e2e/specs/editor/various/block-notes-utils.js index c39f45e0e19de9..735af1828882bf 100644 --- a/test/e2e/specs/editor/various/block-notes-utils.js +++ b/test/e2e/specs/editor/various/block-notes-utils.js @@ -191,6 +191,62 @@ class BlockNoteUtils { await this.#page.getByRole( 'menuitem', { name: actionName } ).click(); } + async addReactionToComment( emoji ) { + await this.#page + .getByRole( 'combobox', { name: 'Add reaction' } ) + .click(); + await this.waitForFullPicker(); + + // Curated reactions carry their own exact labels (e.g. "heart") + // and are seeded into the "Frequently used" section, so an + // exact-name gridcell lookup finds them + // without matching Emojibase labels that merely contain the name + // (e.g. "smiling face with hearts"). + await this.#page + .getByRole( 'gridcell', { name: emoji, exact: true } ) + .first() + .click(); + } + + /** + * Wait for the emoji picker popup to open and the Emojibase data to + * fill its grid with at least one cell. + */ + async waitForFullPicker() { + await expect( + this.#page.getByPlaceholder( 'Search emoji' ) + ).toBeVisible(); + await expect( + this.#page.getByRole( 'grid' ).getByRole( 'gridcell' ).first() + ).toBeVisible(); + } + + /** + * Open the emoji picker, search by name, and click the first + * matching emoji. + * + * @param {string} search Search term (matched against Emojibase + * labels, e.g. "red heart" or "thumbs up"). + */ + async pickFullPickerEmojiBySearch( search ) { + await this.#page + .getByRole( 'combobox', { name: 'Add reaction' } ) + .click(); + await this.waitForFullPicker(); + + await this.#page.getByPlaceholder( 'Search emoji' ).fill( search ); + + // Wait for the search to actually filter. Each gridcell exposes + // the emoji label as its accessible name, so once the first cell + // carries a name matching `search` we know the grid has finished + // re-laying-out. + const match = this.#page + .getByRole( 'gridcell', { name: new RegExp( search, 'i' ) } ) + .first(); + await expect( match ).toBeVisible(); + await match.click(); + } + /** * Deletes a note through its actions menu and waits for the delete to * finish. diff --git a/test/e2e/specs/editor/various/block-notes.spec.js b/test/e2e/specs/editor/various/block-notes.spec.js index 1dd39b83a64e91..b1e13a14e1fc19 100644 --- a/test/e2e/specs/editor/various/block-notes.spec.js +++ b/test/e2e/specs/editor/various/block-notes.spec.js @@ -1131,6 +1131,638 @@ test.describe( 'Block Notes', () => { } ); } ); + test.describe( 'Emoji Reactions', () => { + test( 'can add an emoji reaction to a note', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing emoji reactions' }, + comment: 'Test comment for reactions', + } ); + + await blockNoteUtils.addReactionToComment( 'heart' ); + + // Verify the reaction button appears with count. + const reactionButton = page.getByRole( 'button', { + name: /heart/, + } ); + await expect( reactionButton ).toBeVisible(); + await expect( reactionButton ).toContainText( '1' ); + } ); + + test( 'can re-add the same reaction after removing it', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing re-add reaction' }, + comment: 'Re-add reaction', + } ); + + await blockNoteUtils.addReactionToComment( 'heart' ); + const reactionButton = page.getByRole( 'button', { + name: /heart/, + } ); + await expect( reactionButton ).toBeVisible(); + await expect( reactionButton ).toContainText( '1' ); + + // Remove the reaction. + await reactionButton.click(); + await expect( reactionButton ).toBeHidden(); + + // Add the same reaction again. This used to fail two ways: + // 1) the parent note's cached `reaction_summary` still + // carried the removed heart's `current_user_reaction`, so the toggle + // attempted to delete a now-missing comment record + // instead of routing to add; and 2) the server's + // duplicate-reaction guard included trashed comments, + // so the just-removed reaction blocked the re-add with + // `rest_comment_duplicate_reaction` ("You have already + // reacted with this emoji"). + await blockNoteUtils.addReactionToComment( 'heart' ); + await expect( reactionButton ).toBeVisible(); + await expect( reactionButton ).toContainText( 'ā¤' ); + await expect( reactionButton ).toContainText( '1' ); + + // The duplicate-reaction error must never appear - pins both + // fixes (client refetch + server status='approve' query) + // against regression. + await expect( + page.locator( '.components-snackbar__content', { + hasText: /already reacted/i, + } ) + ).toHaveCount( 0 ); + } ); + + test( 'editing a note after toggling a reaction keeps its text', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing edit after reaction' }, + comment: 'Original note text', + } ); + + // Toggling invalidates the notes list, whose edit-context refetch + // can land after the single-note refetch and mask the bug. Hold the + // single-note refetch back so it is the last write to the cache. + const singleNote = /\/wp\/v2\/comments\/\d+([?&]|$)/; + await page.route( + ( url ) => singleNote.test( decodeURIComponent( url.href ) ), + async ( route ) => { + if ( route.request().method() !== 'GET' ) { + return route.fallback(); + } + const response = await route.fetch(); + await new Promise( ( resolve ) => + setTimeout( resolve, 1000 ) + ); + await route.fulfill( { response } ); + } + ); + const refetch = page.waitForResponse( + ( response ) => + response.request().method() === 'GET' && + singleNote.test( decodeURIComponent( response.url() ) ) + ); + await blockNoteUtils.addReactionToComment( 'heart' ); + await refetch; + await expect( + page.getByRole( 'button', { name: /heart/ } ) + ).toContainText( '1' ); + + // The post-toggle refetch must not replace the cached note's + // edit-context `content.raw`, which seeds the edit form. + await blockNoteUtils.clickBlockNoteActionMenuItem( 'Edit' ); + await expect( + page.getByRole( 'textbox', { name: 'Edit note' } ) + ).toHaveText( 'Original note text' ); + } ); + + test( 'can see reaction tooltip on hover', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing reaction tooltip' }, + comment: 'Test comment for reaction tooltip', + } ); + + // Add a reaction. + await blockNoteUtils.addReactionToComment( 'celebration' ); + + // Hover over the reaction button to trigger tooltip. + const reactionButton = page.getByRole( 'button', { + name: /celebration/, + } ); + await expect( reactionButton ).toBeVisible(); + await reactionButton.hover(); + + // The Design System tooltip popup carries no `tooltip` role, so + // match its text. The pill's own label is an `aria-label`, not + // text, so this only matches the popup. + await expect( + page.getByText( /reacted with celebration/ ) + ).toBeVisible(); + } ); + + test( 'the emoji picker is keyboard accessible', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing keyboard accessibility' }, + comment: 'Test comment for keyboard access', + } ); + + // Open the emoji picker with keyboard. + const addReactionTrigger = page.getByRole( 'combobox', { + name: 'Add reaction', + } ); + await addReactionTrigger.focus(); + await page.keyboard.press( 'Enter' ); + + await blockNoteUtils.waitForFullPicker(); + + // Focus stays in the search field while the arrow keys move a + // highlight through the grid, and Enter picks the highlight. + const searchField = page.getByRole( 'combobox', { + name: 'Search emoji', + } ); + await expect( searchField ).toBeFocused(); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowRight' ); + const secondEmoji = page.getByRole( 'gridcell' ).nth( 1 ); + await expect( searchField ).toHaveAttribute( + 'aria-activedescendant', + await secondEmoji.getAttribute( 'id' ) + ); + await expect( searchField ).toBeFocused(); + await page.keyboard.press( 'Enter' ); + + // The selected emoji renders as a reaction pill on the note. + await expect( + page.locator( '.editor-collab-sidebar-panel__reaction-button' ) + ).toBeVisible(); + } ); + + test( 'Tab from the emoji search skips the grid', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Tabbing through the picker' }, + comment: 'Tab order in the emoji picker', + } ); + + await page + .getByRole( 'combobox', { name: 'Add reaction' } ) + .focus(); + await page.keyboard.press( 'Enter' ); + await blockNoteUtils.waitForFullPicker(); + + const searchField = page.getByRole( 'combobox', { + name: 'Search emoji', + } ); + await expect( searchField ).toBeFocused(); + + // The grid is reached with the arrow keys from the search + // field, so Tab moves on to the skin tone toggle. + await page.keyboard.press( 'Tab' ); + await expect( + page.getByRole( 'button', { name: /^Skin tone:/ } ) + ).toBeFocused(); + + // The scrolling grid is never a Tab stop of its own, which + // would read out every emoji in it. + await page.keyboard.press( 'Tab' ); + await expect + .poll( () => + page.evaluate( + () => + !! document.activeElement?.closest( + '.editor-collab-sidebar-panel__picker-viewport' + ) + ) + ) + .toBe( false ); + // Tabbing past the toggle leaves the non-modal picker, closing it. + await expect( searchField ).toBeHidden(); + } ); + + test( 'the add-reaction trigger is revealed on hover and focus', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing the hover trigger' }, + comment: 'Test comment for the hover trigger', + } ); + + const trigger = page.getByRole( 'combobox', { + name: 'Add reaction', + } ); + // The reply composer shares the note class, so match only the + // note that carries the trigger. + const note = page + .locator( '.editor-collab-sidebar-panel__note' ) + .filter( { has: trigger } ); + + // Park the pointer outside the sidebar: adding the note leaves it + // over the thread, which would hold the trigger open. + await page.mouse.move( 0, 0 ); + await expect( trigger ).toHaveCSS( 'opacity', '0' ); + + await note.hover(); + await expect( trigger ).toHaveCSS( 'opacity', '1' ); + + // Keyboard reaches it too: the reveal hangs off the trigger, not + // the thread, which stays focused for as long as it is selected. + await page.mouse.move( 0, 0 ); + await expect( trigger ).toHaveCSS( 'opacity', '0' ); + await trigger.focus(); + await expect( trigger ).toHaveCSS( 'opacity', '1' ); + } ); + + test( 'reactions stay visible once the thread is deselected', async ( { + page, + editor, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing deselected reactions' }, + comment: 'Test comment for deselected reactions', + } ); + + await blockNoteUtils.addReactionToComment( 'heart' ); + const reactionButton = page.getByRole( 'button', { + name: /heart/, + } ); + await expect( reactionButton ).toBeVisible(); + + // Focus the title to deselect the block and the note. The pills + // carry information about the note, so unlike its actions they + // survive being deselected. + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .focus(); + await expect( + page.getByRole( 'combobox', { name: 'Add reaction' } ) + ).toHaveCount( 0 ); + await expect( reactionButton ).toBeVisible(); + } ); + + test( 'resolving a thread locks its reactions', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing resolved reactions' }, + comment: 'Test comment for resolved reactions', + } ); + + // The floating overlay hides a resolved thread, so drive this + // through the sidebar where it stays reachable. + await blockNoteUtils.openBlockNoteSidebar(); + const sidebar = page.getByRole( 'region', { + name: 'Editor settings', + } ); + const thread = sidebar.getByRole( 'treeitem', { + name: 'Note: Test comment for resolved reactions', + } ); + await thread.click(); + await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); + + await blockNoteUtils.addReactionToComment( 'heart' ); + const reactionPill = sidebar.getByRole( 'button', { + name: /heart/, + } ); + await expect( reactionPill ).toBeVisible(); + + const addReaction = sidebar.getByRole( 'combobox', { + name: 'Add reaction', + } ); + const resolveButton = sidebar.getByRole( 'button', { + name: 'Resolve', + } ); + + // Resolving collapses the thread, so re-select it to reach the + // reaction controls again. + await resolveButton.click(); + await thread.click(); + await expect( resolveButton ).toBeDisabled(); + + // A resolved thread is an archived conversation: the add trigger + // is gone and the existing pill can no longer mutate reactions. + await expect( addReaction ).toHaveCount( 0 ); + await expect( reactionPill ).toBeDisabled(); + + // Reopening the thread unlocks them again. + await blockNoteUtils.clickBlockNoteActionMenuItem( 'Reopen' ); + await expect( resolveButton ).toBeEnabled(); + await expect( addReaction.first() ).toBeEnabled(); + await expect( reactionPill ).toBeEnabled(); + } ); + + test( 'a curated pick and the same emoji from search share one hex key', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Curated hex key' }, + comment: 'Pick heart twice', + } ); + + // The Frequently used seed stores the heart by its hex key. + const created = page.waitForRequest( + ( request ) => + request.method() === 'POST' && + /\/wp\/v2\/comments/.test( + decodeURIComponent( request.url() ) + ) + ); + await blockNoteUtils.addReactionToComment( 'heart' ); + expect( ( await created ).postDataJSON().content ).toBe( '2764' ); + + const reactionButton = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await expect( reactionButton ).toHaveCount( 1 ); + await expect( reactionButton ).toContainText( 'ā¤' ); + await expect( reactionButton ).toContainText( '1' ); + + /* + * Picking the same heart from the search results resolves to + * the same key, so it toggles the existing reaction off rather + * than adding a second pill. "heart" is a curated label, so the + * exact match skips "smiling face with hearts", and the cell is + * marked as the user's own reaction. + */ + await page + .getByRole( 'combobox', { name: 'Add reaction' } ) + .click(); + await blockNoteUtils.waitForFullPicker(); + await page.getByPlaceholder( 'Search emoji' ).fill( 'heart' ); + await page + .getByRole( 'gridcell', { + name: 'heart, your reaction', + exact: true, + } ) + .click(); + await expect( reactionButton ).toHaveCount( 0 ); + } ); + + test( 'a full-picker pick that is not curated renders the chosen emoji', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Non-curated emoji' }, + comment: 'Pick thumbs up from full picker', + } ); + + await blockNoteUtils.pickFullPickerEmojiBySearch( 'thumbs up' ); + + const reactionButton = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await expect( reactionButton ).toHaveCount( 1 ); + await expect( reactionButton ).toContainText( 'šŸ‘' ); + } ); + + test( 'Escape in the skin-tone menu closes only that popup', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Nested overlay dismissal' }, + comment: 'Escape unwinds one layer at a time', + } ); + + await page + .getByRole( 'combobox', { name: 'Add reaction' } ) + .click(); + await blockNoteUtils.waitForFullPicker(); + + // Open the nested skin-tone menu. + const skinToneToggle = page.getByRole( 'button', { + name: /^Skin tone:/, + } ); + await skinToneToggle.click(); + // The notes' Actions menus stay mounted, so pick out this one. + const skinToneMenu = page + .getByRole( 'menu' ) + .filter( { hasText: 'Choose your default skin tone' } ); + await expect( skinToneMenu ).toBeVisible(); + + // The first Escape closes only the menu, returns focus to its + // toggle, and leaves the full picker open. + await page.keyboard.press( 'Escape' ); + await expect( skinToneMenu ).toBeHidden(); + await expect( skinToneToggle ).toBeFocused(); + await expect( + page.getByPlaceholder( 'Search emoji' ) + ).toBeVisible(); + + // Keyboard focus back on the toggle shows its tooltip, the next + // layer an Escape dismisses, again leaving the picker open. + const skinToneTooltip = page.getByText( + 'Skin tone: Default skin tone' + ); + await expect( skinToneTooltip ).toBeVisible(); + await page.keyboard.press( 'Escape' ); + await expect( skinToneTooltip ).toBeHidden(); + await expect( + page.getByPlaceholder( 'Search emoji' ) + ).toBeVisible(); + + // The next Escape closes the full picker and returns focus + // to the add-reaction trigger. + await page.keyboard.press( 'Escape' ); + await expect( + page.getByPlaceholder( 'Search emoji' ) + ).toBeHidden(); + await expect( + page.getByRole( 'combobox', { name: 'Add reaction' } ) + ).toBeFocused(); + } ); + + test( 'full picker shows a Frequently used section that learns from picks', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Frequently used emoji' }, + comment: 'Learn frequent picks', + } ); + + // Clear any usage persisted by earlier tests or runs so the + // seeded state is deterministic. + await page.evaluate( () => + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core', 'emojiPickerFrequentEmojis', [] ) + ); + + await page + .getByRole( 'combobox', { name: 'Add reaction' } ) + .click(); + await blockNoteUtils.waitForFullPicker(); + + // Seeded with the curated set, so it has content before any picks. + const frequentSection = page + .locator( '.editor-collab-sidebar-panel__picker-list > div' ) + .filter( { hasText: 'Frequently used' } ) + .first(); + await expect( + page + .locator( '.editor-collab-sidebar-panel__picker-category' ) + .first() + ).toHaveText( 'Frequently used' ); + await expect( + frequentSection.getByRole( 'gridcell', { + name: 'heart', + exact: true, + } ) + ).toBeVisible(); + // An emoji no other test picks is not in the section yet. + await expect( + frequentSection.getByRole( 'gridcell', { + name: 'avocado', + exact: true, + } ) + ).toBeHidden(); + + // While searching, the section is hidden so it doesn't + // duplicate hits from the category results. + await page.getByPlaceholder( 'Search emoji' ).fill( 'avocado' ); + await expect( page.getByText( 'Frequently used' ) ).toBeHidden(); + + await page + .getByRole( 'gridcell', { name: 'avocado', exact: true } ) + .click(); + + // On reopening, the pick has joined the Frequently used section, + // marked as the user's own reaction. + await page + .getByRole( 'combobox', { name: 'Add reaction' } ) + .click(); + await blockNoteUtils.waitForFullPicker(); + await expect( + frequentSection.getByRole( 'gridcell', { + name: 'avocado, your reaction', + exact: true, + } ) + ).toBeVisible(); + } ); + + test( 'can set a default skin tone that applies to picked emoji', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Skin tone preference' }, + comment: 'Pick a toned thumbs up', + } ); + + await page + .getByRole( 'combobox', { name: 'Add reaction' } ) + .click(); + await blockNoteUtils.waitForFullPicker(); + + // The persistent toggle next to the search field shows the + // current (default) tone. + await page + .getByRole( 'button', { name: 'Skin tone: Default skin tone' } ) + .click(); + + // The menu has an explicit heading and six tones, with the + // default tone checked. + await expect( + page.getByText( 'Choose your default skin tone' ) + ).toBeVisible(); + const tones = page.getByRole( 'menuitemradio' ); + await expect( tones ).toHaveCount( 6 ); + await expect( + page.getByRole( 'menuitemradio', { name: 'Default skin tone' } ) + ).toHaveAttribute( 'aria-checked', 'true' ); + + await page + .getByRole( 'menuitemradio', { + name: 'Dark skin tone', + exact: true, + } ) + .click(); + + // The toggle reflects the new tone and the menu closes. + await expect( + page.getByRole( 'button', { + name: 'Skin tone: Dark skin tone', + } ) + ).toBeVisible(); + await expect( + page.getByText( 'Choose your default skin tone' ) + ).toBeHidden(); + + // Tone-capable emoji in the grid now carry the chosen tone. + await page.getByPlaceholder( 'Search emoji' ).fill( 'thumbs up' ); + await page + .getByRole( 'gridcell', { + name: 'thumbs up: dark skin tone', + exact: true, + } ) + .click(); + + // The stored reaction renders the toned emoji. + const reactionButton = page.locator( + '.editor-collab-sidebar-panel__reaction-button' + ); + await expect( reactionButton ).toHaveCount( 1 ); + await expect( reactionButton ).toContainText( 'šŸ‘šŸæ' ); + } ); + + test( 'note remains selected while reaction picker is open', async ( { + page, + blockNoteUtils, + } ) => { + await blockNoteUtils.addBlockWithNote( { + type: 'core/paragraph', + attributes: { content: 'Testing selection persistence' }, + comment: 'Selection persistence', + } ); + + const thread = page.getByRole( 'treeitem', { + name: /Note: Selection persistence/, + } ); + await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); + + await page + .getByRole( 'combobox', { name: 'Add reaction' } ) + .click(); + await blockNoteUtils.waitForFullPicker(); + + // Focus has moved into the portaled popup, but its focus events + // still bubble to the thread's `useFocusOutside` through the + // React tree, so the thread stays selected and the trigger mounted. + await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); + } ); + } ); + test.describe( 'Multiple notes per block', () => { test( 'can add multiple notes to the same block', async ( { editor, diff --git a/tools/build-scripts/build.mjs b/tools/build-scripts/build.mjs index 7d1f4f9d564e7f..7d0ceddda59bcd 100644 --- a/tools/build-scripts/build.mjs +++ b/tools/build-scripts/build.mjs @@ -80,7 +80,7 @@ async function build() { try { /* - * Step 0: Verify node_modules is in sync with package-lock.json. + * Verify node_modules is in sync with package-lock.json. * * GUTENBERG_CHECK_INSTALLED_DEPS controls when this runs: * - `BEFORE_BUILD` (default): pre-build gate. @@ -154,6 +154,15 @@ async function build() { env: { ...process.env, NODE_ENV: 'production' }, } ); + // Copy emojibase locale data into build/ so the editor + // can fetch it from plugins_url() instead of bundling ~770KB of + // JSON into the main JS bundle. + console.log( '\nšŸ“¦ Copying emojibase data...' ); + await exec( 'node', [ + path.join( __dirname, 'copy-emojibase-data.mjs' ), + ] ); + + // Build blocks manifests console.log( '\nšŸ“¦ Building blocks manifests...' ); const blocksDirs = [ { diff --git a/tools/build-scripts/copy-emojibase-data.mjs b/tools/build-scripts/copy-emojibase-data.mjs new file mode 100644 index 00000000000000..a316538dbc9ab7 --- /dev/null +++ b/tools/build-scripts/copy-emojibase-data.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +/** + * Copy Emojibase locale data into `build/emojibase-data/` so the emoji + * picker fetches labels same-origin rather than inlining them into the + * JS bundle. One locale is fetched per editor session, so the disk cost + * is not a network cost. + * + * Runs from `tools/build-scripts/build.mjs` and `dev.mjs` after wp-build + * populates `build/`. Exits 0 when emojibase-data is missing: the picker + * degrades to offering the site's named reaction set. + */ + +import { mkdir, copyFile } from 'fs/promises'; +import { existsSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +const require = createRequire( import.meta.url ); +const __dirname = path.dirname( fileURLToPath( import.meta.url ) ); +const ROOT_DIR = path.resolve( __dirname, '../..' ); + +/* + * Resolved through node so a hoisted or nested install both work. An + * unresolvable module is the same "not installed" case the copy step + * skips, so it must not throw and take the plugin build down with it. + */ +const SRC_DIR = resolveEmojibaseDir(); + +function resolveEmojibaseDir() { + try { + return path.dirname( require.resolve( 'emojibase-data/package.json' ) ); + } catch { + return null; + } +} + +const DEST_DIR = path.join( ROOT_DIR, 'build', 'emojibase-data' ); + +/* + * Only this one is fetched; the rest of the ~49MB package is skipped. + * Category headings come from Unicode's own group names, translated in + * the picker, so `messages.json` is not copied. + */ +const FILES = [ 'data.json' ]; + +/* + * Every locale Emojibase translates. Must stay in sync with + * `EMOJIBASE_LOCALES` in + * `packages/editor/src/components/collab-sidebar/reactions/emojibase-data.ts`. + * Each locale costs ~780KB on disk (~22MB total), ~95KB gzipped over the + * wire, and only the active one is fetched per editor session. + */ +const LOCALES = [ + 'bn', + 'da', + 'de', + 'en', + 'en-gb', + 'es', + 'es-mx', + 'et', + 'fi', + 'fr', + 'hi', + 'hu', + 'it', + 'ja', + 'ko', + 'lt', + 'ms', + 'nb', + 'nl', + 'pl', + 'pt', + 'ru', + 'sv', + 'th', + 'uk', + 'vi', + 'zh', + 'zh-hant', +]; + +async function copyEmojibaseData() { + if ( ! SRC_DIR || ! existsSync( SRC_DIR ) ) { + console.warn( + 'āš ļø emojibase-data not found', + SRC_DIR ? `at ${ SRC_DIR }` : '', + '- skipping. Run `npm install` to fetch it.' + ); + return; + } + + for ( const locale of LOCALES ) { + const localeDest = path.join( DEST_DIR, locale ); + await mkdir( localeDest, { recursive: true } ); + for ( const file of FILES ) { + const from = path.join( SRC_DIR, locale, file ); + const to = path.join( localeDest, file ); + if ( ! existsSync( from ) ) { + console.warn( `āš ļø emojibase-data missing ${ from }` ); + continue; + } + await copyFile( from, to ); + } + } + console.log( + ` āœ” Copied emojibase data for ${ LOCALES.join( + ', ' + ) } to build/emojibase-data/` + ); +} + +copyEmojibaseData().catch( ( error ) => { + console.error( 'āŒ Failed to copy emojibase data:', error ); + process.exit( 1 ); +} ); diff --git a/tools/build-scripts/dev.mjs b/tools/build-scripts/dev.mjs index 3760c2d0ad3a11..e8a2bcd604e390 100644 --- a/tools/build-scripts/dev.mjs +++ b/tools/build-scripts/dev.mjs @@ -262,6 +262,13 @@ async function dev() { ); } + // Copy emojibase locale data, as the production build does. + await exec( + 'node', + [ path.join( __dirname, 'copy-emojibase-data.mjs' ) ], + { silent: true } + ); + readyMarkerFile.create(); } } ); diff --git a/tools/build-scripts/package.json b/tools/build-scripts/package.json index 3ae338f6586855..228093cd99c6f1 100644 --- a/tools/build-scripts/package.json +++ b/tools/build-scripts/package.json @@ -34,6 +34,7 @@ "babel-plugin-inline-json-import": "^0.3.2", "chalk": "^4.1.1", "cross-spawn": "^7.0.6", + "emojibase-data": "^17.0.0", "esbuild": "^0.27.2", "fast-glob": "^3.2.7", "magic-string": "^0.30.21",