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.