Commit bc07280eee for wordpress.org
commit bc07280eee20dcb8351104993da036fb0869247c
Author: adamsilverstein <adamsilverstein@git.wordpress.org>
Date: Sun Oct 18 22:28:48 2026 +0000
Editor: Add emoji reactions ❤️ 🎉 😄 👀 🚀
Add a `reaction` comment type so users can react to a note with one of five emoji. Each reaction is stored as its own comment under the note, with `comment_content` holding the emoji's hex key rather than raw emoji bytes, since the comments table isn't guaranteed to be utf8mb4.
Reactions are created and deleted through the `/wp/v2/comments` endpoint using the note permission checks (`edit_post` on the note's post). They are always approved and authored by the current user, limited to one per emoji per user per note, and cannot be edited. Notes gain a read-only `reaction_summary` field, prefetched in at most two queries per page of notes.
Reactions are excluded from the admin comments list, comment counts, `get_lastcommentmodified()`, comment feeds and a note's `children` link, and are trashed and deleted along with their note. Restoring a trashed note does not yet restore its reactions, see https://github.com/WordPress/gutenberg/issues/84202.
This brings over the PHP changes from https://github.com/WordPress/gutenberg/pull/76767.
Props adamsilverstein, wildworks, westonruter, noruzzaman, mamaduka, swissspidy, joedolson, joen, jeffpaul, andraganescu, mciampini, fcoveram, scruffian, manzoorwanijk, annezazu, ellatrix, jorbin.
Fixes #64638.
Built from https://develop.svn.wordpress.org/trunk@64488
git-svn-id: http://core.svn.wordpress.org/trunk@63638 1a063a9b-81f0-0310-95a4-ce76da25c4cd
diff --git a/wp-admin/includes/class-wp-comments-list-table.php b/wp-admin/includes/class-wp-comments-list-table.php
index 7294f89d1b..1dff5d2675 100644
--- a/wp-admin/includes/class-wp-comments-list-table.php
+++ b/wp-admin/includes/class-wp-comments-list-table.php
@@ -107,7 +107,7 @@ class WP_Comments_List_Table extends WP_List_Table {
$comment_type = '';
- if ( ! empty( $_REQUEST['comment_type'] ) && 'note' !== $_REQUEST['comment_type'] ) {
+ if ( ! empty( $_REQUEST['comment_type'] ) && ! in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) {
$comment_type = $_REQUEST['comment_type'];
}
@@ -157,7 +157,7 @@ class WP_Comments_List_Table extends WP_List_Table {
'number' => $number,
'post_id' => $post_id,
'type' => $comment_type,
- 'type__not_in' => array( 'note' ),
+ 'type__not_in' => array( 'note', 'reaction' ),
'orderby' => $orderby,
'order' => $order,
'post_type' => $post_type,
diff --git a/wp-admin/includes/comment.php b/wp-admin/includes/comment.php
index f32bd91ad2..1cfc93144e 100644
--- a/wp-admin/includes/comment.php
+++ b/wp-admin/includes/comment.php
@@ -223,6 +223,7 @@ function get_comment_to_edit( $id ) {
*
* @since 2.3.0
* @since 6.9.0 Exclude the 'note' comment type from the count.
+ * @since 7.2.0 Exclude the 'reaction' comment type from the count.
*
* @global wpdb $wpdb WordPress database abstraction object.
*
@@ -242,7 +243,7 @@ function get_pending_comments_num( $post_id ) {
$post_id_array = array_map( 'intval', $post_id_array );
$post_id_in = "'" . implode( "', '", $post_id_array ) . "'";
- $pending = $wpdb->get_results( "SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM $wpdb->comments WHERE comment_post_ID IN ( $post_id_in ) AND comment_approved = '0' AND comment_type != 'note' GROUP BY comment_post_ID", ARRAY_A );
+ $pending = $wpdb->get_results( "SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM $wpdb->comments WHERE comment_post_ID IN ( $post_id_in ) AND comment_approved = '0' AND comment_type NOT IN ( 'note', 'reaction' ) GROUP BY comment_post_ID", ARRAY_A );
if ( $single ) {
if ( empty( $pending ) ) {
diff --git a/wp-includes/class-wp-comment-query.php b/wp-includes/class-wp-comment-query.php
index 8b5f1f276c..76367e43ee 100644
--- a/wp-includes/class-wp-comment-query.php
+++ b/wp-includes/class-wp-comment-query.php
@@ -549,6 +549,7 @@ class WP_Comment_Query {
*
* @since 4.4.0
* @since 6.9.0 Excludes the 'note' comment type, unless 'all' or the 'note' types are requested.
+ * @since 7.2.0 Excludes the 'reaction' comment type, unless 'all' or the 'reaction' types are requested.
*
* @global wpdb $wpdb WordPress database abstraction object.
*
@@ -784,13 +785,16 @@ class WP_Comment_Query {
'NOT IN' => (array) $this->query_vars['type__not_in'],
);
- // Exclude the 'note' comment type, unless 'all' types or the 'note' type explicitly are requested.
- if (
- ! in_array( 'all', $raw_types['IN'], true ) &&
- ! in_array( 'note', $raw_types['IN'], true ) &&
- ! in_array( 'note', $raw_types['NOT IN'], true )
- ) {
- $raw_types['NOT IN'][] = 'note';
+ // Exclude the 'note' and 'reaction' comment types, unless 'all' types or that type explicitly are requested.
+ if ( ! in_array( 'all', $raw_types['IN'], true ) ) {
+ foreach ( array( 'note', 'reaction' ) as $excluded_type ) {
+ if (
+ ! in_array( $excluded_type, $raw_types['IN'], true ) &&
+ ! in_array( $excluded_type, $raw_types['NOT IN'], true )
+ ) {
+ $raw_types['NOT IN'][] = $excluded_type;
+ }
+ }
}
$comment_types = array();
diff --git a/wp-includes/class-wp-query.php b/wp-includes/class-wp-query.php
index bd114c58af..01848af60e 100644
--- a/wp-includes/class-wp-query.php
+++ b/wp-includes/class-wp-query.php
@@ -2818,11 +2818,11 @@ class WP_Query {
if ( $this->is_comment_feed && ! $this->is_singular ) {
if ( $this->is_archive || $this->is_search ) {
$cjoin = "JOIN {$wpdb->posts} ON ( {$wpdb->comments}.comment_post_ID = {$wpdb->posts}.ID ) $join ";
- $cwhere = "WHERE comment_approved = '1' AND {$wpdb->comments}.comment_type != 'note' $where";
+ $cwhere = "WHERE comment_approved = '1' AND {$wpdb->comments}.comment_type NOT IN ( 'note', 'reaction' ) $where";
$cgroupby = "{$wpdb->comments}.comment_id";
} else { // Other non-singular, e.g. front.
$cjoin = "JOIN {$wpdb->posts} ON ( {$wpdb->comments}.comment_post_ID = {$wpdb->posts}.ID )";
- $cwhere = "WHERE ( post_status = 'publish' OR ( post_status = 'inherit' AND post_type = 'attachment' ) ) AND comment_approved = '1' AND {$wpdb->comments}.comment_type != 'note'";
+ $cwhere = "WHERE ( post_status = 'publish' OR ( post_status = 'inherit' AND post_type = 'attachment' ) ) AND comment_approved = '1' AND {$wpdb->comments}.comment_type NOT IN ( 'note', 'reaction' )";
$cgroupby = '';
}
@@ -3539,7 +3539,7 @@ class WP_Query {
$cjoin = apply_filters_ref_array( 'comment_feed_join', array( '', &$this ) );
/** This filter is documented in wp-includes/class-wp-query.php */
- $cwhere = apply_filters_ref_array( 'comment_feed_where', array( "WHERE comment_post_ID = '{$this->posts[0]->ID}' AND comment_approved = '1' AND {$wpdb->comments}.comment_type != 'note'", &$this ) );
+ $cwhere = apply_filters_ref_array( 'comment_feed_where', array( "WHERE comment_post_ID = '{$this->posts[0]->ID}' AND comment_approved = '1' AND {$wpdb->comments}.comment_type NOT IN ( 'note', 'reaction' )", &$this ) );
/** This filter is documented in wp-includes/class-wp-query.php */
$cgroupby = apply_filters_ref_array( 'comment_feed_groupby', array( '', &$this ) );
diff --git a/wp-includes/comment.php b/wp-includes/comment.php
index 95314e4947..c5d576e773 100644
--- a/wp-includes/comment.php
+++ b/wp-includes/comment.php
@@ -401,6 +401,7 @@ function get_default_comment_status( $post_type = 'post', $comment_type = 'comme
* @since 1.5.0
* @since 4.7.0 Replaced caching the modified date in a local static variable
* with the Object Cache API.
+ * @since 7.2.0 The 'note' and 'reaction' comment types are excluded from the query.
*
* @global wpdb $wpdb WordPress database abstraction object.
*
@@ -420,15 +421,15 @@ function get_lastcommentmodified( $timezone = 'server' ) {
switch ( $timezone ) {
case 'gmt':
- $comment_modified_date = $wpdb->get_var( "SELECT comment_date_gmt FROM $wpdb->comments WHERE comment_approved = '1' ORDER BY comment_date_gmt DESC LIMIT 1" );
+ $comment_modified_date = $wpdb->get_var( "SELECT comment_date_gmt FROM $wpdb->comments WHERE comment_approved = '1' AND comment_type NOT IN ( 'note', 'reaction' ) ORDER BY comment_date_gmt DESC LIMIT 1" );
break;
case 'blog':
- $comment_modified_date = $wpdb->get_var( "SELECT comment_date FROM $wpdb->comments WHERE comment_approved = '1' ORDER BY comment_date_gmt DESC LIMIT 1" );
+ $comment_modified_date = $wpdb->get_var( "SELECT comment_date FROM $wpdb->comments WHERE comment_approved = '1' AND comment_type NOT IN ( 'note', 'reaction' ) ORDER BY comment_date_gmt DESC LIMIT 1" );
break;
case 'server':
$add_seconds_server = gmdate( 'Z' );
- $comment_modified_date = $wpdb->get_var( $wpdb->prepare( "SELECT DATE_ADD(comment_date_gmt, INTERVAL %s SECOND) FROM $wpdb->comments WHERE comment_approved = '1' ORDER BY comment_date_gmt DESC LIMIT 1", $add_seconds_server ) );
+ $comment_modified_date = $wpdb->get_var( $wpdb->prepare( "SELECT DATE_ADD(comment_date_gmt, INTERVAL %s SECOND) FROM $wpdb->comments WHERE comment_approved = '1' AND comment_type NOT IN ( 'note', 'reaction' ) ORDER BY comment_date_gmt DESC LIMIT 1", $add_seconds_server ) );
break;
}
@@ -1571,6 +1572,8 @@ function wp_count_comments( $post_id = 0 ) {
* post ID available.
*
* @since 2.0.0
+ * @since 7.2.0 A note's reactions are deleted along with it, rather than
+ * being reparented.
*
* @global wpdb $wpdb WordPress database abstraction object.
*
@@ -1601,6 +1604,27 @@ function wp_delete_comment( $comment_id, $force_delete = false ) {
*/
do_action( 'delete_comment', $comment->comment_ID, $comment );
+ /*
+ * Delete a note's reactions rather than letting them be reparented below.
+ * A reaction only means anything attached to its note, and an orphaned one
+ * would keep the reactor's identity on a note that no longer exists. This
+ * covers every status: a reaction the user removed is trashed, not deleted.
+ */
+ if ( 'note' === $comment->comment_type ) {
+ $reaction_ids = get_comments(
+ array(
+ 'parent' => $comment->comment_ID,
+ 'type' => 'reaction',
+ 'status' => 'any',
+ 'fields' => 'ids',
+ )
+ );
+
+ foreach ( $reaction_ids as $reaction_id ) {
+ wp_delete_comment( $reaction_id, true );
+ }
+ }
+
// Move children up a level.
$children = $wpdb->get_col( $wpdb->prepare( "SELECT comment_ID FROM $wpdb->comments WHERE comment_parent = %d", $comment->comment_ID ) );
if ( ! empty( $children ) ) {
@@ -1651,6 +1675,7 @@ function wp_delete_comment( $comment_id, $force_delete = false ) {
*
* @since 2.9.0
* @since 6.9.0 Any child notes are deleted when deleting a note.
+ * @since 7.2.0 A note's reactions are trashed along with it.
*
* @param int|WP_Comment $comment_id Comment ID or WP_Comment object.
* @return bool True on success, false on failure.
@@ -1717,6 +1742,30 @@ function wp_trash_comment( $comment_id ) {
*/
do_action( 'trashed_comment', $comment->comment_ID, $comment );
+ /*
+ * Trash a note's reactions with it, at any depth. The child-note
+ * cascade below trashes each reply in turn, which brings the replies'
+ * own reactions along through this same branch.
+ *
+ * Restoring the note does not bring its reactions back:
+ * wp_untrash_comment() restores no children of any type, so restoring
+ * children is left to a cascade that covers every child type together.
+ */
+ if ( 'note' === $comment->comment_type ) {
+ $reaction_ids = get_comments(
+ array(
+ 'parent' => $comment->comment_ID,
+ 'type' => 'reaction',
+ 'status' => 'approve',
+ 'fields' => 'ids',
+ )
+ );
+
+ foreach ( $reaction_ids as $reaction_id ) {
+ wp_trash_comment( $reaction_id );
+ }
+ }
+
// For top level 'note' type comments, also trash children.
if ( 'note' === $comment->comment_type && 0 === (int) $comment->comment_parent ) {
$children = $comment->get_children(
@@ -3159,7 +3208,7 @@ function wp_update_comment_count_now( $post_id ) {
$new = apply_filters( 'pre_wp_update_comment_count_now', null, $old, $post_id );
if ( is_null( $new ) ) {
- $new = (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 ) );
+ $new = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type NOT IN ( 'note', 'reaction' )", $post_id ) );
} else {
$new = (int) $new;
}
diff --git a/wp-includes/link-template.php b/wp-includes/link-template.php
index a35203c65e..f6bd62e0e3 100644
--- a/wp-includes/link-template.php
+++ b/wp-includes/link-template.php
@@ -4378,10 +4378,12 @@ function is_avatar_comment_type( $comment_type ) {
* @since 3.0.0
*
* @since 6.9.0 The 'note' comment type was added.
+ * @since 7.2.0 The 'reaction' comment type was added.
*
- * @param array $types An array of content types. Default contains 'comment' and 'note'.
+ * @param array $types An array of content types. Default contains 'comment', 'note',
+ * and 'reaction'.
*/
- $allowed_comment_types = apply_filters( 'get_avatar_comment_types', array( 'comment', 'note' ) );
+ $allowed_comment_types = apply_filters( 'get_avatar_comment_types', array( 'comment', 'note', 'reaction' ) );
return in_array( $comment_type, (array) $allowed_comment_types, true );
}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php
index 277e2305bc..a10d29299d 100644
--- a/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php
@@ -24,6 +24,34 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
*/
protected $meta;
+ /**
+ * Pre-fetched reaction summaries keyed by note comment ID.
+ *
+ * Populated by get_items() to avoid N+1 queries when listing notes
+ * with their reaction summaries. Reset after each get_items() call.
+ *
+ * @since 7.2.0
+ * @var array<int, array<string, array{count: int, current_user_reaction: int}>>|null
+ */
+ 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 heart, celebration, smile, eyes and rocket.
+ *
+ * @phpstan-return non-empty-list<lowercase-string&non-falsy-string>
+ */
+ private static function get_note_reaction_keys(): array {
+ return array( '2764', '1f389', '1f604', '1f440', '1f680' );
+ }
+
/**
* Constructor.
*
@@ -123,7 +151,7 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
* @return true|WP_Error True if the request has read access, error object otherwise.
*/
public function get_items_permissions_check( $request ) {
- $is_note = 'note' === $request['type'];
+ $is_note = in_array( $request['type'], array( 'note', 'reaction' ), true );
$is_edit_context = 'edit' === $request['context'];
$protected_params = array( 'author', 'author_exclude', 'author_email', 'type', 'status' );
$forbidden_params = array();
@@ -329,6 +357,23 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
$comments = array();
if ( ! $is_head_request ) {
+ /*
+ * When listing notes that include the reaction_summary field,
+ * pre-fetch all summaries in a single aggregated query to
+ * avoid an N+1 query in prepare_item_for_response().
+ */
+ $fields = $this->get_fields_for_response( $request );
+ if (
+ ! empty( $request['type'] ) &&
+ 'note' === $request['type'] &&
+ rest_is_field_included( 'reaction_summary', $fields )
+ ) {
+ $note_ids = array_map( 'intval', wp_list_pluck( $query_result, 'comment_ID' ) );
+ if ( ! empty( $note_ids ) ) {
+ $this->prefetch_reaction_summaries( $note_ids );
+ }
+ }
+
foreach ( $query_result as $comment ) {
if ( ! $this->check_read_permission( $comment, $request ) ) {
continue;
@@ -337,6 +382,8 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
$data = $this->prepare_item_for_response( $comment, $request );
$comments[] = $this->prepare_response_for_collection( $data );
}
+
+ $this->reaction_summaries = null;
}
$total_comments = (int) $query->found_comments;
@@ -436,8 +483,8 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
return $comment;
}
- // Re-map edit context capabilities when requesting `note` type.
- $edit_cap = 'note' === $comment->comment_type ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' );
+ // Re-map edit context capabilities when requesting `note` or `reaction` type.
+ $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',
@@ -496,7 +543,7 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
* @return true|WP_Error True if the request has access to create items, error object otherwise.
*/
public function create_item_permissions_check( $request ) {
- $is_note = ! empty( $request['type'] ) && 'note' === $request['type'];
+ $is_note = ! empty( $request['type'] ) && in_array( $request['type'], array( 'note', 'reaction' ), true );
if ( ! is_user_logged_in() && $is_note ) {
return new WP_Error(
@@ -548,6 +595,23 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
);
}
+ /*
+ * A reaction is always first-person. create_item() enforces one emoji per
+ * user per note against the current user, and update_item() refuses to
+ * reattribute one, so a reaction stored against somebody else would be a
+ * row the uniqueness check and the reaction summary can never see.
+ */
+ 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.' ),
+ 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(
@@ -644,6 +708,7 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
* Creates a comment.
*
* @since 4.7.0
+ * @since 7.2.0 Added support for the `reaction` comment type.
*
* @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.
@@ -658,7 +723,7 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
}
// Do not allow comments to be created with a non-core type.
- if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note' ), 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.' ),
@@ -666,6 +731,154 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
);
}
+ /*
+ * 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 constraints.
+ if ( ! empty( $request['type'] ) && 'reaction' === $request['type'] ) {
+ // Reaction parent must be specified.
+ if ( empty( $request['parent'] ) ) {
+ return new WP_Error(
+ 'rest_comment_invalid_parent',
+ __( 'A reaction must have a parent note.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Reaction parent must exist and be a note.
+ $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.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ /*
+ * The parent note must belong to the post the reaction targets.
+ * create_item_permissions_check() requires `post` and checks that the
+ * user can edit it, so this runs before the parent's status checks:
+ * a note on another post gets the same error whatever its status,
+ * rather than revealing whether it is trashed, spammed or resolved.
+ */
+ 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.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // A reaction under a trashed or spammed 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.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ /*
+ * Resolving a thread approves its root note, and the editor disables
+ * reactions from then on. Hold requests from stale editor sessions
+ * to that too, for the root note and for every reply in its thread.
+ */
+ $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.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ /*
+ * 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.
+ *
+ * Read the content the same two ways prepare_item_for_database()
+ * does, so `content` and `content.raw` are both accepted.
+ */
+ $raw_content = '';
+ if ( isset( $request['content'] ) && is_string( $request['content'] ) ) {
+ $raw_content = $request['content'];
+ } elseif ( isset( $request['content']['raw'] ) && is_string( $request['content']['raw'] ) ) {
+ $raw_content = $request['content']['raw'];
+ }
+
+ $emoji_key = trim( wp_strip_all_tags( $raw_content ) );
+
+ if ( ! in_array( $emoji_key, self::get_note_reaction_keys(), true ) ) {
+ return new WP_Error(
+ 'rest_comment_invalid_reaction',
+ __( 'Invalid reaction emoji.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ /*
+ * A reaction is always approved. The uniqueness check, the race
+ * cleanup below and the reaction summary only see approved rows, so
+ * a reaction created in any other status could never be counted,
+ * deduplicated or, since reactions cannot be updated, fixed.
+ */
+ if ( isset( $request['status'] ) && ! in_array( $request['status'], array( 'approve', 'approved', '1' ), true ) ) {
+ return new WP_Error(
+ 'rest_comment_invalid_status',
+ __( 'A reaction cannot be created with that status.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ /*
+ * Enforce uniqueness: one emoji per user per note.
+ *
+ * Scope to active (approved) reactions only — trashed reactions
+ * are invisible to the user and must not block 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.' ),
+ array( 'status' => 409 )
+ );
+ }
+ }
+
+ $reaction_key = $emoji_key;
+ }
+
$prepared_comment = $this->prepare_item_for_database( $request );
if ( is_wp_error( $prepared_comment ) ) {
return $prepared_comment;
@@ -673,6 +886,15 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
$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. "<b>2764</b>" is stored as "2764").
+ */
+ if ( null !== $reaction_key ) {
+ $prepared_comment['comment_content'] = $reaction_key;
+ }
+
if ( ! isset( $prepared_comment['comment_content'] ) ) {
$prepared_comment['comment_content'] = '';
}
@@ -710,6 +932,20 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
$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'] ) ) {
@@ -744,9 +980,9 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
);
}
- // Don't check for duplicates or flooding for notes.
+ // Don't check for duplicates or flooding for notes or reactions.
$prepared_comment['comment_approved'] =
- 'note' === $prepared_comment['comment_type'] ?
+ in_array( $prepared_comment['comment_type'], array( 'note', 'reaction' ), true ) ?
'1' :
wp_allow_comment( $prepared_comment, true );
@@ -801,7 +1037,54 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
);
}
- if ( isset( $request['status'] ) ) {
+ /*
+ * 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;
+ }
+ }
+
+ /*
+ * Reactions are inserted approved and their status was validated above.
+ * Skipping them here also keeps a request from changing the status of a
+ * row that the race cleanup above may have handed it from another request.
+ */
+ if ( isset( $request['status'] ) && null === $reaction_key ) {
$this->handle_status_param( $request['status'], $comment_id );
}
@@ -864,6 +1147,7 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
*
* @since 4.7.0
* @since 7.1.1 Target post permissions are checked when a comment's parent post is changed.
+ * @since 7.2.0 Reactions cannot be updated.
*
* @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.
@@ -874,6 +1158,22 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
return $comment;
}
+ /*
+ * Reactions are immutable. create_item() validates the author, parent
+ * note, target post and canonical emoji hex key as a set, and none of that
+ * is re-checked here. Allowing an update 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. Removing a reaction is a delete.
+ */
+ 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.' ),
+ array( 'status' => 403 )
+ );
+ }
+
if ( ! $this->check_edit_permission( $comment ) ) {
return new WP_Error(
'rest_cannot_edit',
@@ -1234,6 +1534,20 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
$data['meta'] = $this->meta->get_value( $comment->comment_ID, $request );
}
+ if ( in_array( 'reaction_summary', $fields, true ) && 'note' === $comment->comment_type ) {
+ $note_id = (int) $comment->comment_ID;
+
+ if ( null !== $this->reaction_summaries && isset( $this->reaction_summaries[ $note_id ] ) ) {
+ $data['reaction_summary'] = $this->reaction_summaries[ $note_id ];
+ } else {
+ // Single-item path (get_item or single create/update): query individually.
+ $this->prefetch_reaction_summaries( array( $note_id ) );
+ $data['reaction_summary'] = $this->reaction_summaries[ $note_id ] ?? array();
+ // Reset so subsequent unrelated calls do not see this entry.
+ $this->reaction_summaries = null;
+ }
+ }
+
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
@@ -1315,12 +1629,17 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
);
}
- // Only grab one comment to verify the comment has children.
+ /*
+ * Only grab one comment to verify the comment has children. Reactions are
+ * left out: they are summarized in `reaction_summary`, and counting them
+ * would advertise a `children` link on a note that has no replies.
+ */
$comment_children = $comment->get_children(
array(
- 'count' => true,
- 'orderby' => 'none',
- 'type' => 'all',
+ 'count' => true,
+ 'orderby' => 'none',
+ 'type' => 'all',
+ 'type__not_in' => array( 'reaction' ),
)
);
@@ -1678,6 +1997,27 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
'readonly' => true,
'default' => 'comment',
),
+ 'reaction_summary' => array(
+ 'description' => __( 'Aggregated reaction counts for this note, keyed by emoji hex key.' ),
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ 'additionalProperties' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'count' => array(
+ 'description' => __( 'Total number of reactions with this emoji.' ),
+ 'type' => 'integer',
+ 'minimum' => 0,
+ ),
+ 'current_user_reaction' => array(
+ 'description' => __( "The current user's reaction comment ID for this emoji, or 0 if they have not reacted." ),
+ 'type' => 'integer',
+ 'minimum' => 0,
+ ),
+ ),
+ ),
+ ),
),
);
@@ -1968,6 +2308,82 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
return $result;
}
+ /**
+ * Pre-fetches reaction summaries for a set of note IDs.
+ *
+ * Runs two aggregated queries (one for the per-emoji counts, one for the
+ * current user's own reactions) and stores the result in
+ * $this->reaction_summaries, keyed by note comment ID. This lets a
+ * batched note listing return reaction_summary for many notes without
+ * issuing a per-note query.
+ *
+ * @since 7.2.0
+ *
+ * @global wpdb $wpdb WordPress database abstraction object.
+ *
+ * @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();
+
+ $parent_in = $wpdb->prepare(
+ 'comment_parent IN (' . implode( ',', array_fill( 0, count( $note_ids ), '%d' ) ) . ')',
+ ...$note_ids
+ );
+
+ // Query 1: Get aggregated counts per emoji per note.
+ $query = "SELECT comment_parent, comment_content, COUNT(*) AS reaction_count FROM $wpdb->comments WHERE comment_type = 'reaction' AND comment_approved = '1' AND ";
+ $query .= $parent_in;
+ $query .= ' GROUP BY comment_parent, comment_content';
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ $counts = $wpdb->get_results( $query );
+
+ // Query 2: Get the current user's own reaction IDs (if logged in).
+ $my_reactions = array();
+ if ( $current_user_id ) {
+ $query = "SELECT comment_ID, comment_parent, comment_content FROM $wpdb->comments WHERE comment_type = 'reaction' AND comment_approved = '1' AND ";
+ $query .= $parent_in;
+ $query .= $wpdb->prepare( ' AND user_id = %d', $current_user_id );
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ $user_rows = $wpdb->get_results( $query );
+
+ 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 every requested note ID.
+ 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;
+ $emoji_key = wp_strip_all_tags( $row->comment_content );
+ $key = $note_id . ':' . $emoji_key;
+
+ $this->reaction_summaries[ $note_id ][ $emoji_key ] = array(
+ 'count' => (int) $row->reaction_count,
+ 'current_user_reaction' => $my_reactions[ $key ] ?? 0,
+ );
+ }
+ }
+
/**
* Checks if the comment can be read.
*
@@ -1978,7 +2394,7 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
* @return bool Whether the comment can be read.
*/
protected function check_read_permission( $comment, $request ) {
- if ( 'note' !== $comment->comment_type && ! empty( $comment->comment_post_ID ) ) {
+ if ( ! in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) && ! empty( $comment->comment_post_ID ) ) {
$post = get_post( $comment->comment_post_ID );
if ( $post ) {
if ( $this->check_read_post_permission( $post, $request ) && 1 === (int) $comment->comment_approved ) {
diff --git a/wp-includes/version.php b/wp-includes/version.php
index 62c9236a53..3f4035e0fd 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
-$wp_version = '7.2-alpha-64487';
+$wp_version = '7.2-alpha-64488';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.