diff --git a/src/wp-includes/block-editor.php b/src/wp-includes/block-editor.php index 24bdd2f6db5db..91725ea0e0f18 100644 --- a/src/wp-includes/block-editor.php +++ b/src/wp-includes/block-editor.php @@ -637,6 +637,9 @@ function get_block_editor_settings( array $custom_settings, $block_editor_contex $editor_settings['canUpdateBlockBindings'] = current_user_can( 'edit_block_binding', $block_editor_context ); + // Lets the Notes reaction menu offer the same emoji the REST API accepts. + $editor_settings['noteReactionEmojis'] = wp_get_note_reaction_emojis(); + /** * Filters the settings to pass to the block editor for all editor type. * diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index b6847266eb162..7e9ccb6489825 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -351,6 +351,158 @@ function get_comment_statuses() { return $status; } +/** + * Normalizes an emoji hex key to the form note reactions are stored under. + * + * Lowercases the code points, pads each to four digits and drops U+FE0F, so + * `2764-FE0F`, `2764` and `2764-fe0f` all become `2764`. + * + * @since 7.3.0 + * + * @param mixed $hex_key The key to normalize. + * @return string The normalized key, or an empty string when it is not a + * sequence of valid Unicode code points. + * + * @phpstan-return lowercase-string + */ +function wp_normalize_note_reaction_key( $hex_key ): string { + if ( ! is_string( $hex_key ) || ! preg_match( '/^[0-9a-f]{1,6}(?:-[0-9a-f]{1,6})*$/i', $hex_key ) ) { + return ''; + } + + $code_points = array(); + foreach ( explode( '-', strtolower( $hex_key ) ) as $code_point ) { + $value = (int) hexdec( $code_point ); + // Surrogates and values past U+10FFFF are not code points. + if ( $value > 0x10FFFF || ( $value >= 0xD800 && $value <= 0xDFFF ) ) { + return ''; + } + if ( 0xFE0F === $value ) { + continue; + } + $code_points[] = str_pad( ltrim( $code_point, '0' ), 4, '0', STR_PAD_LEFT ); + } + + return implode( '-', $code_points ); +} + +/** + * Retrieves the emoji a note can be reacted with. + * + * @since 7.3.0 + * + * @return array[] { + * The emoji, in display order. + * + * @type array ...$0 { + * @type string $hexKey The emoji's hex key, e.g. `2764`. + * @type string $label The emoji's name, lowercase, e.g. `heart`. + * } + * } + * + * @phpstan-return list + */ +function wp_get_note_reaction_emojis(): array { + // Labels are lowercase since they also appear mid-sentence, e.g. in the reaction pill's tooltip. + $default_emojis = array( + array( + 'hexKey' => '1f44d', + 'label' => _x( 'thumbs up', 'emoji reaction' ), + ), + array( + 'hexKey' => '2795', + 'label' => _x( 'plus', 'emoji reaction' ), + ), + array( + 'hexKey' => '2764', + 'label' => _x( 'heart', 'emoji reaction' ), + ), + array( + 'hexKey' => '1f389', + 'label' => _x( 'celebration', 'emoji reaction' ), + ), + array( + 'hexKey' => '1f604', + 'label' => _x( 'smile', 'emoji reaction' ), + ), + array( + 'hexKey' => '1f440', + 'label' => _x( 'eyes', 'emoji reaction' ), + ), + array( + 'hexKey' => '1f680', + 'label' => _x( 'rocket', 'emoji reaction' ), + ), + ); + + /** + * Filters the emoji a note can be reacted with. + * + * Add entries to offer more emoji, or remove them to offer fewer. An + * empty list turns off adding reactions; existing reactions still show + * and can be removed by their authors. + * + * Each emoji is identified by its hex key: its code points in hex, + * joined by `-` (e.g. `1f984` for 🦄, `1f468-200d-1f4bb` for 👨‍💻). + * U+FE0F is dropped and case does not matter. The editor renders each + * emoji as text, so pick emoji the fonts on your users' systems can + * display: newer emoji and flags do not render everywhere. + * + * @since 7.3.0 + * + * @param array[] $emojis { + * The emoji to offer, in display order. + * + * @type array ...$0 { + * @type string $hexKey The emoji's hex key, e.g. `1f984`. + * @type string $label The emoji's name, lowercase, e.g. `unicorn`. + * } + * } + */ + $emojis = apply_filters( 'wp_note_reaction_emojis', $default_emojis ); + + if ( ! is_array( $emojis ) ) { + return $default_emojis; + } + + // Drop malformed entries and duplicate keys, keeping the first label. + $sanitized = array(); + foreach ( $emojis as $emoji ) { + if ( ! is_array( $emoji ) || ! isset( $emoji['hexKey'], $emoji['label'] ) || ! is_string( $emoji['label'] ) ) { + continue; + } + + $hex_key = wp_normalize_note_reaction_key( $emoji['hexKey'] ); + $label = sanitize_text_field( $emoji['label'] ); + if ( '' === $hex_key || '' === $label || isset( $sanitized[ $hex_key ] ) ) { + continue; + } + + $sanitized[ $hex_key ] = array( + 'hexKey' => $hex_key, + 'label' => $label, + ); + } + + return array_values( $sanitized ); +} + +/** + * Retrieves the hex keys of the emoji a note reaction accepts. + * + * Each key is the emoji's lowercase code points, padded to four digits and + * joined by `-`. A reaction stores its key in `comment_content`. + * + * @since 7.3.0 + * + * @return string[] The keys of the emoji from wp_get_note_reaction_emojis(). + * + * @phpstan-return list + */ +function wp_get_note_reaction_keys(): array { + return array_column( wp_get_note_reaction_emojis(), 'hexKey' ); +} + /** * Gets the default comment status for a post type. * diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php index f5739e6b3a3f3..5f22b3ed858cc 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php @@ -35,23 +35,6 @@ class WP_REST_Comments_Controller extends WP_REST_Controller { */ protected $reaction_summaries = null; - /** - * Retrieves the hex keys of the emoji a note reaction accepts. - * - * Each key is the emoji's lowercase code points, padded to four digits, - * matching the client's `emojiToHexKey()`. A reaction stores its key in - * `comment_content`. - * - * @since 7.2.0 - * - * @return string[] Hex keys for thumbs up, plus, heart, celebration, smile, eyes and rocket. - * - * @phpstan-return non-empty-list - */ - private static function get_note_reaction_keys(): array { - return array( '1f44d', '2795', '2764', '1f389', '1f604', '1f440', '1f680' ); - } - /** * Constructor. * @@ -796,11 +779,11 @@ public function create_item( $request ) { } /* - * Validate the reaction content: the hex key of one of the curated - * reaction emoji, as listed by self::get_note_reaction_keys() (e.g. - * `2764` for the heart). Raw emoji bytes are rejected because the - * comments table is not guaranteed to be utf8mb4 across all WordPress - * installs; clients are expected to normalize before submitting. + * Validate the reaction content: the hex key of one of the reaction + * emoji, as listed by wp_get_note_reaction_keys() (e.g. `2764` for + * the heart). Raw emoji bytes are rejected because the comments + * table is not guaranteed to be utf8mb4 across all WordPress installs; + * clients are expected to normalize before submitting. * * Read the content the same two ways prepare_item_for_database() * does, so `content` and `content.raw` are both accepted. @@ -814,7 +797,7 @@ public function create_item( $request ) { $emoji_key = trim( wp_strip_all_tags( $raw_content ) ); - if ( ! in_array( $emoji_key, self::get_note_reaction_keys(), true ) ) { + if ( ! in_array( $emoji_key, wp_get_note_reaction_keys(), true ) ) { return new WP_Error( 'rest_comment_invalid_reaction', __( 'Invalid reaction emoji.' ), diff --git a/tests/phpunit/tests/blocks/editor.php b/tests/phpunit/tests/blocks/editor.php index 5055aab4d999e..d65ef3f78432a 100644 --- a/tests/phpunit/tests/blocks/editor.php +++ b/tests/phpunit/tests/blocks/editor.php @@ -436,6 +436,21 @@ public function test_get_legacy_widget_block_editor_settings() { ); } + /** + * The note reaction menu offers the same emoji the REST API accepts. + * + * @ticket 66276 + * + * @covers ::get_block_editor_settings + */ + public function test_get_block_editor_settings_includes_note_reaction_emojis(): void { + add_filter( 'wp_note_reaction_emojis', '__return_empty_array' ); + + $settings = get_block_editor_settings( array(), new WP_Block_Editor_Context() ); + + $this->assertSame( array(), $settings['noteReactionEmojis'] ); + } + /** * @ticket 52920 */ diff --git a/tests/phpunit/tests/comment/wpGetNoteReactionEmojis.php b/tests/phpunit/tests/comment/wpGetNoteReactionEmojis.php new file mode 100644 index 0000000000000..a90b5bf0c3fdf --- /dev/null +++ b/tests/phpunit/tests/comment/wpGetNoteReactionEmojis.php @@ -0,0 +1,155 @@ +assertSame( + array( '1f44d', '2795', '2764', '1f389', '1f604', '1f440', '1f680' ), + wp_get_note_reaction_keys() + ); + } + + /** + * @ticket 66276 + */ + public function test_filter_can_add_and_remove_emoji(): void { + add_filter( + 'wp_note_reaction_emojis', + static function ( array $emojis ): array { + $emojis[] = array( + 'hexKey' => '1f984', + 'label' => 'unicorn', + ); + return array_slice( $emojis, 1 ); + } + ); + + $this->assertSame( + array( '2795', '2764', '1f389', '1f604', '1f440', '1f680', '1f984' ), + wp_get_note_reaction_keys() + ); + } + + /** + * @ticket 66276 + */ + public function test_filter_can_remove_every_emoji(): void { + add_filter( 'wp_note_reaction_emojis', '__return_empty_array' ); + + $this->assertSame( array(), wp_get_note_reaction_emojis() ); + } + + /** + * @ticket 66276 + */ + public function test_falls_back_to_the_defaults_when_the_filter_returns_no_list(): void { + add_filter( 'wp_note_reaction_emojis', '__return_null' ); + + $this->assertCount( 7, wp_get_note_reaction_emojis() ); + } + + /** + * @ticket 66276 + */ + public function test_drops_malformed_and_duplicate_entries(): void { + add_filter( + 'wp_note_reaction_emojis', + static function () { + return array( + // Normalized: lowercase, padded, U+FE0F dropped. + array( + 'hexKey' => '2764-FE0F', + 'label' => 'heart', + ), + // The same key again. + array( + 'hexKey' => '2764', + 'label' => 'red heart', + ), + array( + 'hexKey' => '1F468-200D-1F4BB', + 'label' => 'technologist', + ), + array( + 'hexKey' => 'not-hex', + 'label' => 'broken', + ), + array( + 'hexKey' => 'd83d', + 'label' => 'surrogate', + ), + array( + 'hexKey' => '110000', + 'label' => 'past U+10FFFF', + ), + array( + 'hexKey' => '1f984', + 'label' => '', + ), + array( 'hexKey' => '1f680' ), + '1f389', + ); + } + ); + + $this->assertSame( + array( + array( + 'hexKey' => '2764', + 'label' => 'heart', + ), + array( + 'hexKey' => '1f468-200d-1f4bb', + 'label' => 'technologist', + ), + ), + wp_get_note_reaction_emojis() + ); + } + + /** + * @ticket 66276 + * + * @dataProvider data_normalize_note_reaction_key + * + * @param mixed $hex_key The key to normalize. + * @param string $expected The normalized key. + */ + public function test_normalize_note_reaction_key( $hex_key, $expected ): void { + $this->assertSame( $expected, wp_normalize_note_reaction_key( $hex_key ) ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_normalize_note_reaction_key(): array { + return array( + 'already normalized' => array( '1f984', '1f984' ), + 'uppercase' => array( '1F984', '1f984' ), + 'short code point' => array( '23', '0023' ), + 'variation selector' => array( '2764-fe0f', '2764' ), + 'ZWJ sequence' => array( '1f468-200d-1f4bb', '1f468-200d-1f4bb' ), + 'surrogate' => array( 'd83d', '' ), + 'past U+10FFFF' => array( '110000', '' ), + 'not hex' => array( 'heart', '' ), + 'trailing separator' => array( '2764-', '' ), + 'not a string' => array( 0x2764, '' ), + 'empty' => array( '', '' ), + ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 898b344842f9a..f88a3c15d394f 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4996,6 +4996,99 @@ public static function data_uncurated_reaction_keys() { ); } + /** + * The `wp_note_reaction_emojis` filter decides which emoji the REST API + * accepts: an added emoji is accepted and a removed one is not. + * + * @ticket 66276 + */ + public function test_create_reaction_respects_reaction_emojis_filter(): void { + add_filter( + 'wp_note_reaction_emojis', + static function ( array $emojis ): array { + // Drop the heart, add a unicorn. + $emojis = array_values( + array_filter( + $emojis, + static function ( $emoji ): bool { + return ! is_array( $emoji ) || '2764' !== ( $emoji['hexKey'] ?? '' ); + } + ) + ); + $emojis[] = array( + 'hexKey' => '1F984', + 'label' => 'unicorn', + ); + return $emojis; + } + ); + + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create(); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + + $params = array( + 'post' => $post_id, + 'parent' => $note_id, + 'content' => '1f984', + 'type' => 'reaction', + 'author' => self::$editor_id, + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( (string) wp_json_encode( $params ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status(), 'An emoji added by the filter should be accepted.' ); + + $params['content'] = '2764'; + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( (string) wp_json_encode( $params ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); + } + + /** + * A reaction whose emoji the site stopped offering can still be removed + * by its author. + * + * @ticket 66276 + */ + public function test_delete_reaction_after_its_emoji_is_filtered_out(): void { + wp_set_current_user( self::$editor_id ); + + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 0, + 'user_id' => self::$editor_id, + 'comment_content' => 'Test note', + ) + ); + $reaction_id = $this->create_reaction_for_update_tests( $post_id, $note_id, self::$editor_id ); + + add_filter( 'wp_note_reaction_emojis', '__return_empty_array' ); + + $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 ) ); + } + /** * The stored reaction content is the validated, canonical key - markup * around the key must not reach the database, or `reaction_summary`