@@ -24,6 +24,17 @@ class WP_REST_Comments_Controller extends WP_REST_Controller {
2424 */
2525 protected $ meta ;
2626
27+ /**
28+ * Pre-fetched reaction summaries keyed by note comment ID.
29+ *
30+ * Populated by get_items() to avoid N+1 queries when listing notes
31+ * with their reaction summaries. Reset after each get_items() call.
32+ *
33+ * @since 7.0.0
34+ * @var array|null
35+ */
36+ protected $ reaction_summaries = null ;
37+
2738 /**
2839 * Constructor.
2940 *
@@ -330,6 +341,28 @@ public function get_items( $request ) {
330341 if ( ! $ is_head_request ) {
331342 $ comments = array ();
332343
344+ /*
345+ * When listing notes that include the reaction_summary field,
346+ * pre-fetch all summaries in a single aggregated query to
347+ * avoid an N+1 query in prepare_item_for_response().
348+ */
349+ $ fields = $ this ->get_fields_for_response ( $ request );
350+ if (
351+ ! empty ( $ request ['type ' ] ) &&
352+ 'note ' === $ request ['type ' ] &&
353+ rest_is_field_included ( 'reaction_summary ' , $ fields )
354+ ) {
355+ $ note_ids = array ();
356+ foreach ( $ query_result as $ comment ) {
357+ if ( 'note ' === $ comment ->comment_type ) {
358+ $ note_ids [] = (int ) $ comment ->comment_ID ;
359+ }
360+ }
361+ if ( ! empty ( $ note_ids ) ) {
362+ $ this ->prefetch_reaction_summaries ( $ note_ids );
363+ }
364+ }
365+
333366 foreach ( $ query_result as $ comment ) {
334367 if ( ! $ this ->check_read_permission ( $ comment , $ request ) ) {
335368 continue ;
@@ -338,6 +371,8 @@ public function get_items( $request ) {
338371 $ data = $ this ->prepare_item_for_response ( $ comment , $ request );
339372 $ comments [] = $ this ->prepare_response_for_collection ( $ data );
340373 }
374+
375+ $ this ->reaction_summaries = null ;
341376 }
342377
343378 $ total_comments = (int ) $ query ->found_comments ;
@@ -667,18 +702,7 @@ public function create_item( $request ) {
667702
668703 // Validate reaction-specific constraints.
669704 if ( ! empty ( $ request ['type ' ] ) && 'reaction ' === $ request ['type ' ] ) {
670- $ valid_emojis = array ( 'heart ' , 'celebration ' , 'smile ' , 'eyes ' , 'rocket ' );
671-
672- // Reaction content must be a valid emoji slug.
673- if ( empty ( $ request ['content ' ] ) || ! in_array ( $ request ['content ' ], $ valid_emojis , true ) ) {
674- return new WP_Error (
675- 'rest_reaction_invalid_emoji ' ,
676- __ ( 'Reaction content must be a valid emoji slug. ' ),
677- array ( 'status ' => 400 )
678- );
679- }
680-
681- // Reaction parent must exist and be a note.
705+ // Reaction parent must be specified.
682706 if ( empty ( $ request ['parent ' ] ) ) {
683707 return new WP_Error (
684708 'rest_reaction_parent_required ' ,
@@ -687,6 +711,7 @@ public function create_item( $request ) {
687711 );
688712 }
689713
714+ // Reaction parent must exist and be a note.
690715 $ parent_comment = get_comment ( $ request ['parent ' ] );
691716 if ( ! $ parent_comment || 'note ' !== $ parent_comment ->comment_type ) {
692717 return new WP_Error (
@@ -696,23 +721,57 @@ public function create_item( $request ) {
696721 );
697722 }
698723
699- // Enforce uniqueness: one emoji per user per note.
724+ /*
725+ * Validate the reaction content. Two shapes are accepted:
726+ *
727+ * - A curated slug (e.g. `heart`) from wp_get_note_reaction_emojis().
728+ * - A lowercase hex-codepoint sequence joined by `-` (e.g. `1f44d`
729+ * for 👍 or `1f468-200d-1f4bb` for 👨💻).
730+ *
731+ * Raw emoji bytes are rejected because the comments table is not
732+ * guaranteed to be utf8mb4 across all WordPress installs; clients
733+ * are expected to normalize before submitting. Variation selector
734+ * U+FE0F is dropped on the client so visually-equivalent
735+ * presentations collapse onto a single key.
736+ */
737+ $ valid_slugs = wp_list_pluck ( wp_get_note_reaction_emojis (), 'value ' );
738+ $ emoji_slug = isset ( $ request ['content ' ] ) ? wp_strip_all_tags ( $ request ['content ' ] ) : '' ;
739+
740+ $ is_curated_slug = in_array ( $ emoji_slug , $ valid_slugs , true );
741+ $ is_hex_key = (bool ) preg_match ( '/^[0-9a-f]{2,6}(-[0-9a-f]{2,6}){0,15}$/ ' , $ emoji_slug );
742+
743+ if ( '' === $ emoji_slug || ( ! $ is_curated_slug && ! $ is_hex_key ) ) {
744+ return new WP_Error (
745+ 'rest_reaction_invalid_emoji ' ,
746+ __ ( 'Reaction content must be a valid emoji slug. ' ),
747+ array ( 'status ' => 400 )
748+ );
749+ }
750+
751+ /*
752+ * Enforce uniqueness: one emoji per user per note.
753+ *
754+ * Scope to active (approved) reactions only — trashed reactions
755+ * are invisible to the user and must not block re-adding the
756+ * same emoji.
757+ */
700758 $ existing = get_comments (
701759 array (
702- 'comment_type ' => 'reaction ' ,
703- 'comment_parent ' => $ request ['parent ' ],
704- 'user_id ' => get_current_user_id (),
705- 'search ' => $ request ['content ' ],
706- 'count ' => true ,
760+ 'parent ' => $ request ['parent ' ],
761+ 'user_id ' => get_current_user_id (),
762+ 'type ' => 'reaction ' ,
763+ 'status ' => 'approve ' ,
707764 )
708765 );
709766
710- if ( $ existing > 0 ) {
711- return new WP_Error (
712- 'rest_reaction_duplicate ' ,
713- __ ( 'You have already added this reaction. ' ),
714- array ( 'status ' => 409 )
715- );
767+ foreach ( $ existing as $ existing_reaction ) {
768+ if ( wp_strip_all_tags ( $ existing_reaction ->comment_content ) === $ emoji_slug ) {
769+ return new WP_Error (
770+ 'rest_reaction_duplicate ' ,
771+ __ ( 'You have already added this reaction. ' ),
772+ array ( 'status ' => 409 )
773+ );
774+ }
716775 }
717776 }
718777
@@ -1263,6 +1322,20 @@ public function prepare_item_for_response( $item, $request ) {
12631322 $ data ['meta ' ] = $ this ->meta ->get_value ( $ comment ->comment_ID , $ request );
12641323 }
12651324
1325+ if ( in_array ( 'reaction_summary ' , $ fields , true ) && 'note ' === $ comment ->comment_type ) {
1326+ $ note_id = (int ) $ comment ->comment_ID ;
1327+
1328+ if ( null !== $ this ->reaction_summaries && isset ( $ this ->reaction_summaries [ $ note_id ] ) ) {
1329+ $ data ['reaction_summary ' ] = $ this ->reaction_summaries [ $ note_id ];
1330+ } else {
1331+ // Single-item path (get_item or single create/update): query individually.
1332+ $ this ->prefetch_reaction_summaries ( array ( $ note_id ) );
1333+ $ data ['reaction_summary ' ] = $ this ->reaction_summaries [ $ note_id ] ?? array ();
1334+ // Reset so subsequent unrelated calls do not see this entry.
1335+ $ this ->reaction_summaries = null ;
1336+ }
1337+ }
1338+
12661339 $ context = ! empty ( $ request ['context ' ] ) ? $ request ['context ' ] : 'view ' ;
12671340 $ data = $ this ->add_additional_fields_to_object ( $ data , $ request );
12681341 $ data = $ this ->filter_response_by_context ( $ data , $ context );
@@ -1357,9 +1430,11 @@ protected function prepare_links( $comment ) {
13571430
13581431 // Embedding children for notes requires `type` and `status` inheritance.
13591432 if ( isset ( $ links ['children ' ] ) && in_array ( $ comment ->comment_type , wp_get_internal_comment_types (), true ) ) {
1360- $ args = array (
1433+ // Notes have reaction children; reactions don't have children of their own.
1434+ $ child_type = 'note ' === $ comment ->comment_type ? 'reaction ' : $ comment ->comment_type ;
1435+ $ args = array (
13611436 'parent ' => $ comment ->comment_ID ,
1362- 'type ' => $ comment -> comment_type ,
1437+ 'type ' => $ child_type ,
13631438 'status ' => 'all ' ,
13641439 );
13651440
@@ -1670,6 +1745,53 @@ public function get_item_schema() {
16701745 'readonly ' => true ,
16711746 'default ' => 'comment ' ,
16721747 ),
1748+ 'reaction_emojis ' => array (
1749+ 'description ' => __ ( 'Allowed emoji reactions for notes. ' ),
1750+ 'type ' => 'array ' ,
1751+ 'context ' => array ( 'view ' , 'edit ' ),
1752+ 'readonly ' => true ,
1753+ 'items ' => array (
1754+ 'type ' => 'object ' ,
1755+ 'properties ' => array (
1756+ 'emoji ' => array (
1757+ 'description ' => __ ( 'The emoji character. ' ),
1758+ 'type ' => 'string ' ,
1759+ ),
1760+ 'label ' => array (
1761+ 'description ' => __ ( 'A human-readable label for the emoji. ' ),
1762+ 'type ' => 'string ' ,
1763+ ),
1764+ 'value ' => array (
1765+ 'description ' => __ ( 'The slug used as the storage key. ' ),
1766+ 'type ' => 'string ' ,
1767+ ),
1768+ ),
1769+ ),
1770+ 'default ' => wp_get_note_reaction_emojis (),
1771+ ),
1772+ 'reaction_summary ' => array (
1773+ 'description ' => __ ( 'Aggregated reaction counts for this note, keyed by emoji slug. ' ),
1774+ 'type ' => 'object ' ,
1775+ 'context ' => array ( 'view ' , 'edit ' ),
1776+ 'readonly ' => true ,
1777+ 'additionalProperties ' => array (
1778+ 'type ' => 'object ' ,
1779+ 'properties ' => array (
1780+ 'count ' => array (
1781+ 'description ' => __ ( 'Total number of reactions with this emoji. ' ),
1782+ 'type ' => 'integer ' ,
1783+ ),
1784+ 'reacted ' => array (
1785+ 'description ' => __ ( 'Whether the current user reacted with this emoji. ' ),
1786+ 'type ' => 'boolean ' ,
1787+ ),
1788+ 'my_reaction_id ' => array (
1789+ 'description ' => __ ( "The current user's reaction comment ID, or 0 if not reacted. " ),
1790+ 'type ' => 'integer ' ,
1791+ ),
1792+ ),
1793+ ),
1794+ ),
16731795 ),
16741796 );
16751797
@@ -1960,6 +2082,95 @@ protected function check_read_post_permission( $post, $request ) {
19602082 return $ result ;
19612083 }
19622084
2085+ /**
2086+ * Pre-fetches reaction summaries for a set of note IDs.
2087+ *
2088+ * Runs two aggregated queries (one for the per-emoji counts, one for the
2089+ * current user's own reactions) and stores the result in
2090+ * $this->reaction_summaries, keyed by note comment ID. This lets a
2091+ * batched note listing return reaction_summary for many notes without
2092+ * issuing a per-note query.
2093+ *
2094+ * @since 7.0.0
2095+ *
2096+ * @global wpdb $wpdb WordPress database abstraction object.
2097+ *
2098+ * @param int[] $note_ids Array of note comment IDs.
2099+ */
2100+ protected function prefetch_reaction_summaries ( $ note_ids ) {
2101+ global $ wpdb ;
2102+
2103+ $ this ->reaction_summaries = array ();
2104+
2105+ if ( empty ( $ note_ids ) ) {
2106+ return ;
2107+ }
2108+
2109+ $ note_ids = array_map ( 'intval ' , $ note_ids );
2110+ $ current_user_id = get_current_user_id ();
2111+ $ id_placeholders = implode ( ', ' , array_fill ( 0 , count ( $ note_ids ), '%d ' ) );
2112+
2113+ // Query 1: aggregated counts per emoji per note.
2114+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
2115+ $ counts = $ wpdb ->get_results (
2116+ $ wpdb ->prepare (
2117+ "SELECT comment_parent, comment_content, COUNT(*) AS reaction_count
2118+ FROM {$ wpdb ->comments }
2119+ WHERE comment_parent IN ( $ id_placeholders )
2120+ AND comment_type = %s
2121+ AND comment_approved = %s
2122+ GROUP BY comment_parent, comment_content " ,
2123+ ...array_merge ( $ note_ids , array ( 'reaction ' , '1 ' ) )
2124+ )
2125+ );
2126+
2127+ // Query 2: the current user's own reaction IDs (only when logged in).
2128+ $ my_reactions = array ();
2129+ if ( $ current_user_id ) {
2130+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
2131+ $ user_rows = $ wpdb ->get_results (
2132+ $ wpdb ->prepare (
2133+ "SELECT comment_ID, comment_parent, comment_content
2134+ FROM {$ wpdb ->comments }
2135+ WHERE comment_parent IN ( $ id_placeholders )
2136+ AND comment_type = %s
2137+ AND comment_approved = %s
2138+ AND user_id = %d " ,
2139+ ...array_merge ( $ note_ids , array ( 'reaction ' , '1 ' , $ current_user_id ) )
2140+ )
2141+ );
2142+
2143+ if ( $ user_rows ) {
2144+ foreach ( $ user_rows as $ row ) {
2145+ $ key = (int ) $ row ->comment_parent . ': ' . wp_strip_all_tags ( $ row ->comment_content );
2146+ $ my_reactions [ $ key ] = (int ) $ row ->comment_ID ;
2147+ }
2148+ }
2149+ }
2150+
2151+ // Initialize empty summaries for every requested note ID.
2152+ foreach ( $ note_ids as $ note_id ) {
2153+ $ this ->reaction_summaries [ $ note_id ] = array ();
2154+ }
2155+
2156+ if ( ! $ counts ) {
2157+ return ;
2158+ }
2159+
2160+ foreach ( $ counts as $ row ) {
2161+ $ note_id = (int ) $ row ->comment_parent ;
2162+ $ slug = wp_strip_all_tags ( $ row ->comment_content );
2163+ $ key = $ note_id . ': ' . $ slug ;
2164+ $ my_reaction_id = $ my_reactions [ $ key ] ?? 0 ;
2165+
2166+ $ this ->reaction_summaries [ $ note_id ][ $ slug ] = array (
2167+ 'count ' => (int ) $ row ->reaction_count ,
2168+ 'reacted ' => $ my_reaction_id > 0 ,
2169+ 'my_reaction_id ' => $ my_reaction_id ,
2170+ );
2171+ }
2172+ }
2173+
19632174 /**
19642175 * Checks if the comment can be read.
19652176 *
0 commit comments