Skip to content

Commit 8fec4c9

Browse files
REST API: Expand reaction support in comments controller.
Align the reaction comment type handling with the latest Gutenberg notes reactions PR (WordPress/gutenberg#76767): - Accept emoji slugs as either a curated slug (heart, celebration, smile, eyes, rocket) or a lowercase hex-codepoint sequence (e.g. 1f44d for 👍 or 1f468-200d-1f4bb for 👨‍💻). Raw emoji bytes are rejected since the comments table is not guaranteed to be utf8mb4 across installs; clients normalize before submitting. - Scope the uniqueness check to active reactions only, so a user can re-add the same emoji after removing (trashing) it on the same note. - Point note's children link at reaction children, not at notes, so embedded children resolve to the reactions on the note. - Add a read-only reaction_emojis schema property exposing the allowed emoji list, so clients can discover accepted slugs via OPTIONS. - Add a reaction_summary field aggregating per-emoji counts on note responses, with reacted/my_reaction_id for the current user. - Pre-fetch reaction summaries in get_items() to avoid N+1 queries when listing many notes. See #63191.
1 parent f3d39d0 commit 8fec4c9

1 file changed

Lines changed: 237 additions & 26 deletions

File tree

src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php

Lines changed: 237 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)