From 3b2512dd7116ee66b5cd614f7d3f1a929e8d2a2d Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 17 Feb 2026 22:37:44 +0700 Subject: [PATCH 1/5] Remove stray add_action for nonexistent metadata registration function The merge from add-notes-emoji-reactions left behind an add_action call for gutenberg_register_block_comment_reaction_metadata, which doesn't exist in this branch. This branch uses custom comment types instead of comment metadata for storing reactions. Co-Authored-By: Claude Opus 4.6 --- lib/compat/wordpress-7.0/block-comments.php | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/compat/wordpress-7.0/block-comments.php b/lib/compat/wordpress-7.0/block-comments.php index 3c75ea478734d5..cac43cac662bf3 100644 --- a/lib/compat/wordpress-7.0/block-comments.php +++ b/lib/compat/wordpress-7.0/block-comments.php @@ -59,4 +59,3 @@ function gutenberg_get_note_reaction_emojis() { */ return apply_filters( 'note_reaction_emojis', $default_emojis ); } -add_action( 'init', 'gutenberg_register_block_comment_reaction_metadata' ); From b9e42844e250a55edc191052efa552fc8396e398 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 18 Feb 2026 08:00:44 +0700 Subject: [PATCH 2/5] Revert filterable emoji list for note reactions Remove the PHP filter (note_reaction_emojis) and editor settings injection for customizing the reaction emoji set. The JS components now use the hardcoded REACTION_EMOJIS list directly again. Co-Authored-By: Claude Opus 4.6 --- lib/block-editor-settings.php | 14 ----- lib/compat/wordpress-7.0/block-comments.php | 55 ------------------- .../collab-sidebar/reaction-display.js | 12 ++-- .../collab-sidebar/reaction-emoji-picker.js | 37 +++---------- 4 files changed, 12 insertions(+), 106 deletions(-) diff --git a/lib/block-editor-settings.php b/lib/block-editor-settings.php index c2d472540c8278..5ebf1ddd34499f 100644 --- a/lib/block-editor-settings.php +++ b/lib/block-editor-settings.php @@ -124,17 +124,3 @@ function gutenberg_get_block_editor_settings( $settings ) { return $settings; } add_filter( 'block_editor_settings_all', 'gutenberg_get_block_editor_settings', 0 ); - -/** - * Injects the filtered note reaction emoji list into block editor settings. - * - * @since 7.0.0 - * - * @param array $settings Existing block editor settings. - * @return array Updated block editor settings. - */ -function gutenberg_add_note_reaction_emojis_setting( $settings ) { - $settings['noteReactionEmojis'] = gutenberg_get_note_reaction_emojis(); - return $settings; -} -add_filter( 'block_editor_settings_all', 'gutenberg_add_note_reaction_emojis_setting' ); diff --git a/lib/compat/wordpress-7.0/block-comments.php b/lib/compat/wordpress-7.0/block-comments.php index cac43cac662bf3..c00baee85a0151 100644 --- a/lib/compat/wordpress-7.0/block-comments.php +++ b/lib/compat/wordpress-7.0/block-comments.php @@ -4,58 +4,3 @@ * * @package gutenberg */ - -/** - * Returns the default emoji list for note reactions, filterable via the - * 'note_reaction_emojis' hook. - * - * Each emoji definition requires: - * - 'emoji' (string) The emoji character. - * - 'label' (string) Human-readable label. - * - 'value' (string) Storage slug used as the database key. - * - * @since 7.0.0 - * - * @return array[] Array of emoji definitions. - */ -function gutenberg_get_note_reaction_emojis() { - $default_emojis = array( - array( - 'emoji' => '❤️', - 'label' => __( 'Heart', 'gutenberg' ), - 'value' => 'heart', - ), - array( - 'emoji' => '🎉', - 'label' => __( 'Celebration', 'gutenberg' ), - 'value' => 'celebration', - ), - array( - 'emoji' => '😄', - 'label' => __( 'Smile', 'gutenberg' ), - 'value' => 'smile', - ), - array( - 'emoji' => '👀', - 'label' => __( 'Eyes', 'gutenberg' ), - 'value' => 'eyes', - ), - array( - 'emoji' => '🚀', - 'label' => __( 'Rocket', 'gutenberg' ), - 'value' => 'rocket', - ), - ); - - /** - * Filters the list of emoji available for note reactions. - * - * Each emoji requires an 'emoji' (character), 'label' (human-readable), - * and 'value' (storage slug). - * - * @since 7.0.0 - * - * @param array[] $emojis Array of emoji definitions. - */ - return apply_filters( 'note_reaction_emojis', $default_emojis ); -} diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index 5d288dddbd79c8..b069ca40935f07 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -18,7 +18,6 @@ import { store as coreStore } from '@wordpress/core-data'; import ReactionEmojiPicker, { getEmojiBySlug, getLabelBySlug, - useReactionEmojis, } from './reaction-emoji-picker'; import { unlock } from '../../lock-unlock'; @@ -90,10 +89,9 @@ function getReactedSlugs( reactions ) { * @param {Object} users Map of user data by ID. * @param {Array} userIds Array of user IDs who reacted. * @param {string} slug The reaction slug. - * @param {Array} emojis The emoji list to look up labels. * @return {string} The tooltip text. */ -function getReactionTooltipText( users, userIds, slug, emojis ) { +function getReactionTooltipText( users, userIds, slug ) { const names = userIds .map( ( id ) => users?.[ id ]?.name ) .filter( Boolean ); @@ -102,7 +100,7 @@ function getReactionTooltipText( users, userIds, slug, emojis ) { return ''; } - const emojiLabel = getLabelBySlug( slug, emojis ); + const emojiLabel = getLabelBySlug( slug ); if ( names.length === 1 ) { return sprintf( @@ -146,7 +144,6 @@ function getReactionTooltipText( users, userIds, slug, emojis ) { * @param {Function} props.onToggleReaction Callback to toggle a reaction. */ export default function ReactionDisplay( { reactions, onToggleReaction } ) { - const emojis = useReactionEmojis(); const currentUserId = useSelect( ( select ) => { const { getCurrentUser } = select( coreStore ); const user = getCurrentUser(); @@ -208,7 +205,7 @@ export default function ReactionDisplay( { reactions, onToggleReaction } ) { slug, currentUserId ); - const emoji = getEmojiBySlug( slug, emojis ); + const emoji = getEmojiBySlug( slug ); const reactionUserIds = ( reactions?.[ slug ] || [] ).map( ( r ) => r.userId @@ -216,8 +213,7 @@ export default function ReactionDisplay( { reactions, onToggleReaction } ) { const tooltipText = getReactionTooltipText( users, reactionUserIds, - slug, - emojis + slug ); // Use tooltip text when user data is loaded, otherwise fall back to basic label. diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index b92282336aec6a..0fc23a184bfa47 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -3,11 +3,9 @@ */ import { __ } from '@wordpress/i18n'; import { Button, Composite } from '@wordpress/components'; -import { useSelect } from '@wordpress/data'; -import { store as blockEditorStore } from '@wordpress/block-editor'; /** - * Default emoji set for reactions (JS-side fallback). + * Curated emoji set for reactions. * The `value` slug is used as the storage key in the database to avoid * potential encoding issues with emoji characters. */ @@ -19,41 +17,24 @@ export const REACTION_EMOJIS = [ { emoji: '🚀', label: __( 'Rocket' ), value: 'rocket' }, ]; -/** - * Hook that returns the reaction emoji list from editor settings, - * falling back to the hardcoded REACTION_EMOJIS default. - * - * @return {Array} The emoji list. - */ -export function useReactionEmojis() { - return useSelect( ( select ) => { - const { noteReactionEmojis } = select( blockEditorStore ).getSettings(); - return noteReactionEmojis?.length - ? noteReactionEmojis - : REACTION_EMOJIS; - }, [] ); -} - /** * Get the emoji character for a given reaction slug. * - * @param {string} slug The reaction slug. - * @param {Array} emojis Optional emoji list to search. + * @param {string} slug The reaction slug. * @return {string} The emoji character, or the slug if not found. */ -export function getEmojiBySlug( slug, emojis = REACTION_EMOJIS ) { - return emojis.find( ( r ) => r.value === slug )?.emoji ?? slug; +export function getEmojiBySlug( slug ) { + return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.emoji ?? slug; } /** * Get the label for a given reaction slug. * - * @param {string} slug The reaction slug. - * @param {Array} emojis Optional emoji list to search. + * @param {string} slug The reaction slug. * @return {string} The label, or the slug if not found. */ -export function getLabelBySlug( slug, emojis = REACTION_EMOJIS ) { - return emojis.find( ( r ) => r.value === slug )?.label ?? slug; +export function getLabelBySlug( slug ) { + return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.label ?? slug; } /** @@ -63,15 +44,13 @@ export function getLabelBySlug( slug, emojis = REACTION_EMOJIS ) { * @param {Function} props.onSelect Callback when an emoji is selected. */ export default function ReactionEmojiPicker( { onSelect } ) { - const emojis = useReactionEmojis(); - return ( - { emojis.map( ( { emoji, label, value } ) => ( + { REACTION_EMOJIS.map( ( { emoji, label, value } ) => ( Date: Wed, 18 Feb 2026 08:07:59 +0700 Subject: [PATCH 3/5] Rename note_reaction comment type to reaction Makes the reaction comment type more generic and future-proof so emoji reactions can eventually be used elsewhere in WordPress, not just on notes. Aligns with the approach used by the React plugin (wordpress.org/plugins/react). --- lib/compat/wordpress-6.9/block-comments.php | 10 +++--- ...-gutenberg-rest-comment-controller-6-9.php | 22 ++++++------- .../src/components/collab-sidebar/hooks.js | 4 +-- ...est-comments-controller-gutenberg-test.php | 32 +++++++++---------- 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/lib/compat/wordpress-6.9/block-comments.php b/lib/compat/wordpress-6.9/block-comments.php index 2e7acca52f030e..d85e0946bf8206 100644 --- a/lib/compat/wordpress-6.9/block-comments.php +++ b/lib/compat/wordpress-6.9/block-comments.php @@ -63,7 +63,7 @@ function gutenberg_register_block_comment_metadata() { if ( ! function_exists( 'update_get_avatar_comment_type' ) ) { function update_get_avatar_comment_type( $comment_type ) { $comment_type[] = 'note'; - $comment_type[] = 'note_reaction'; + $comment_type[] = 'reaction'; return $comment_type; } add_filter( 'get_avatar_comment_types', 'update_get_avatar_comment_type' ); @@ -89,7 +89,7 @@ function exclude_block_comments_from_admin( $clauses, $query ) { $query->set( 'type', '' ); global $wpdb; - $clauses['where'] .= " AND {$wpdb->comments}.comment_type != 'note' AND {$wpdb->comments}.comment_type != 'note_reaction'"; + $clauses['where'] .= " AND {$wpdb->comments}.comment_type != 'note' AND {$wpdb->comments}.comment_type != 'reaction'"; } return $clauses; @@ -110,7 +110,7 @@ function gutenberg_filter_comment_count_query_exclude_block_comments( $query ) { // Adjust the query if it is a comment count query. if ( str_starts_with( $query, 'SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM' ) && str_contains( $query, 'comment_approved' ) ) { if ( ! str_contains( $query, "comment_type != 'note'" ) ) { - $query = str_replace( 'comment_approved', "comment_type != 'note' AND comment_type != 'note_reaction' AND comment_approved", $query ); + $query = str_replace( 'comment_approved', "comment_type != 'note' AND comment_type != 'reaction' AND comment_approved", $query ); } } return $query; @@ -124,7 +124,7 @@ function gutenberg_filter_comment_count_query_exclude_block_comments( $query ) { * @return array Possibly modified arguments for get_comments(). */ function gutenberg_hide_note_from_comment_list_table( $args ) { - if ( ! empty( $_REQUEST['comment_type'] ) && in_array( $_REQUEST['comment_type'], array( 'note', 'note_reaction' ), true ) ) { + if ( ! empty( $_REQUEST['comment_type'] ) && in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) { unset( $args['type'] ); } return $args; @@ -145,7 +145,7 @@ function gutenberg_exclude_notes_from_comment_count( $new_count, $old_count, $po if ( null !== $new_count ) { return $new_count; } - $new_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type != 'note' AND comment_type != 'note_reaction'", $post_id ) ); + $new_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type != 'note' AND comment_type != 'reaction'", $post_id ) ); return $new_count; } add_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count', 10, 3 ); diff --git a/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php b/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php index 2d0426e7b2ae60..c1d33aaf35abd7 100644 --- a/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php +++ b/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php @@ -10,7 +10,7 @@ class Gutenberg_REST_Comment_Controller_6_9 extends WP_REST_Comments_Controller { public function get_items_permissions_check( $request ) { - $is_note = in_array( $request['type'], array( 'note', 'note_reaction' ), true ); + $is_note = in_array( $request['type'], array( 'note', 'reaction' ), true ); $is_edit_context = 'edit' === $request['context']; if ( ! empty( $request['post'] ) ) { @@ -99,9 +99,9 @@ public function get_item_permissions_check( $request ) { return $comment; } - // Re-map edit context capabilities when requesting `note` or `note_reaction` type. + // Re-map edit context capabilities when requesting `note` or `reaction` type. // Note: This is only relevant change for the backport. - $edit_cap = in_array( $comment->comment_type, array( 'note', 'note_reaction' ), true ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); + $edit_cap = in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( ...$edit_cap ) ) { return new WP_Error( 'rest_forbidden_context', @@ -132,7 +132,7 @@ public function get_item_permissions_check( $request ) { } public function create_item_permissions_check( $request ) { - $is_note = ! empty( $request['type'] ) && in_array( $request['type'], array( 'note', 'note_reaction' ), true ); + $is_note = ! empty( $request['type'] ) && in_array( $request['type'], array( 'note', 'reaction' ), true ); // Note: This is only relevant change for the backport. if ( ! is_user_logged_in() && $is_note ) { @@ -292,7 +292,7 @@ public function create_item( $request ) { // Note: Removes non-default comment type check for the backport. // Do not allow comments to be created with a non-core type. - if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note', 'note_reaction' ), true ) ) { + if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note', 'reaction' ), true ) ) { return new WP_Error( 'rest_invalid_comment_type', __( 'Cannot create a comment with that type.', 'gutenberg' ), @@ -300,8 +300,8 @@ public function create_item( $request ) { ); } - // Validate note_reaction specific requirements. - if ( ! empty( $request['type'] ) && 'note_reaction' === $request['type'] ) { + // Validate reaction-specific requirements. + if ( ! empty( $request['type'] ) && 'reaction' === $request['type'] ) { // Validate parent is a note. if ( empty( $request['parent'] ) ) { return new WP_Error( @@ -336,7 +336,7 @@ public function create_item( $request ) { array( 'parent' => $request['parent'], 'user_id' => get_current_user_id(), - 'type' => 'note_reaction', + 'type' => 'reaction', 'status' => 'any', ) ); @@ -432,7 +432,7 @@ public function create_item( $request ) { // Don't check for duplicates or flooding for notes and reactions. $prepared_comment['comment_approved'] = - in_array( $prepared_comment['comment_type'], array( 'note', 'note_reaction' ), true ) ? + in_array( $prepared_comment['comment_type'], array( 'note', 'reaction' ), true ) ? '1' : wp_allow_comment( $prepared_comment, true ); @@ -580,7 +580,7 @@ protected function prepare_links( $comment ) { // Embedding children for notes requires `type` and `status` inheritance. // Note: This is only relevant change for the backport. - if ( isset( $links['children'] ) && in_array( $comment->comment_type, array( 'note', 'note_reaction' ), true ) ) { + if ( isset( $links['children'] ) && in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) ) { $args = array( 'parent' => $comment->comment_ID, 'type' => $comment->comment_type, @@ -658,7 +658,7 @@ protected function check_is_comment_content_allowed( $prepared_comment ) { } // Note reactions always have content (the emoji slug). - if ( isset( $check['comment_type'] ) && 'note_reaction' === $check['comment_type'] ) { + if ( isset( $check['comment_type'] ) && 'reaction' === $check['comment_type'] ) { return true; } diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index 2faa2f67c95311..8b4e09c0809c1f 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -62,7 +62,7 @@ export function useBlockComments( postId ) { 'comment', { post: postId, - type: 'note_reaction', + type: 'reaction', status: 'all', per_page: -1, }, @@ -419,7 +419,7 @@ export function useBlockCommentsActions( 'comment', { post: getCurrentPostId(), - type: 'note_reaction', + type: 'reaction', parent: commentId, content: emoji, status: 'approve', 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 7d0725756df637..2be8e9082631e1 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -480,14 +480,14 @@ protected function create_note( $post_id, $user_id ) { return $data['id']; } - public function test_create_note_reaction() { + 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' => 'note_reaction', + 'type' => 'reaction', 'parent' => $note_id, 'content' => 'heart', 'author' => self::$editor_id, @@ -502,19 +502,19 @@ public function test_create_note_reaction() { $data = $response->get_data(); $new_comment = get_comment( $data['id'] ); - $this->assertSame( 'note_reaction', $new_comment->comment_type ); + $this->assertSame( 'reaction', $new_comment->comment_type ); $this->assertSame( 'heart', $new_comment->comment_content ); $this->assertSame( (string) $note_id, $new_comment->comment_parent ); $this->assertSame( '1', $new_comment->comment_approved ); } - public function test_cannot_create_note_reaction_without_parent() { + public function test_cannot_create_reaction_without_parent() { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'content' => 'heart', 'author' => self::$editor_id, ); @@ -527,14 +527,14 @@ public function test_cannot_create_note_reaction_without_parent() { $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); } - public function test_cannot_create_note_reaction_with_invalid_emoji() { + public function test_cannot_create_reaction_with_invalid_emoji() { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); $note_id = $this->create_note( $post_id, self::$editor_id ); $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'parent' => $note_id, 'content' => 'invalid_emoji', 'author' => self::$editor_id, @@ -548,14 +548,14 @@ public function test_cannot_create_note_reaction_with_invalid_emoji() { $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); } - public function test_cannot_create_duplicate_note_reaction() { + 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' => 'note_reaction', + 'type' => 'reaction', 'parent' => $note_id, 'content' => 'rocket', 'author' => self::$editor_id, @@ -585,7 +585,7 @@ public function test_can_create_different_reactions_on_same_note() { foreach ( $emojis as $emoji ) { $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'parent' => $note_id, 'content' => $emoji, 'author' => self::$editor_id, @@ -598,7 +598,7 @@ public function test_can_create_different_reactions_on_same_note() { } } - public function test_cannot_create_note_reaction_on_regular_comment() { + public function test_cannot_create_reaction_on_regular_comment() { wp_set_current_user( self::$editor_id ); $post_id = self::factory()->post->create(); $comment_id = self::factory()->comment->create( @@ -610,7 +610,7 @@ public function test_cannot_create_note_reaction_on_regular_comment() { $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'parent' => $comment_id, 'content' => 'heart', 'author' => self::$editor_id, @@ -624,13 +624,13 @@ public function test_cannot_create_note_reaction_on_regular_comment() { $this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 ); } - public function test_create_note_reaction_requires_login() { + public function test_create_reaction_requires_login() { wp_set_current_user( 0 ); $post_id = self::factory()->post->create(); $params = array( 'post' => $post_id, - 'type' => 'note_reaction', + 'type' => 'reaction', 'content' => 'heart', ); @@ -645,14 +645,14 @@ public function test_create_note_reaction_requires_login() { /** * @dataProvider data_valid_reaction_emojis */ - public function test_create_note_reaction_valid_emojis( $emoji ) { + 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' => 'note_reaction', + 'type' => 'reaction', 'parent' => $note_id, 'content' => $emoji, 'author' => self::$editor_id, From 5664b647e57a6c4a5b09dd697bb4aadff2846792 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 18 Feb 2026 22:32:03 +0700 Subject: [PATCH 4/5] Move reaction PHP code to lib/compat/wordpress-7.1 --- lib/compat/wordpress-6.9/block-comments.php | 9 +- ...-gutenberg-rest-comment-controller-6-9.php | 90 +-- lib/compat/wordpress-7.1/block-comments.php | 108 ++++ ...-gutenberg-rest-comment-controller-7-1.php | 574 ++++++++++++++++++ lib/load.php | 4 + 5 files changed, 707 insertions(+), 78 deletions(-) create mode 100644 lib/compat/wordpress-7.1/block-comments.php create mode 100644 lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php diff --git a/lib/compat/wordpress-6.9/block-comments.php b/lib/compat/wordpress-6.9/block-comments.php index d85e0946bf8206..11d46ae8292d20 100644 --- a/lib/compat/wordpress-6.9/block-comments.php +++ b/lib/compat/wordpress-6.9/block-comments.php @@ -63,7 +63,6 @@ function gutenberg_register_block_comment_metadata() { if ( ! function_exists( 'update_get_avatar_comment_type' ) ) { function update_get_avatar_comment_type( $comment_type ) { $comment_type[] = 'note'; - $comment_type[] = 'reaction'; return $comment_type; } add_filter( 'get_avatar_comment_types', 'update_get_avatar_comment_type' ); @@ -89,7 +88,7 @@ function exclude_block_comments_from_admin( $clauses, $query ) { $query->set( 'type', '' ); global $wpdb; - $clauses['where'] .= " AND {$wpdb->comments}.comment_type != 'note' AND {$wpdb->comments}.comment_type != 'reaction'"; + $clauses['where'] .= " AND {$wpdb->comments}.comment_type != 'note'"; } return $clauses; @@ -110,7 +109,7 @@ function gutenberg_filter_comment_count_query_exclude_block_comments( $query ) { // Adjust the query if it is a comment count query. if ( str_starts_with( $query, 'SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM' ) && str_contains( $query, 'comment_approved' ) ) { if ( ! str_contains( $query, "comment_type != 'note'" ) ) { - $query = str_replace( 'comment_approved', "comment_type != 'note' AND comment_type != 'reaction' AND comment_approved", $query ); + $query = str_replace( 'comment_approved', "comment_type != 'note' AND comment_approved", $query ); } } return $query; @@ -124,7 +123,7 @@ function gutenberg_filter_comment_count_query_exclude_block_comments( $query ) { * @return array Possibly modified arguments for get_comments(). */ function gutenberg_hide_note_from_comment_list_table( $args ) { - if ( ! empty( $_REQUEST['comment_type'] ) && in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) { + if ( ! empty( $_REQUEST['comment_type'] ) && 'note' === $_REQUEST['comment_type'] ) { unset( $args['type'] ); } return $args; @@ -145,7 +144,7 @@ function gutenberg_exclude_notes_from_comment_count( $new_count, $old_count, $po if ( null !== $new_count ) { return $new_count; } - $new_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type != 'note' AND comment_type != 'reaction'", $post_id ) ); + $new_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type != 'note'", $post_id ) ); return $new_count; } add_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count', 10, 3 ); diff --git a/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php b/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php index c1d33aaf35abd7..e92d0570e76060 100644 --- a/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php +++ b/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php @@ -10,7 +10,7 @@ class Gutenberg_REST_Comment_Controller_6_9 extends WP_REST_Comments_Controller { public function get_items_permissions_check( $request ) { - $is_note = in_array( $request['type'], array( 'note', 'reaction' ), true ); + $is_note = 'note' === $request['type']; $is_edit_context = 'edit' === $request['context']; if ( ! empty( $request['post'] ) ) { @@ -99,9 +99,9 @@ public function get_item_permissions_check( $request ) { return $comment; } - // Re-map edit context capabilities when requesting `note` or `reaction` type. + // Re-map edit context capabilities when requesting `note` type. // Note: This is only relevant change for the backport. - $edit_cap = in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); + $edit_cap = 'note' === $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', @@ -132,7 +132,7 @@ public function get_item_permissions_check( $request ) { } public function create_item_permissions_check( $request ) { - $is_note = ! empty( $request['type'] ) && in_array( $request['type'], array( 'note', 'reaction' ), true ); + $is_note = ! empty( $request['type'] ) && 'note' === $request['type']; // Note: This is only relevant change for the backport. if ( ! is_user_logged_in() && $is_note ) { @@ -292,7 +292,7 @@ public function create_item( $request ) { // Note: Removes non-default comment type check for the backport. // Do not allow comments to be created with a non-core type. - if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note', 'reaction' ), true ) ) { + if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note' ), true ) ) { return new WP_Error( 'rest_invalid_comment_type', __( 'Cannot create a comment with that type.', 'gutenberg' ), @@ -300,58 +300,6 @@ public function create_item( $request ) { ); } - // Validate reaction-specific requirements. - if ( ! empty( $request['type'] ) && 'reaction' === $request['type'] ) { - // Validate parent is a note. - if ( empty( $request['parent'] ) ) { - return new WP_Error( - 'rest_comment_invalid_parent', - __( 'A reaction must have a parent note.', 'gutenberg' ), - array( 'status' => 400 ) - ); - } - - $parent_comment = get_comment( $request['parent'] ); - if ( ! $parent_comment || 'note' !== $parent_comment->comment_type ) { - return new WP_Error( - 'rest_comment_invalid_parent', - __( 'A reaction must be attached to a note.', 'gutenberg' ), - array( 'status' => 400 ) - ); - } - - // Validate content is a valid emoji slug. - $valid_slugs = array( 'heart', 'celebration', 'smile', 'eyes', 'rocket' ); - $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; - if ( ! in_array( $emoji_slug, $valid_slugs, true ) ) { - return new WP_Error( - 'rest_comment_invalid_reaction', - __( 'Invalid reaction emoji.', 'gutenberg' ), - array( 'status' => 400 ) - ); - } - - // Enforce uniqueness: prevent duplicate emoji per user per note. - $existing = get_comments( - array( - 'parent' => $request['parent'], - 'user_id' => get_current_user_id(), - 'type' => 'reaction', - 'status' => 'any', - ) - ); - - foreach ( $existing as $existing_reaction ) { - if ( wp_strip_all_tags( $existing_reaction->comment_content ) === $emoji_slug ) { - return new WP_Error( - 'rest_comment_duplicate_reaction', - __( 'You have already reacted with this emoji.', 'gutenberg' ), - array( 'status' => 409 ) - ); - } - } - } - $prepared_comment = $this->prepare_item_for_database( $request ); if ( is_wp_error( $prepared_comment ) ) { return $prepared_comment; @@ -430,9 +378,9 @@ public function create_item( $request ) { ); } - // Don't check for duplicates or flooding for notes and reactions. + // Don't check for duplicates or flooding for notes. $prepared_comment['comment_approved'] = - in_array( $prepared_comment['comment_type'], array( 'note', 'reaction' ), true ) ? + 'note' === $prepared_comment['comment_type'] ? '1' : wp_allow_comment( $prepared_comment, true ); @@ -551,7 +499,7 @@ public function create_item( $request ) { * @param string $post_type Post type name. * @return bool True if post type supports block comments, false otherwise. */ - private function check_post_type_supports_notes( $post_type ) { + protected function check_post_type_supports_notes( $post_type ) { $supports = get_all_post_type_supports( $post_type ); if ( ! isset( $supports['editor'] ) ) { return false; @@ -580,7 +528,7 @@ protected function prepare_links( $comment ) { // Embedding children for notes requires `type` and `status` inheritance. // Note: This is only relevant change for the backport. - if ( isset( $links['children'] ) && in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) ) { + if ( isset( $links['children'] ) && 'note' === $comment->comment_type ) { $args = array( 'parent' => $comment->comment_ID, 'type' => $comment->comment_type, @@ -657,11 +605,6 @@ protected function check_is_comment_content_allowed( $prepared_comment ) { return true; } - // Note reactions always have content (the emoji slug). - if ( isset( $check['comment_type'] ) && 'reaction' === $check['comment_type'] ) { - return true; - } - /* * Do not allow a comment to be created with missing or empty * comment_content. See wp_handle_comment_submission(). @@ -670,10 +613,11 @@ protected function check_is_comment_content_allowed( $prepared_comment ) { } } -add_action( - 'rest_api_init', - function () { - $controller = new Gutenberg_REST_Comment_Controller_6_9(); - $controller->register_routes(); - } -); +/** + * Registers the Gutenberg REST comment controller for WordPress 6.9 compatibility. + */ +function gutenberg_register_comment_controller_6_9() { + $controller = new Gutenberg_REST_Comment_Controller_6_9(); + $controller->register_routes(); +} +add_action( 'rest_api_init', 'gutenberg_register_comment_controller_6_9' ); diff --git a/lib/compat/wordpress-7.1/block-comments.php b/lib/compat/wordpress-7.1/block-comments.php new file mode 100644 index 00000000000000..a4c3fd17ec2463 --- /dev/null +++ b/lib/compat/wordpress-7.1/block-comments.php @@ -0,0 +1,108 @@ +query_vars['type'] ) && '' === $query->query_vars['type'] ) { + $query->set( 'type', '' ); + + global $wpdb; + $clauses['where'] .= " AND {$wpdb->comments}.comment_type != 'note' AND {$wpdb->comments}.comment_type != 'reaction'"; + } + + return $clauses; +} +remove_action( 'comments_clauses', 'exclude_block_comments_from_admin', 10 ); +add_action( 'comments_clauses', 'gutenberg_exclude_block_comments_from_admin_7_1', 10, 2 ); + +/** + * Filter the comment count query to exclude notes and reactions. + * + * Replaces the 6.9 implementation to also exclude 'reaction' type. + * + * @param string $query The SQL query string. + * @return string The modified SQL query string. + */ +function gutenberg_filter_comment_count_query_exclude_block_comments_7_1( $query ) { + if ( str_starts_with( $query, 'SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM' ) && str_contains( $query, 'comment_approved' ) ) { + if ( ! str_contains( $query, "comment_type != 'note'" ) ) { + $query = str_replace( 'comment_approved', "comment_type != 'note' AND comment_type != 'reaction' AND comment_approved", $query ); + } + } + return $query; +} +remove_filter( 'query', 'gutenberg_filter_comment_count_query_exclude_block_comments' ); +add_filter( 'query', 'gutenberg_filter_comment_count_query_exclude_block_comments_7_1' ); + +/** + * Adjusts the comments list table query so notes and reactions never display. + * + * Replaces the 6.9 implementation to also handle 'reaction' type. + * + * @param array $args An array of get_comments() arguments. + * @return array Possibly modified arguments for get_comments(). + */ +function gutenberg_hide_note_from_comment_list_table_7_1( $args ) { + if ( ! empty( $_REQUEST['comment_type'] ) && in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) { + unset( $args['type'] ); + } + return $args; +} +remove_filter( 'comments_list_table_query_args', 'gutenberg_hide_note_from_comment_list_table' ); +add_filter( 'comments_list_table_query_args', 'gutenberg_hide_note_from_comment_list_table_7_1' ); + +/** + * Override comment_count to exclude notes and reactions from the comment count. + * + * Replaces the 6.9 implementation to also exclude 'reaction' type. + * + * @param int|null $new The new comment count. Default null. + * @param int $old The old comment count. + * @param int $post_id Post ID. + * @return int|null The modified comment count. + */ +function gutenberg_exclude_notes_from_comment_count_7_1( $new_count, $old_count, $post_id ) { + global $wpdb; + if ( null !== $new_count ) { + return $new_count; + } + $new_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type != 'note' AND comment_type != 'reaction'", $post_id ) ); + return $new_count; +} +remove_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count', 10 ); +add_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count_7_1', 10, 3 ); diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php new file mode 100644 index 00000000000000..b8f13c0454d419 --- /dev/null +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -0,0 +1,574 @@ +is_note_or_reaction( $request['type'] ); + $is_edit_context = 'edit' === $request['context']; + + if ( ! empty( $request['post'] ) ) { + foreach ( (array) $request['post'] as $post_id ) { + $post = get_post( $post_id ); + + if ( $post && $is_note && ! $this->check_post_type_supports_notes( $post->post_type ) ) { + return new WP_Error( + 'rest_comment_not_supported_post_type', + __( 'Sorry, this post type does not support notes.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + if ( ! empty( $post_id ) && $post && ! $this->check_read_post_permission( $post, $request ) ) { + return new WP_Error( + 'rest_cannot_read_post', + __( 'Sorry, you are not allowed to read the post for this comment.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } elseif ( 0 === $post_id && ! current_user_can( 'moderate_comments' ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to read comments without a post.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + } + + if ( $is_edit_context && $is_note && ! empty( $request['post'] ) ) { + foreach ( (array) $request['post'] as $post_id ) { + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'rest_forbidden_context', + __( 'Sorry, you are not allowed to edit comments.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + } elseif ( $is_edit_context && ! current_user_can( 'moderate_comments' ) ) { + return new WP_Error( + 'rest_forbidden_context', + __( 'Sorry, you are not allowed to edit comments.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( ! current_user_can( 'edit_posts' ) ) { + $protected_params = array( 'author', 'author_exclude', 'author_email', 'type', 'status' ); + $forbidden_params = array(); + + foreach ( $protected_params as $param ) { + if ( 'status' === $param ) { + if ( 'approve' !== $request[ $param ] ) { + $forbidden_params[] = $param; + } + } elseif ( 'type' === $param ) { + if ( 'comment' !== $request[ $param ] ) { + $forbidden_params[] = $param; + } + } elseif ( ! empty( $request[ $param ] ) ) { + $forbidden_params[] = $param; + } + } + + if ( ! empty( $forbidden_params ) ) { + return new WP_Error( + 'rest_forbidden_param', + /* translators: %s: List of forbidden parameters. */ + sprintf( __( 'Query parameter not permitted: %s', 'gutenberg' ), implode( ', ', $forbidden_params ) ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + + return true; + } + + public function get_item_permissions_check( $request ) { + $comment = $this->get_comment( $request['id'] ); + if ( is_wp_error( $comment ) ) { + return $comment; + } + + // Re-map edit context capabilities when requesting `note` or `reaction` type. + $edit_cap = $this->is_note_or_reaction( $comment->comment_type ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); + if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( ...$edit_cap ) ) { + return new WP_Error( + 'rest_forbidden_context', + __( 'Sorry, you are not allowed to edit comments.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $post = get_post( $comment->comment_post_ID ); + + if ( ! $this->check_read_permission( $comment, $request ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to read this comment.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( $post && ! $this->check_read_post_permission( $post, $request ) ) { + return new WP_Error( + 'rest_cannot_read_post', + __( 'Sorry, you are not allowed to read the post for this comment.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + public function create_item_permissions_check( $request ) { + $is_note = ! empty( $request['type'] ) && $this->is_note_or_reaction( $request['type'] ); + + if ( ! is_user_logged_in() && $is_note ) { + return new WP_Error( + 'rest_comment_login_required', + __( 'Sorry, you must be logged in to comment.', 'gutenberg' ), + array( 'status' => 401 ) + ); + } + + if ( ! is_user_logged_in() ) { + if ( get_option( 'comment_registration' ) ) { + return new WP_Error( + 'rest_comment_login_required', + __( 'Sorry, you must be logged in to comment.', 'gutenberg' ), + array( 'status' => 401 ) + ); + } + + /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */ + $allow_anonymous = apply_filters( 'rest_allow_anonymous_comments', false, $request ); + + if ( ! $allow_anonymous ) { + return new WP_Error( + 'rest_comment_login_required', + __( 'Sorry, you must be logged in to comment.', 'gutenberg' ), + array( 'status' => 401 ) + ); + } + } + + if ( isset( $request['author'] ) && get_current_user_id() !== $request['author'] && ! current_user_can( 'moderate_comments' ) ) { + return new WP_Error( + 'rest_comment_invalid_author', + /* translators: %s: Request parameter. */ + sprintf( __( "Sorry, you are not allowed to edit '%s' for comments.", 'gutenberg' ), 'author' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( isset( $request['author_ip'] ) && ! current_user_can( 'moderate_comments' ) ) { + if ( empty( $_SERVER['REMOTE_ADDR'] ) || $request['author_ip'] !== $_SERVER['REMOTE_ADDR'] ) { + return new WP_Error( + 'rest_comment_invalid_author_ip', + /* translators: %s: Request parameter. */ + sprintf( __( "Sorry, you are not allowed to edit '%s' for comments.", 'gutenberg' ), 'author_ip' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + } + + $edit_cap = $is_note ? array( 'edit_post', (int) $request['post'] ) : array( 'moderate_comments' ); + if ( isset( $request['status'] ) && ! current_user_can( ...$edit_cap ) ) { + return new WP_Error( + 'rest_comment_invalid_status', + /* translators: %s: Request parameter. */ + sprintf( __( "Sorry, you are not allowed to edit '%s' for comments.", 'gutenberg' ), 'status' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( empty( $request['post'] ) ) { + return new WP_Error( + 'rest_comment_invalid_post_id', + __( 'Sorry, you are not allowed to create this comment without a post.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + $post = get_post( (int) $request['post'] ); + + if ( ! $post ) { + return new WP_Error( + 'rest_comment_invalid_post_id', + __( 'Sorry, you are not allowed to create this comment without a post.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + if ( $is_note && ! $this->check_post_type_supports_notes( $post->post_type ) ) { + return new WP_Error( + 'rest_comment_not_supported_post_type', + __( 'Sorry, this post type does not support notes.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + if ( 'draft' === $post->post_status && ! $is_note ) { + return new WP_Error( + 'rest_comment_draft_post', + __( 'Sorry, you are not allowed to create a comment on this post.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + if ( 'trash' === $post->post_status ) { + return new WP_Error( + 'rest_comment_trash_post', + __( 'Sorry, you are not allowed to create a comment on this post.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + if ( ! $this->check_read_post_permission( $post, $request ) ) { + return new WP_Error( + 'rest_cannot_read_post', + __( 'Sorry, you are not allowed to read the post for this comment.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( ! comments_open( $post->ID ) && ! $is_note ) { + return new WP_Error( + 'rest_comment_closed', + __( 'Sorry, comments are closed for this item.', 'gutenberg' ), + array( 'status' => 403 ) + ); + } + + return true; + } + + /** + * Creates a comment. + * + * Extends the 6.9 implementation to support 'reaction' comment type + * with validation for parent note, valid emoji slugs, and uniqueness. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or error object on failure. + */ + public function create_item( $request ) { + if ( ! empty( $request['id'] ) ) { + return new WP_Error( + 'rest_comment_exists', + __( 'Cannot create existing comment.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Allow 'comment', 'note', and 'reaction' types. + if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note', 'reaction' ), true ) ) { + return new WP_Error( + 'rest_invalid_comment_type', + __( 'Cannot create a comment with that type.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Validate reaction-specific requirements. + if ( ! empty( $request['type'] ) && 'reaction' === $request['type'] ) { + // Validate parent is a note. + if ( empty( $request['parent'] ) ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction must have a parent note.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + $parent_comment = get_comment( $request['parent'] ); + if ( ! $parent_comment || 'note' !== $parent_comment->comment_type ) { + return new WP_Error( + 'rest_comment_invalid_parent', + __( 'A reaction must be attached to a note.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Validate content is a valid emoji slug. + $valid_slugs = array( 'heart', 'celebration', 'smile', 'eyes', 'rocket' ); + $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; + if ( ! in_array( $emoji_slug, $valid_slugs, true ) ) { + return new WP_Error( + 'rest_comment_invalid_reaction', + __( 'Invalid reaction emoji.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Enforce uniqueness: prevent duplicate emoji per user per note. + $existing = get_comments( + array( + 'parent' => $request['parent'], + 'user_id' => get_current_user_id(), + 'type' => 'reaction', + 'status' => 'any', + ) + ); + + foreach ( $existing as $existing_reaction ) { + if ( wp_strip_all_tags( $existing_reaction->comment_content ) === $emoji_slug ) { + return new WP_Error( + 'rest_comment_duplicate_reaction', + __( 'You have already reacted with this emoji.', 'gutenberg' ), + array( 'status' => 409 ) + ); + } + } + } + + $prepared_comment = $this->prepare_item_for_database( $request ); + if ( is_wp_error( $prepared_comment ) ) { + return $prepared_comment; + } + + $prepared_comment['comment_type'] = $request['type']; + + if ( ! isset( $prepared_comment['comment_content'] ) ) { + $prepared_comment['comment_content'] = ''; + } + + // Include note metadata into check_is_comment_content_allowed [backport]. + if ( isset( $request['meta']['_wp_note_status'] ) ) { + $prepared_comment['meta']['_wp_note_status'] = $request['meta']['_wp_note_status']; + } + + if ( ! $this->check_is_comment_content_allowed( $prepared_comment ) ) { + return new WP_Error( + 'rest_comment_content_invalid', + __( 'Invalid comment content.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Setting remaining values before wp_insert_comment so we can use wp_allow_comment(). + if ( ! isset( $prepared_comment['comment_date_gmt'] ) ) { + $prepared_comment['comment_date_gmt'] = current_time( 'mysql', true ); + } + + // Set author data if the user's logged in. + $missing_author = empty( $prepared_comment['user_id'] ) + && empty( $prepared_comment['comment_author'] ) + && empty( $prepared_comment['comment_author_email'] ) + && empty( $prepared_comment['comment_author_url'] ); + + if ( is_user_logged_in() && $missing_author ) { + $user = wp_get_current_user(); + + $prepared_comment['user_id'] = $user->ID; + $prepared_comment['comment_author'] = $user->display_name; + $prepared_comment['comment_author_email'] = $user->user_email; + $prepared_comment['comment_author_url'] = $user->user_url; + } + + // Honor the discussion setting that requires a name and email address of the comment author. + if ( get_option( 'require_name_email' ) ) { + if ( empty( $prepared_comment['comment_author'] ) || empty( $prepared_comment['comment_author_email'] ) ) { + return new WP_Error( + 'rest_comment_author_data_required', + __( 'Creating a comment requires valid author name and email values.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + } + + if ( ! isset( $prepared_comment['comment_author_email'] ) ) { + $prepared_comment['comment_author_email'] = ''; + } + + if ( ! isset( $prepared_comment['comment_author_url'] ) ) { + $prepared_comment['comment_author_url'] = ''; + } + + if ( ! isset( $prepared_comment['comment_agent'] ) ) { + $prepared_comment['comment_agent'] = ''; + } + + $check_comment_lengths = wp_check_comment_data_max_lengths( $prepared_comment ); + + if ( is_wp_error( $check_comment_lengths ) ) { + $error_code = $check_comment_lengths->get_error_code(); + return new WP_Error( + $error_code, + __( 'Comment field exceeds maximum length allowed.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Don't check for duplicates or flooding for notes and reactions. + $prepared_comment['comment_approved'] = + $this->is_note_or_reaction( $prepared_comment['comment_type'] ) ? + '1' : + wp_allow_comment( $prepared_comment, true ); + + if ( is_wp_error( $prepared_comment['comment_approved'] ) ) { + $error_code = $prepared_comment['comment_approved']->get_error_code(); + $error_message = $prepared_comment['comment_approved']->get_error_message(); + + if ( 'comment_duplicate' === $error_code ) { + return new WP_Error( + $error_code, + $error_message, + array( 'status' => 409 ) + ); + } + + if ( 'comment_flood' === $error_code ) { + return new WP_Error( + $error_code, + $error_message, + array( 'status' => 400 ) + ); + } + + return $prepared_comment['comment_approved']; + } + + /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */ + $prepared_comment = apply_filters( 'rest_pre_insert_comment', $prepared_comment, $request ); + if ( is_wp_error( $prepared_comment ) ) { + return $prepared_comment; + } + + $comment_id = wp_insert_comment( wp_filter_comment( wp_slash( (array) $prepared_comment ) ) ); + + if ( ! $comment_id ) { + return new WP_Error( + 'rest_comment_failed_create', + __( 'Creating comment failed.', 'gutenberg' ), + array( 'status' => 500 ) + ); + } + + if ( isset( $request['status'] ) ) { + $this->handle_status_param( $request['status'], $comment_id ); + } + + $comment = get_comment( $comment_id ); + + /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */ + do_action( 'rest_insert_comment', $comment, $request, true ); + + $schema = $this->get_item_schema(); + + if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { + $meta_update = $this->meta->update_value( $request['meta'], $comment_id ); + + if ( is_wp_error( $meta_update ) ) { + return $meta_update; + } + } + + $fields_update = $this->update_additional_fields_for_object( $comment, $request ); + + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } + + $context = current_user_can( 'moderate_comments' ) ? 'edit' : 'view'; + $request->set_param( 'context', $context ); + + /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php */ + do_action( 'rest_after_insert_comment', $comment, $request, true ); + + $response = $this->prepare_item_for_response( $comment, $request ); + $response = rest_ensure_response( $response ); + + $response->set_status( 201 ); + $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $comment_id ) ) ); + + return $response; + } + + /** + * Prepares links for the request. + * + * Extends the 6.9 implementation to also handle 'reaction' type + * for children link embedding. + * + * @since 7.1.0 + * + * @param WP_Comment $comment Comment object. + * @return array Links for the given comment. + */ + protected function prepare_links( $comment ) { + $links = WP_REST_Comments_Controller::prepare_links( $comment ); + + // Embedding children for notes and reactions requires `type` and `status` inheritance. + if ( isset( $links['children'] ) && $this->is_note_or_reaction( $comment->comment_type ) ) { + $args = array( + 'parent' => $comment->comment_ID, + 'type' => $comment->comment_type, + 'status' => 'all', + ); + + $rest_url = add_query_arg( $args, rest_url( $this->namespace . '/' . $this->rest_base ) ); + + $links['children'] = array( + 'href' => $rest_url, + 'embeddable' => true, + ); + } + + return $links; + } + + /** + * Checks if comment content is allowed. + * + * Extends the 6.9 implementation to also allow reaction content + * (emoji slugs are always valid content). + * + * @since 7.1.0 + * + * @param array $prepared_comment The prepared comment data. + * @return bool True if the content is allowed, false otherwise. + */ + protected function check_is_comment_content_allowed( $prepared_comment ) { + // Note reactions always have content (the emoji slug). + if ( isset( $prepared_comment['comment_type'] ) && 'reaction' === $prepared_comment['comment_type'] ) { + return true; + } + + return parent::check_is_comment_content_allowed( $prepared_comment ); + } +} + +/** + * Registers the Gutenberg REST comment controller for WordPress 7.1 compatibility. + * + * Replaces the 6.9 controller registration to use the 7.1 controller + * which adds reaction support. + */ +function gutenberg_register_comment_controller_7_1() { + $controller = new Gutenberg_REST_Comment_Controller_7_1(); + $controller->register_routes(); +} +remove_action( 'rest_api_init', 'gutenberg_register_comment_controller_6_9' ); +add_action( 'rest_api_init', 'gutenberg_register_comment_controller_7_1' ); diff --git a/lib/load.php b/lib/load.php index a331461f146eef..ca7a5457042613 100644 --- a/lib/load.php +++ b/lib/load.php @@ -74,6 +74,10 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-7.0/global-styles.php'; require __DIR__ . '/compat/wordpress-7.0/block-comments.php'; + // WordPress 7.1 compat. + require __DIR__ . '/compat/wordpress-7.1/block-comments.php'; + require __DIR__ . '/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php'; + // Plugin specific code. require_once __DIR__ . '/class-wp-rest-global-styles-controller-gutenberg.php'; require_once __DIR__ . '/class-wp-rest-edit-site-export-controller-gutenberg.php'; From 8598f75eeb44b7e24aad89566e056f920c53da94 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 18 Feb 2026 23:10:55 +0700 Subject: [PATCH 5/5] Expose allowed reaction emojis via REST API schema Add a filterable gutenberg_get_note_reaction_emojis() helper and expose the emoji list as a read-only reaction_emojis property in the comments REST API schema. The client-side useReactionEmojis() hook reads this from the OPTIONS response so the emoji picker and display render what the server actually allows. Validation in create_item() now uses the same filtered list, so custom emojis added via the gutenberg_note_reaction_emojis filter are accepted end-to-end. Co-Authored-By: Claude Opus 4.6 --- lib/compat/wordpress-7.1/block-comments.php | 52 +++++++++ ...-gutenberg-rest-comment-controller-7-1.php | 46 +++++++- .../collab-sidebar/reaction-display.js | 12 +- .../collab-sidebar/reaction-emoji-picker.js | 71 ++++++++++-- ...est-comments-controller-gutenberg-test.php | 103 +++++++++++++++++- 5 files changed, 265 insertions(+), 19 deletions(-) diff --git a/lib/compat/wordpress-7.1/block-comments.php b/lib/compat/wordpress-7.1/block-comments.php index a4c3fd17ec2463..7940f597f5331f 100644 --- a/lib/compat/wordpress-7.1/block-comments.php +++ b/lib/compat/wordpress-7.1/block-comments.php @@ -106,3 +106,55 @@ function gutenberg_exclude_notes_from_comment_count_7_1( $new_count, $old_count, } remove_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count', 10 ); add_filter( 'pre_wp_update_comment_count_now', 'gutenberg_exclude_notes_from_comment_count_7_1', 10, 3 ); + +/** + * Returns the allowed emojis for note reactions. + * + * Each emoji is an associative array with: + * - `emoji` (string) The emoji character. + * - `label` (string) A translated human-readable label. + * - `value` (string) A slug used as the storage key. + * + * @since 7.1.0 + * + * @return array[] List of emoji definitions. + */ +function gutenberg_get_note_reaction_emojis() { + $default_emojis = array( + array( + 'emoji' => '❤️', + 'label' => __( 'Heart', 'gutenberg' ), + 'value' => 'heart', + ), + array( + 'emoji' => '🎉', + 'label' => __( 'Celebration', 'gutenberg' ), + 'value' => 'celebration', + ), + array( + 'emoji' => '😄', + 'label' => __( 'Smile', 'gutenberg' ), + 'value' => 'smile', + ), + array( + 'emoji' => '👀', + 'label' => __( 'Eyes', 'gutenberg' ), + 'value' => 'eyes', + ), + array( + 'emoji' => '🚀', + 'label' => __( 'Rocket', 'gutenberg' ), + 'value' => 'rocket', + ), + ); + + /** + * Filters the list of allowed emojis for note reactions. + * + * @since 7.1.0 + * + * @param array[] $emojis List of emoji definitions. Each item has + * `emoji`, `label`, and `value` keys. + */ + return apply_filters( 'gutenberg_note_reaction_emojis', $default_emojis ); +} diff --git a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php index b8f13c0454d419..4bcdc2b641de59 100644 --- a/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php +++ b/lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php @@ -11,6 +11,49 @@ class Gutenberg_REST_Comment_Controller_7_1 extends Gutenberg_REST_Comment_Controller_6_9 { + /** + * Retrieves the comment schema, adding reaction_emojis. + * + * Extends the parent schema with a read-only `reaction_emojis` + * property whose default value exposes the filtered emoji list. + * Clients can read this from the OPTIONS response to discover + * which reaction emojis the server accepts. + * + * @since 7.1.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + $schema = parent::get_item_schema(); + + $schema['properties']['reaction_emojis'] = array( + 'description' => __( 'Allowed emoji reactions for notes.', 'gutenberg' ), + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'emoji' => array( + 'description' => __( 'The emoji character.', 'gutenberg' ), + 'type' => 'string', + ), + 'label' => array( + 'description' => __( 'A human-readable label for the emoji.', 'gutenberg' ), + 'type' => 'string', + ), + 'value' => array( + 'description' => __( 'The slug used as the storage key.', 'gutenberg' ), + 'type' => 'string', + ), + ), + ), + 'default' => gutenberg_get_note_reaction_emojis(), + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ); + + return $schema; + } + /** * Checks whether the request type is a note or reaction. * @@ -312,7 +355,8 @@ public function create_item( $request ) { } // Validate content is a valid emoji slug. - $valid_slugs = array( 'heart', 'celebration', 'smile', 'eyes', 'rocket' ); + $emojis = gutenberg_get_note_reaction_emojis(); + $valid_slugs = wp_list_pluck( $emojis, 'value' ); $emoji_slug = isset( $request['content'] ) ? wp_strip_all_tags( $request['content'] ) : ''; if ( ! in_array( $emoji_slug, $valid_slugs, true ) ) { return new WP_Error( diff --git a/packages/editor/src/components/collab-sidebar/reaction-display.js b/packages/editor/src/components/collab-sidebar/reaction-display.js index b069ca40935f07..4fa152e53d0133 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-display.js +++ b/packages/editor/src/components/collab-sidebar/reaction-display.js @@ -18,6 +18,7 @@ import { store as coreStore } from '@wordpress/core-data'; import ReactionEmojiPicker, { getEmojiBySlug, getLabelBySlug, + useReactionEmojis, } from './reaction-emoji-picker'; import { unlock } from '../../lock-unlock'; @@ -89,9 +90,10 @@ function getReactedSlugs( reactions ) { * @param {Object} users Map of user data by ID. * @param {Array} userIds Array of user IDs who reacted. * @param {string} slug The reaction slug. + * @param {Array} emojis The emoji list to look up labels from. * @return {string} The tooltip text. */ -function getReactionTooltipText( users, userIds, slug ) { +function getReactionTooltipText( users, userIds, slug, emojis ) { const names = userIds .map( ( id ) => users?.[ id ]?.name ) .filter( Boolean ); @@ -100,7 +102,7 @@ function getReactionTooltipText( users, userIds, slug ) { return ''; } - const emojiLabel = getLabelBySlug( slug ); + const emojiLabel = getLabelBySlug( slug, emojis ); if ( names.length === 1 ) { return sprintf( @@ -144,6 +146,7 @@ function getReactionTooltipText( users, userIds, slug ) { * @param {Function} props.onToggleReaction Callback to toggle a reaction. */ export default function ReactionDisplay( { reactions, onToggleReaction } ) { + const emojis = useReactionEmojis(); const currentUserId = useSelect( ( select ) => { const { getCurrentUser } = select( coreStore ); const user = getCurrentUser(); @@ -205,7 +208,7 @@ export default function ReactionDisplay( { reactions, onToggleReaction } ) { slug, currentUserId ); - const emoji = getEmojiBySlug( slug ); + const emoji = getEmojiBySlug( slug, emojis ); const reactionUserIds = ( reactions?.[ slug ] || [] ).map( ( r ) => r.userId @@ -213,7 +216,8 @@ export default function ReactionDisplay( { reactions, onToggleReaction } ) { const tooltipText = getReactionTooltipText( users, reactionUserIds, - slug + slug, + emojis ); // Use tooltip text when user data is loaded, otherwise fall back to basic label. diff --git a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js index 0fc23a184bfa47..fbd4cec1a52127 100644 --- a/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js +++ b/packages/editor/src/components/collab-sidebar/reaction-emoji-picker.js @@ -3,9 +3,11 @@ */ import { __ } from '@wordpress/i18n'; import { Button, Composite } from '@wordpress/components'; +import { useState, useEffect } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; /** - * Curated emoji set for reactions. + * Curated emoji set for reactions (fallback). * The `value` slug is used as the storage key in the database to avoid * potential encoding issues with emoji characters. */ @@ -17,24 +19,75 @@ export const REACTION_EMOJIS = [ { emoji: '🚀', label: __( 'Rocket' ), value: 'rocket' }, ]; +// Module-level cache so the OPTIONS request is made only once. +let cachedEmojis = null; +let fetchPromise = null; + +/** + * Hook that returns the allowed reaction emojis from the REST API schema. + * + * Fetches the emoji list from the `OPTIONS /wp/v2/comments` response + * and caches the result at module level. Falls back to the hardcoded + * REACTION_EMOJIS if the request fails or the schema property is missing. + * + * @return {Array} The list of allowed reaction emoji objects. + */ +export function useReactionEmojis() { + const [ emojis, setEmojis ] = useState( cachedEmojis || REACTION_EMOJIS ); + + useEffect( () => { + if ( cachedEmojis ) { + return; + } + + if ( ! fetchPromise ) { + fetchPromise = apiFetch( { + path: '/wp/v2/comments', + method: 'OPTIONS', + } ) + .then( ( response ) => { + const schemaEmojis = + response?.schema?.properties?.reaction_emojis + ?.default; + if ( Array.isArray( schemaEmojis ) && schemaEmojis.length ) { + cachedEmojis = schemaEmojis; + } else { + cachedEmojis = REACTION_EMOJIS; + } + } ) + .catch( () => { + cachedEmojis = REACTION_EMOJIS; + } ); + } + + fetchPromise.then( () => { + setEmojis( cachedEmojis ); + } ); + }, [] ); + + return emojis; +} + /** * Get the emoji character for a given reaction slug. * - * @param {string} slug The reaction slug. + * @param {string} slug The reaction slug. + * @param {Array} emojis Optional emoji list to search. * @return {string} The emoji character, or the slug if not found. */ -export function getEmojiBySlug( slug ) { - return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.emoji ?? slug; +export function getEmojiBySlug( slug, emojis = REACTION_EMOJIS ) { + return emojis.find( ( r ) => r.value === slug )?.emoji ?? slug; } /** * Get the label for a given reaction slug. * - * @param {string} slug The reaction slug. + * @param {string} slug The reaction slug. + * @param {Array} emojis Optional emoji list to search. * @return {string} The label, or the slug if not found. */ -export function getLabelBySlug( slug ) { - return REACTION_EMOJIS.find( ( r ) => r.value === slug )?.label ?? slug; +export function getLabelBySlug( slug, emojis = REACTION_EMOJIS ) { + return emojis.find( ( r ) => r.value === slug )?.label ?? slug; } /** @@ -44,13 +97,15 @@ export function getLabelBySlug( slug ) { * @param {Function} props.onSelect Callback when an emoji is selected. */ export default function ReactionEmojiPicker( { onSelect } ) { + const emojis = useReactionEmojis(); + return ( - { REACTION_EMOJIS.map( ( { emoji, label, value } ) => ( + { emojis.map( ( { emoji, label, value } ) => ( array( 'heart' ), - 'celebration' => array( 'celebration' ), - 'smile' => array( 'smile' ), - 'eyes' => array( 'eyes' ), - 'rocket' => array( 'rocket' ), + $emojis = gutenberg_get_note_reaction_emojis(); + $data = array(); + foreach ( $emojis as $emoji ) { + $data[ $emoji['value'] ] = array( $emoji['value'] ); + } + return $data; + } + + public function test_schema_includes_reaction_emojis() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comments' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'reaction_emojis', $data['schema']['properties'] ); + + $reaction_emojis_schema = $data['schema']['properties']['reaction_emojis']; + $this->assertTrue( $reaction_emojis_schema['readonly'] ); + $this->assertSame( 'array', $reaction_emojis_schema['type'] ); + $this->assertContains( 'view', $reaction_emojis_schema['context'] ); + $this->assertContains( 'edit', $reaction_emojis_schema['context'] ); + + // Verify the default matches the helper function output. + $expected = gutenberg_get_note_reaction_emojis(); + $this->assertSame( $expected, $reaction_emojis_schema['default'] ); + } + + public function test_reaction_emojis_filter_modifies_schema() { + $custom_emoji = array( + array( + 'emoji' => '👍', + 'label' => 'Thumbs Up', + 'value' => 'thumbsup', + ), + ); + + $filter = function () use ( $custom_emoji ) { + return $custom_emoji; + }; + add_filter( 'gutenberg_note_reaction_emojis', $filter ); + + // Re-instantiate the controller so the schema picks up the filtered value. + $server = rest_get_server(); + $controller = new Gutenberg_REST_Comment_Controller_7_1(); + $controller->register_routes(); + + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comments' ); + $response = $server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( + $custom_emoji, + $data['schema']['properties']['reaction_emojis']['default'] + ); + + remove_filter( 'gutenberg_note_reaction_emojis', $filter ); + } + + public function test_reaction_emojis_filter_affects_validation() { + $custom_emoji = array( + array( + 'emoji' => '👍', + 'label' => 'Thumbs Up', + 'value' => 'thumbsup', + ), ); + + $filter = function () use ( $custom_emoji ) { + return $custom_emoji; + }; + add_filter( 'gutenberg_note_reaction_emojis', $filter ); + + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create(); + $note_id = $this->create_note( $post_id, self::$editor_id ); + + // The custom emoji should be accepted. + $params = array( + 'post' => $post_id, + 'type' => 'reaction', + 'parent' => $note_id, + 'content' => 'thumbsup', + '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() ); + + // A previously-default emoji should now be rejected. + $params['content'] = 'heart'; + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_comment_invalid_reaction', $response, 400 ); + + remove_filter( 'gutenberg_note_reaction_emojis', $filter ); } }