Skip to content

Commit fe19243

Browse files
Editor: Add emoji reactions as a comment type for Notes.
Introduce the `reaction` comment type to support emoji reactions on collaborative Notes, replacing the previous `_wp_note_reactions` meta approach. Changes include: - Add `reaction` to avatar comment types. - Exclude reactions from admin comment lists and comment counts. - Extend the REST API Comments Controller to handle reactions: permissions checks, validation (valid emoji slugs, parent must be a note, one emoji per user per note), auto-approval, and content allowed checks. - Add PHPUnit tests for reaction creation, validation, and counting. - Regenerate API fixtures. Props flavor flavor. See #63191. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 7b3ea1a commit fe19243

8 files changed

Lines changed: 485 additions & 47 deletions

File tree

src/wp-admin/includes/class-wp-comments-list-table.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ public function prepare_items() {
105105

106106
$comment_type = '';
107107

108-
if ( ! empty( $_REQUEST['comment_type'] ) && 'note' !== $_REQUEST['comment_type'] ) {
108+
if ( ! empty( $_REQUEST['comment_type'] ) && ! in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) {
109109
$comment_type = $_REQUEST['comment_type'];
110110
}
111111

@@ -155,7 +155,7 @@ public function prepare_items() {
155155
'number' => $number,
156156
'post_id' => $post_id,
157157
'type' => $comment_type,
158-
'type__not_in' => array( 'note' ),
158+
'type__not_in' => array( 'note', 'reaction' ),
159159
'orderby' => $orderby,
160160
'order' => $order,
161161
'post_type' => $post_type,

src/wp-admin/includes/comment.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ function get_pending_comments_num( $post_id ) {
158158
$post_id_array = array_map( 'intval', $post_id_array );
159159
$post_id_in = "'" . implode( "', '", $post_id_array ) . "'";
160160

161-
$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 );
161+
$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' AND comment_type != 'reaction' GROUP BY comment_post_ID", ARRAY_A );
162162

163163
if ( $single ) {
164164
if ( empty( $pending ) ) {

src/wp-includes/comment.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2875,7 +2875,7 @@ function wp_update_comment_count_now( $post_id ) {
28752875
$new = apply_filters( 'pre_wp_update_comment_count_now', null, $old, $post_id );
28762876

28772877
if ( is_null( $new ) ) {
2878-
$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 ) );
2878+
$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' AND comment_type != 'reaction'", $post_id ) );
28792879
} else {
28802880
$new = (int) $new;
28812881
}

src/wp-includes/link-template.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4349,10 +4349,11 @@ function is_avatar_comment_type( $comment_type ) {
43494349
* @since 3.0.0
43504350
*
43514351
* @since 6.9.0 The 'note' comment type was added.
4352+
* @since 7.0.0 The 'reaction' comment type was added.
43524353
*
4353-
* @param array $types An array of content types. Default contains 'comment' and 'note'.
4354+
* @param array $types An array of content types. Default contains 'comment', 'note', and 'reaction'.
43544355
*/
4355-
$allowed_comment_types = apply_filters( 'get_avatar_comment_types', array( 'comment', 'note' ) );
4356+
$allowed_comment_types = apply_filters( 'get_avatar_comment_types', array( 'comment', 'note', 'reaction' ) );
43564357

43574358
return in_array( $comment_type, (array) $allowed_comment_types, true );
43584359
}

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

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ public function register_routes() {
123123
* @return true|WP_Error True if the request has read access, error object otherwise.
124124
*/
125125
public function get_items_permissions_check( $request ) {
126-
$is_note = 'note' === $request['type'];
126+
$is_note = in_array( $request['type'], array( 'note', 'reaction' ), true );
127127
$is_edit_context = 'edit' === $request['context'];
128128
$protected_params = array( 'author', 'author_exclude', 'author_email', 'type', 'status' );
129129
$forbidden_params = array();
@@ -437,8 +437,8 @@ public function get_item_permissions_check( $request ) {
437437
return $comment;
438438
}
439439

440-
// Re-map edit context capabilities when requesting `note` type.
441-
$edit_cap = 'note' === $comment->comment_type ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' );
440+
// Re-map edit context capabilities when requesting `note` or `reaction` type.
441+
$edit_cap = in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' );
442442
if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( ...$edit_cap ) ) {
443443
return new WP_Error(
444444
'rest_forbidden_context',
@@ -497,7 +497,7 @@ public function get_item( $request ) {
497497
* @return true|WP_Error True if the request has access to create items, error object otherwise.
498498
*/
499499
public function create_item_permissions_check( $request ) {
500-
$is_note = ! empty( $request['type'] ) && 'note' === $request['type'];
500+
$is_note = ! empty( $request['type'] ) && in_array( $request['type'], array( 'note', 'reaction' ), true );
501501

502502
if ( ! is_user_logged_in() && $is_note ) {
503503
return new WP_Error(
@@ -649,14 +649,65 @@ public function create_item( $request ) {
649649
}
650650

651651
// Do not allow comments to be created with a non-core type.
652-
if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note' ), true ) ) {
652+
if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note', 'reaction' ), true ) ) {
653653
return new WP_Error(
654654
'rest_invalid_comment_type',
655655
__( 'Cannot create a comment with that type.' ),
656656
array( 'status' => 400 )
657657
);
658658
}
659659

660+
// Validate reaction-specific constraints.
661+
if ( ! empty( $request['type'] ) && 'reaction' === $request['type'] ) {
662+
$valid_emojis = array( 'heart', 'celebration', 'smile', 'eyes', 'rocket' );
663+
664+
// Reaction content must be a valid emoji slug.
665+
if ( empty( $request['content'] ) || ! in_array( $request['content'], $valid_emojis, true ) ) {
666+
return new WP_Error(
667+
'rest_reaction_invalid_emoji',
668+
__( 'Reaction content must be a valid emoji slug.' ),
669+
array( 'status' => 400 )
670+
);
671+
}
672+
673+
// Reaction parent must exist and be a note.
674+
if ( empty( $request['parent'] ) ) {
675+
return new WP_Error(
676+
'rest_reaction_parent_required',
677+
__( 'Reactions must have a parent note.' ),
678+
array( 'status' => 400 )
679+
);
680+
}
681+
682+
$parent_comment = get_comment( $request['parent'] );
683+
if ( ! $parent_comment || 'note' !== $parent_comment->comment_type ) {
684+
return new WP_Error(
685+
'rest_reaction_invalid_parent',
686+
__( 'Reactions can only be added to notes.' ),
687+
array( 'status' => 400 )
688+
);
689+
}
690+
691+
// Enforce uniqueness: one emoji per user per note.
692+
$existing = get_comments(
693+
array(
694+
'comment_type' => 'reaction',
695+
'comment_parent' => $request['parent'],
696+
'user_id' => get_current_user_id(),
697+
'search' => $request['content'],
698+
'count' => true,
699+
)
700+
);
701+
702+
if ( $existing > 0 ) {
703+
return new WP_Error(
704+
'rest_reaction_duplicate',
705+
__( 'You have already added this reaction.' ),
706+
array( 'status' => 409 )
707+
);
708+
}
709+
}
710+
660711
$prepared_comment = $this->prepare_item_for_database( $request );
661712
if ( is_wp_error( $prepared_comment ) ) {
662713
return $prepared_comment;
@@ -735,9 +786,9 @@ public function create_item( $request ) {
735786
);
736787
}
737788

738-
// Don't check for duplicates or flooding for notes.
789+
// Don't check for duplicates or flooding for notes or reactions.
739790
$prepared_comment['comment_approved'] =
740-
'note' === $prepared_comment['comment_type'] ?
791+
in_array( $prepared_comment['comment_type'], array( 'note', 'reaction' ), true ) ?
741792
'1' :
742793
wp_allow_comment( $prepared_comment, true );
743794

@@ -1297,7 +1348,7 @@ protected function prepare_links( $comment ) {
12971348
}
12981349

12991350
// Embedding children for notes requires `type` and `status` inheritance.
1300-
if ( isset( $links['children'] ) && 'note' === $comment->comment_type ) {
1351+
if ( isset( $links['children'] ) && in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) ) {
13011352
$args = array(
13021353
'parent' => $comment->comment_ID,
13031354
'type' => $comment->comment_type,
@@ -1911,7 +1962,7 @@ protected function check_read_post_permission( $post, $request ) {
19111962
* @return bool Whether the comment can be read.
19121963
*/
19131964
protected function check_read_permission( $comment, $request ) {
1914-
if ( 'note' !== $comment->comment_type && ! empty( $comment->comment_post_ID ) ) {
1965+
if ( ! in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) && ! empty( $comment->comment_post_ID ) ) {
19151966
$post = get_post( $comment->comment_post_ID );
19161967
if ( $post ) {
19171968
if ( $this->check_read_post_permission( $post, $request ) && 1 === (int) $comment->comment_approved ) {
@@ -2026,6 +2077,11 @@ protected function check_is_comment_content_allowed( $prepared_comment ) {
20262077
return true;
20272078
}
20282079

2080+
// Reactions always have content (the emoji slug), so allow them.
2081+
if ( isset( $check['comment_type'] ) && 'reaction' === $check['comment_type'] ) {
2082+
return true;
2083+
}
2084+
20292085
/*
20302086
* Do not allow a comment to be created with missing or empty
20312087
* comment_content. See wp_handle_comment_submission().

tests/phpunit/tests/comment/wpUpdateCommentCountNow.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,20 @@ public function test_only_approved_regular_comments_are_counted() {
7878
'comment_approved' => 1,
7979
)
8080
);
81+
self::factory()->comment->create(
82+
array(
83+
'comment_post_ID' => $post_id,
84+
'comment_type' => 'reaction',
85+
'comment_approved' => 0,
86+
)
87+
);
88+
self::factory()->comment->create(
89+
array(
90+
'comment_post_ID' => $post_id,
91+
'comment_type' => 'reaction',
92+
'comment_approved' => 1,
93+
)
94+
);
8195

8296
$this->assertTrue( wp_update_comment_count_now( $post_id ) );
8397
$this->assertSame( '1', get_comments_number( $post_id ) );

0 commit comments

Comments
 (0)