Skip to content
Closed
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
e08eea2
Update comment REST API
t-hamano Oct 15, 2025
c00b9e1
Add post type support
t-hamano Oct 15, 2025
c1aebc0
Register metadata
t-hamano Oct 15, 2025
495504c
Update avatar comment type
t-hamano Oct 15, 2025
f5207cf
Allow duplicate notes for resolution purposes
t-hamano Oct 15, 2025
954f534
Exclude notes from admin comments query
t-hamano Oct 15, 2025
7d1e549
Exclude note type comment from comment count query
t-hamano Oct 15, 2025
3e4347b
Extend post_type_supports function to check sub-features
t-hamano Oct 15, 2025
7276884
Remove unnecessary check
t-hamano Oct 15, 2025
f74effd
Change how sub-features are checked
t-hamano Oct 15, 2025
13defce
Fix WP_Test_REST_Comments_Controller Unit test
t-hamano Oct 15, 2025
3cf787a
Revert changes to the post_type_supports function
t-hamano Oct 15, 2025
b351ddb
Add unit test for notes
t-hamano Oct 15, 2025
ce0f128
Minor refactoring and fix lint error
t-hamano Oct 15, 2025
df8cb5d
Regenerate API fixtures
t-hamano Oct 15, 2025
4833d19
Try to fix JS API Tests
t-hamano Oct 15, 2025
35375c8
"block comments" to "notes"
t-hamano Oct 15, 2025
4ab0abf
Try to fix JS API Tests (Take 2)
t-hamano Oct 15, 2025
b0bcc8c
Revert changes for JS API Tests
t-hamano Oct 15, 2025
891d9d2
Add @ticket docblock
t-hamano Oct 15, 2025
6caa20b
Revert unnecessary changes for post_type_support
t-hamano Oct 15, 2025
1f3b1d8
Move "where" clauses into WP_Comments_List_Table
t-hamano Oct 15, 2025
10e1023
Revert wp-api.js and wp-api-generated.js to trunk versions
t-hamano Oct 15, 2025
ae2ba63
Don't change 'type' schema
t-hamano Oct 15, 2025
6ada59c
check_post_type_supports_notes: invert default return value
t-hamano Oct 15, 2025
571ede1
entirely revert wp-api-generated.js
t-hamano Oct 15, 2025
c0d81db
Remove unnecessary textdomain
t-hamano Oct 15, 2025
a208f84
Delete unnecessary empty line
t-hamano Oct 16, 2025
a21c51f
Simplify note support check
t-hamano Oct 16, 2025
992a3bd
Removed redundant empty check
t-hamano Oct 16, 2025
f0ee469
Define default value ​​for comment type as a schema
t-hamano Oct 16, 2025
48cb687
Updated function name for registering comment meta
t-hamano Oct 16, 2025
278d274
Remove redundant check from comment meta registration
t-hamano Oct 16, 2025
a43a2b0
Make filterable
t-hamano Oct 16, 2025
a643878
Don't count Note comments in the dashboard UI
t-hamano Oct 16, 2025
6eb536a
Merge branch 'trunk' into 64096-notes-backport
t-hamano Oct 17, 2025
4edfc8d
Update PHPDoc comment
t-hamano Oct 18, 2025
2ad2332
Merge branch 'trunk' into 64096-notes-backport
adamsilverstein Oct 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/wp-admin/includes/comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,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' 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 != 'note' GROUP BY comment_post_ID", ARRAY_A );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's another PR for get_pending_comments_num changes - #9869.

Just noting for props.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fan of hardcoding comment types. Long term we should think about using a filterable list of comment types to exclude or using (an improved) WP_Comment_Query

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

100%. I think a long-term solution is to provide proper support for custom comment types, so we can avoid hardcoded patches like we've introduced with the current implementation.


if ( $single ) {
if ( empty( $pending ) ) {
Expand Down
10 changes: 10 additions & 0 deletions src/wp-includes/class-wp-comment-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,16 @@ protected function get_comment_ids() {
*/
$clauses = apply_filters_ref_array( 'comments_clauses', array( compact( $pieces ), &$this ) );

// Exclude notes from admin comment queries.
if ( is_admin() && isset( $this->query_vars['type'] ) && '' === $this->query_vars['type'] ) {
global $wpdb;
if ( isset( $clauses['where'] ) && ! empty( $clauses['where'] ) ) {
$clauses['where'] .= " AND {$wpdb->comments}.comment_type != 'note'";
} else {
$clauses['where'] = "{$wpdb->comments}.comment_type != 'note'";
}
}

$fields = isset( $clauses['fields'] ) ? $clauses['fields'] : '';
$join = isset( $clauses['join'] ) ? $clauses['join'] : '';
$where = isset( $clauses['where'] ) ? $clauses['where'] : '';
Expand Down
32 changes: 32 additions & 0 deletions src/wp-includes/comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,11 @@ function wp_allow_comment( $commentdata, $wp_error = false ) {
*/
$dupe_id = apply_filters( 'duplicate_comment_id', $dupe_id, $commentdata );

// Allow duplicate notes for resolution purposes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this should happen after the apply_filters call. Seems like we'd want to let developers customize behavior here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, I'd prefer we keep this filterable.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a43a2b0

if ( isset( $commentdata['comment_type'] ) && 'note' === $commentdata['comment_type'] ) {
$dupe_id = false;
}

if ( $dupe_id ) {
/**
* Fires immediately after a duplicate comment is detected.
Expand Down Expand Up @@ -4103,3 +4108,30 @@ function _wp_check_for_scheduled_update_comment_type() {
wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'wp_update_comment_type_batch' );
}
}

/**
* Register notes metadata for notes status.
*
* @since 6.9.0
*/
function wp_register_notes_metadata() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd model this closer to wp_create_initial_post_meta, so wp_create_initial_comment_meta. Future additions would be added into that one function, instead of splitting each registration into it's own function.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 48cb687

register_meta(
'comment',
'_wp_note_status',
array(
'type' => 'string',
'description' => __( 'Note resolution status' ),
'single' => true,
'show_in_rest' => array(
'schema' => array(
'type' => 'string',
'enum' => array( 'resolved', 'reopen' ),
),
),
'auth_callback' => function ( $allowed, $meta_key, $object_id ) {
return current_user_can( 'edit_comment', $object_id );
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already the default behavior applied by map_meta_cap, I believe this is just resulting in a duplicate check,

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 278d274

)
);
}
add_action( 'init', 'wp_register_notes_metadata' );
6 changes: 4 additions & 2 deletions src/wp-includes/link-template.php
Original file line number Diff line number Diff line change
Expand Up @@ -4343,9 +4343,11 @@ function is_avatar_comment_type( $comment_type ) {
*
* @since 3.0.0
*
* @param array $types An array of content types. Default only contains 'comment'.
* @since 6.9.0 The 'note' comment type was added.
*
* @param array $types An array of content types. Default contains 'comment' and 'note'.
*/
$allowed_comment_types = apply_filters( 'get_avatar_comment_types', array( 'comment' ) );
$allowed_comment_types = apply_filters( 'get_avatar_comment_types', array( 'comment', 'note' ) );

return in_array( $comment_type, (array) $allowed_comment_types, true );
}
Expand Down
28 changes: 25 additions & 3 deletions src/wp-includes/post.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,18 @@ function create_initial_post_types() {
'rewrite' => false,
'query_var' => false,
'delete_with_user' => true,
'supports' => array( 'title', 'editor', 'author', 'thumbnail', 'excerpt', 'trackbacks', 'custom-fields', 'comments', 'revisions', 'post-formats' ),
'supports' => array(
'title',
'editor' => array( 'notes' => true ),
'author',
'thumbnail',
'excerpt',
'trackbacks',
'custom-fields',
'comments',
'revisions',
'post-formats',
),
'show_in_rest' => true,
'rest_base' => 'posts',
'rest_controller_class' => 'WP_REST_Posts_Controller',
Expand All @@ -62,7 +73,16 @@ function create_initial_post_types() {
'rewrite' => false,
'query_var' => false,
'delete_with_user' => true,
'supports' => array( 'title', 'editor', 'author', 'thumbnail', 'page-attributes', 'custom-fields', 'comments', 'revisions' ),
'supports' => array(
'title',
'editor' => array( 'notes' => true ),
'author',
'thumbnail',
'page-attributes',
'custom-fields',
'comments',
'revisions',
),
'show_in_rest' => true,
'rest_base' => 'pages',
'rest_controller_class' => 'WP_REST_Posts_Controller',
Expand Down Expand Up @@ -2317,6 +2337,9 @@ function get_all_post_type_supports( $post_type ) {
* Checks a post type's support for a given feature.
*
* @since 3.0.0
* @since 6.9.0 Added support for sub-features.
* To check a sub-feature, pass a slash-delimited feature string
* like 'editor/notes'.
*
* @global array $_wp_post_type_features
*
Expand All @@ -2329,7 +2352,6 @@ function post_type_supports( $post_type, $feature ) {

return ( isset( $_wp_post_type_features[ $post_type ][ $feature ] ) );
}

/**
* Retrieves a list of post type names that support a specific feature.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,21 @@ public function register_routes() {
* @return true|WP_Error True if the request has read access, error object otherwise.
*/
public function get_items_permissions_check( $request ) {
$is_note = ! empty( $request['type'] ) && 'note' === $request['type'];
$is_edit_context = ! empty( $request['context'] ) && 'edit' === $request['context'];

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need the ! empty() part. The request is a WP_REST_Request object, it won't error if the key does not exist.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 992a3bd

if ( ! empty( $request['post'] ) ) {
foreach ( (array) $request['post'] as $post_id ) {
$post = get_post( $post_id );

if ( $post && $is_note && ! $this->check_post_type_supports_notes( $post->post_type ) ) {
return new WP_Error(
'rest_comment_not_supported_post_type',
__( 'Sorry, this post type does not support notes.' ),
array( 'status' => 403 )
);
}

if ( ! empty( $post_id ) && $post && ! $this->check_read_post_permission( $post, $request ) ) {
return new WP_Error(
'rest_cannot_read_post',
Expand All @@ -144,7 +154,18 @@ public function get_items_permissions_check( $request ) {
}
}

if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( 'moderate_comments' ) ) {
// Re-map edit context capabilities when requesting `note` for a post.
if ( $is_edit_context && $is_note && ! empty( $request['post'] ) ) {
foreach ( (array) $request['post'] as $post_id ) {
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'rest_forbidden_context',
__( 'Sorry, you are not allowed to edit comments.' ),
array( 'status' => rest_authorization_required_code() )
);
}
}
} elseif ( $is_edit_context && ! current_user_can( 'moderate_comments' ) ) {
return new WP_Error(
'rest_forbidden_context',
__( 'Sorry, you are not allowed to edit comments.' ),
Expand Down Expand Up @@ -394,7 +415,9 @@ public function get_item_permissions_check( $request ) {
return $comment;
}

if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( 'moderate_comments' ) ) {
// Re-map edit context capabilities when requesting `note` type.
$edit_cap = 'note' === $comment->comment_type ? 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',
__( 'Sorry, you are not allowed to edit comments.' ),
Expand Down Expand Up @@ -452,6 +475,16 @@ public function get_item( $request ) {
* @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'];

if ( ! is_user_logged_in() && $is_note ) {
return new WP_Error(
'rest_comment_login_required',
__( 'Sorry, you must be logged in to comment.' ),
array( 'status' => 401 )
);
}

if ( ! is_user_logged_in() ) {
if ( get_option( 'comment_registration' ) ) {
return new WP_Error(
Expand Down Expand Up @@ -505,7 +538,8 @@ public function create_item_permissions_check( $request ) {
}
}

if ( isset( $request['status'] ) && ! current_user_can( 'moderate_comments' ) ) {
$edit_cap = $is_note ? array( 'edit_post', (int) $request['post'] ) : array( 'moderate_comments' );
if ( isset( $request['status'] ) && ! current_user_can( ...$edit_cap ) ) {
return new WP_Error(
'rest_comment_invalid_status',
/* translators: %s: Request parameter. */
Expand All @@ -532,7 +566,15 @@ public function create_item_permissions_check( $request ) {
);
}

if ( 'draft' === $post->post_status ) {
if ( $is_note && ! $this->check_post_type_supports_notes( $post->post_type ) ) {
return new WP_Error(
'rest_comment_not_supported_post_type',
__( 'Sorry, this post type does not support notes.' ),
array( 'status' => 403 )
);
}

if ( 'draft' === $post->post_status && ! $is_note ) {
return new WP_Error(
'rest_comment_draft_post',
__( 'Sorry, you are not allowed to create a comment on this post.' ),
Expand All @@ -556,7 +598,7 @@ public function create_item_permissions_check( $request ) {
);
}

if ( ! comments_open( $post->ID ) ) {
if ( ! comments_open( $post->ID ) && ! $is_note ) {
return new WP_Error(
'rest_comment_closed',
__( 'Sorry, comments are closed for this item.' ),
Expand Down Expand Up @@ -584,26 +626,22 @@ public function create_item( $request ) {
);
}

// Do not allow comments to be created with a non-default type.
if ( ! empty( $request['type'] ) && 'comment' !== $request['type'] ) {
return new WP_Error(
'rest_invalid_comment_type',
__( 'Cannot create a comment with that type.' ),
array( 'status' => 400 )
);
}

$prepared_comment = $this->prepare_item_for_database( $request );
if ( is_wp_error( $prepared_comment ) ) {
return $prepared_comment;
}

$prepared_comment['comment_type'] = 'comment';
$prepared_comment['comment_type'] = $request['type'];

if ( ! isset( $prepared_comment['comment_content'] ) ) {
$prepared_comment['comment_content'] = '';
}

// Include note metadata into check_is_comment_content_allowed.
if ( isset( $request['meta']['_wp_note_status'] ) ) {
$prepared_comment['meta']['_wp_note_status'] = $request['meta']['_wp_note_status'];
}

if ( ! $this->check_is_comment_content_allowed( $prepared_comment ) ) {
return new WP_Error(
'rest_comment_content_invalid',
Expand Down Expand Up @@ -1518,8 +1556,9 @@ public function get_item_schema() {
'type' => array(
'description' => __( 'Type of the comment.' ),
'type' => 'string',
'enum' => array( 'comment', 'note' ),
'default' => 'comment',
'context' => array( 'view', 'edit', 'embed' ),
'readonly' => true,
),
),
);
Expand Down Expand Up @@ -1894,6 +1933,7 @@ public function check_comment_author_email( $value, $request, $param ) {
return $email;
}


/**
* If empty comments are not allowed, checks if the provided comment content is not empty.
*
Expand Down Expand Up @@ -1922,10 +1962,42 @@ protected function check_is_comment_content_allowed( $prepared_comment ) {
return true;
}

// Allow empty notes only when resolution metadata is valid.
if (
isset( $check['comment_type'] ) &&
'note' === $check['comment_type'] &&
isset( $check['meta']['_wp_note_status'] ) &&
in_array( $check['meta']['_wp_note_status'], array( 'resolved', 'reopen' ), true )
) {
return true;
}

/*
* Do not allow a comment to be created with missing or empty
* comment_content. See wp_handle_comment_submission().
*/
return '' !== $check['comment_content'];
}

/**
* Check if post type supports notes.
*
* @param string $post_type Post type name.
* @return bool True if post type supports notes, false otherwise.
*/
private function check_post_type_supports_notes( $post_type ) {
$supports = get_all_post_type_supports( $post_type );
if ( ! isset( $supports['editor'] ) ) {
return false;
}
if ( ! is_array( $supports['editor'] ) ) {
return false;
}
foreach ( $supports['editor'] as $item ) {
if ( is_array( $item ) && isset( $item['notes'] ) && true === $item['notes'] ) {
return true;
}
}
return true;
}
}
Loading
Loading