From 19b54cfc5daf38d6d33f0e0ee0fa682212e46578 Mon Sep 17 00:00:00 2001 From: Matt Crisp Date: Sun, 16 Aug 2026 11:06:07 -0700 Subject: [PATCH 01/15] initial function scaffold --- src/wp-admin/includes/dashboard.php | 297 ++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index 7a78025214f10..2021896c67076 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -932,6 +932,234 @@ function _wp_dashboard_recent_comments_row( &$comment, $show_date = true ) { $GLOBALS['comment'] = null; } +/** + * Outputs a row for the Recent Comments widget. + * + * @access private + * @since 2.7.0 + * + * @global WP_Comment $comment Global comment object. + * + * @param WP_Comment $comment The current comment. + * @param bool $show_date Optional. Whether to display the date. + */ +function _wp_dashboard_recent_notes_row( &$comment, $show_date = true ) { + $GLOBALS['comment'] = clone $comment; + + if ( $comment->comment_post_ID > 0 ) { + $comment_post_title = _draft_or_post_title( $comment->comment_post_ID ); + $comment_post_url = get_the_permalink( $comment->comment_post_ID ); + $comment_post_link = '' . $comment_post_title . ''; + } else { + $comment_post_link = ''; + } + + $actions_string = ''; + if ( current_user_can( 'edit_comment', $comment->comment_ID ) ) { + // Pre-order it: Approve | Reply | Edit | Spam | Trash. + $actions = array( + 'approve' => '', + 'unapprove' => '', + 'reply' => '', + 'edit' => '', + 'spam' => '', + 'trash' => '', + 'delete' => '', + 'view' => '', + ); + + $approve_nonce = esc_html( '_wpnonce=' . wp_create_nonce( 'approve-comment_' . $comment->comment_ID ) ); + $del_nonce = esc_html( '_wpnonce=' . wp_create_nonce( 'delete-comment_' . $comment->comment_ID ) ); + + $action_string = 'comment.php?action=%s&p=' . $comment->comment_post_ID . '&c=' . $comment->comment_ID . '&%s'; + + $approve_url = sprintf( $action_string, 'approvecomment', $approve_nonce ); + $unapprove_url = sprintf( $action_string, 'unapprovecomment', $approve_nonce ); + $spam_url = sprintf( $action_string, 'spamcomment', $del_nonce ); + $trash_url = sprintf( $action_string, 'trashcomment', $del_nonce ); + $delete_url = sprintf( $action_string, 'deletecomment', $del_nonce ); + + $actions['approve'] = sprintf( + '%s', + esc_url( $approve_url ), + "dim:the-comment-list:comment-{$comment->comment_ID}:unapproved:e7e7d3:e7e7d3:new=approved", + esc_attr__( 'Approve this comment' ), + __( 'Approve' ) + ); + + $actions['unapprove'] = sprintf( + '%s', + esc_url( $unapprove_url ), + "dim:the-comment-list:comment-{$comment->comment_ID}:unapproved:e7e7d3:e7e7d3:new=unapproved", + esc_attr__( 'Unapprove this comment' ), + __( 'Unapprove' ) + ); + + $actions['edit'] = sprintf( + '%s', + "comment.php?action=editcomment&c={$comment->comment_ID}", + esc_attr__( 'Edit this comment' ), + __( 'Edit' ) + ); + + $actions['reply'] = sprintf( + '', + $comment->comment_ID, + $comment->comment_post_ID, + esc_attr__( 'Reply to this comment' ), + /* translators: Comment reply button text. */ + _x( 'Reply', 'verb' ) + ); + + $actions['spam'] = sprintf( + '%s', + esc_url( $spam_url ), + "delete:the-comment-list:comment-{$comment->comment_ID}::spam=1", + esc_attr__( 'Mark this comment as spam' ), + /* translators: "Mark as spam" link. */ + _x( 'Spam', 'verb' ) + ); + + if ( ! EMPTY_TRASH_DAYS ) { + $actions['delete'] = sprintf( + '%s', + esc_url( $delete_url ), + "delete:the-comment-list:comment-{$comment->comment_ID}::trash=1", + esc_attr__( 'Delete this comment permanently' ), + __( 'Delete Permanently' ) + ); + } else { + $actions['trash'] = sprintf( + '%s', + esc_url( $trash_url ), + "delete:the-comment-list:comment-{$comment->comment_ID}::trash=1", + esc_attr__( 'Move this comment to the Trash' ), + _x( 'Trash', 'verb' ) + ); + } + + $actions['view'] = sprintf( + '%s', + esc_url( get_comment_link( $comment ) ), + esc_attr__( 'View this comment' ), + __( 'View' ) + ); + + /** This filter is documented in wp-admin/includes/class-wp-comments-list-table.php */ + $actions = apply_filters( 'comment_row_actions', array_filter( $actions ), $comment ); + + $i = 0; + + foreach ( $actions as $action => $link ) { + ++$i; + + if ( ( ( 'approve' === $action || 'unapprove' === $action ) && 2 === $i ) + || 1 === $i + ) { + $separator = ''; + } else { + $separator = ' | '; + } + + // Reply and quickedit need a hide-if-no-js span. + if ( 'reply' === $action || 'quickedit' === $action ) { + $action .= ' hide-if-no-js'; + } + + if ( 'view' === $action && '1' !== $comment->comment_approved ) { + $action .= ' hidden'; + } + + $actions_string .= "{$separator}{$link}"; + } + } + ?> + +
  • > + + + + comment_type || 'comment' === $comment->comment_type ) : ?> + +
    +

    + ' . get_comment_author_link( $comment ) . '', + $comment_post_link, + '' . __( '[Pending]' ) . '' + ); + } else { + printf( + /* translators: 1: Comment author, 2: Notification if the comment is pending. */ + __( 'From %1$s %2$s' ), + '' . get_comment_author_link( $comment ) . '', + '' . __( '[Pending]' ) . '' + ); + } + ?> +

    + + comment_type ) { + case 'pingback': + $type = __( 'Pingback' ); + break; + case 'trackback': + $type = __( 'Trackback' ); + break; + default: + $type = ucwords( $comment->comment_type ); + } + $type = esc_html( $type ); + ?> +
    +

    + $type", + $comment_post_link, + '' . __( '[Pending]' ) . '' + ); + } else { + printf( + /* translators: 1: Type of comment, 2: Notification if the comment is pending. */ + _x( '%1$s %2$s', 'dashboard' ), + "$type", + '' . __( '[Pending]' ) . '' + ); + } + ?> +

    +

    + + +

    + +

    + +
    +
  • + '; echo '

    ' . __( 'No activity yet!' ) . '

    '; @@ -1149,6 +1379,73 @@ function wp_dashboard_recent_comments( $total_items = 5 ) { return true; } +/** + * Show Notes section. + * + * @since 7.2.0 + * + * @param int $total_items Optional. Number of notes to query. Default 5. + * @return bool False if no notes were found. True otherwise. + */ +function wp_dashboard_recent_notes( $total_items = 5 ) { + // Select all notes. + $notes = array(); + + $notes_query = array( + 'number' => $total_items * 5, + 'offset' => 0, + ); + + if ( ! current_user_can( 'edit_posts' ) ) { + $notes_query['status'] = 'approve'; + } + + $notes_count = 0; + do { + $possible = get_comments( $notes_query ); + + if ( empty( $possible ) || ! is_array( $possible ) ) { + break; + } + + foreach ( $possible as $note ) { + if ( ! current_user_can( 'edit_post', $note->comment_post_ID ) + && ( post_password_required( $note->comment_post_ID ) + || ! current_user_can( 'read_post', $note->comment_post_ID ) ) + ) { + // The user has no access to the post and thus cannot see the comments. + continue; + } + + $notes[] = $note; + $notes_count = count( $notes ); + + if ( $notes_count === $total_items ) { + break 2; + } + } + + $notes_query['offset'] += $notes_query['number']; + $notes_query['number'] = $total_items * 10; + } while ( $notes_count < $total_items ); + + if ( $notes ) { + echo '
    '; + echo '

    ' . __( 'Recent Notes' ) . '

    '; + + echo ''; + + echo '
    '; + } else { + return false; + } + return true; +} + /** * Display generic dashboard RSS widget feed. * From ca542fb538d2e794134ba4d091f4dc9dfeb14c3e Mon Sep 17 00:00:00 2001 From: Matt Crisp Date: Sun, 16 Aug 2026 11:35:40 -0700 Subject: [PATCH 02/15] notes query now actually queries notes --- src/wp-admin/includes/dashboard.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index 2021896c67076..0db106d35b0bc 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -1393,6 +1393,7 @@ function wp_dashboard_recent_notes( $total_items = 5 ) { $notes_query = array( 'number' => $total_items * 5, + 'type' => 'note', 'offset' => 0, ); From ee505311ca418494558aeb636f58700b2c10bd99 Mon Sep 17 00:00:00 2001 From: Anca Mosoiu Date: Sun, 16 Aug 2026 11:39:51 -0700 Subject: [PATCH 03/15] formatting for notes in dashboard --- src/wp-admin/includes/dashboard.php | 149 ++-------------------------- 1 file changed, 11 insertions(+), 138 deletions(-) diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index 2021896c67076..0a79b00ec96a2 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -933,10 +933,10 @@ function _wp_dashboard_recent_comments_row( &$comment, $show_date = true ) { } /** - * Outputs a row for the Recent Comments widget. + * Outputs a row for the Recent Notes widget. Notes are implemented as a type of comment. * * @access private - * @since 2.7.0 + * @since 7.2.0 * * @global WP_Comment $comment Global comment object. * @@ -948,142 +948,17 @@ function _wp_dashboard_recent_notes_row( &$comment, $show_date = true ) { if ( $comment->comment_post_ID > 0 ) { $comment_post_title = _draft_or_post_title( $comment->comment_post_ID ); - $comment_post_url = get_the_permalink( $comment->comment_post_ID ); + $comment_post_url = get_edit_post_link( $comment->comment_post_ID ); $comment_post_link = '' . $comment_post_title . ''; } else { $comment_post_link = ''; } - - $actions_string = ''; - if ( current_user_can( 'edit_comment', $comment->comment_ID ) ) { - // Pre-order it: Approve | Reply | Edit | Spam | Trash. - $actions = array( - 'approve' => '', - 'unapprove' => '', - 'reply' => '', - 'edit' => '', - 'spam' => '', - 'trash' => '', - 'delete' => '', - 'view' => '', - ); - - $approve_nonce = esc_html( '_wpnonce=' . wp_create_nonce( 'approve-comment_' . $comment->comment_ID ) ); - $del_nonce = esc_html( '_wpnonce=' . wp_create_nonce( 'delete-comment_' . $comment->comment_ID ) ); - - $action_string = 'comment.php?action=%s&p=' . $comment->comment_post_ID . '&c=' . $comment->comment_ID . '&%s'; - - $approve_url = sprintf( $action_string, 'approvecomment', $approve_nonce ); - $unapprove_url = sprintf( $action_string, 'unapprovecomment', $approve_nonce ); - $spam_url = sprintf( $action_string, 'spamcomment', $del_nonce ); - $trash_url = sprintf( $action_string, 'trashcomment', $del_nonce ); - $delete_url = sprintf( $action_string, 'deletecomment', $del_nonce ); - - $actions['approve'] = sprintf( - '%s', - esc_url( $approve_url ), - "dim:the-comment-list:comment-{$comment->comment_ID}:unapproved:e7e7d3:e7e7d3:new=approved", - esc_attr__( 'Approve this comment' ), - __( 'Approve' ) - ); - - $actions['unapprove'] = sprintf( - '%s', - esc_url( $unapprove_url ), - "dim:the-comment-list:comment-{$comment->comment_ID}:unapproved:e7e7d3:e7e7d3:new=unapproved", - esc_attr__( 'Unapprove this comment' ), - __( 'Unapprove' ) - ); - - $actions['edit'] = sprintf( - '%s', - "comment.php?action=editcomment&c={$comment->comment_ID}", - esc_attr__( 'Edit this comment' ), - __( 'Edit' ) - ); - - $actions['reply'] = sprintf( - '', - $comment->comment_ID, - $comment->comment_post_ID, - esc_attr__( 'Reply to this comment' ), - /* translators: Comment reply button text. */ - _x( 'Reply', 'verb' ) - ); - - $actions['spam'] = sprintf( - '%s', - esc_url( $spam_url ), - "delete:the-comment-list:comment-{$comment->comment_ID}::spam=1", - esc_attr__( 'Mark this comment as spam' ), - /* translators: "Mark as spam" link. */ - _x( 'Spam', 'verb' ) - ); - - if ( ! EMPTY_TRASH_DAYS ) { - $actions['delete'] = sprintf( - '%s', - esc_url( $delete_url ), - "delete:the-comment-list:comment-{$comment->comment_ID}::trash=1", - esc_attr__( 'Delete this comment permanently' ), - __( 'Delete Permanently' ) - ); - } else { - $actions['trash'] = sprintf( - '%s', - esc_url( $trash_url ), - "delete:the-comment-list:comment-{$comment->comment_ID}::trash=1", - esc_attr__( 'Move this comment to the Trash' ), - _x( 'Trash', 'verb' ) - ); - } - - $actions['view'] = sprintf( - '%s', - esc_url( get_comment_link( $comment ) ), - esc_attr__( 'View this comment' ), - __( 'View' ) - ); - - /** This filter is documented in wp-admin/includes/class-wp-comments-list-table.php */ - $actions = apply_filters( 'comment_row_actions', array_filter( $actions ), $comment ); - - $i = 0; - - foreach ( $actions as $action => $link ) { - ++$i; - - if ( ( ( 'approve' === $action || 'unapprove' === $action ) && 2 === $i ) - || 1 === $i - ) { - $separator = ''; - } else { - $separator = ' | '; - } - - // Reply and quickedit need a hide-if-no-js span. - if ( 'reply' === $action || 'quickedit' === $action ) { - $action .= ' hide-if-no-js'; - } - - if ( 'view' === $action && '1' !== $comment->comment_approved ) { - $action .= ' hidden'; - } - - $actions_string .= "{$separator}{$link}"; - } - } ?>
  • > comment_type || 'comment' === $comment->comment_type ) : ?> @@ -1091,20 +966,21 @@ function _wp_dashboard_recent_notes_row( &$comment, $show_date = true ) {

    ' . get_comment_author_link( $comment ) . '', - $comment_post_link, - '' . __( '[Pending]' ) . '' + /* translators: 1: Comment author, 2: Post link */ + __( '%1$s - From %2$s on %3$s' ), + '' , //get_comment_date( $comment ), + '' . get_comment_author( $comment ) . '', + $comment_post_link ); } else { printf( /* translators: 1: Comment author, 2: Notification if the comment is pending. */ __( 'From %1$s %2$s' ), - '' . get_comment_author_link( $comment ) . '', + '' . get_comment_author( $comment ) . '', '' . __( '[Pending]' ) . '' ); } @@ -1151,9 +1027,6 @@ function _wp_dashboard_recent_notes_row( &$comment, $show_date = true ) {

    - -

    -
  • Date: Sun, 16 Aug 2026 12:00:45 -0700 Subject: [PATCH 04/15] formatting for notes - first pass --- src/wp-admin/includes/dashboard.php | 58 ++++------------------------- 1 file changed, 7 insertions(+), 51 deletions(-) diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index e885e0b355d22..4a3c502cdaef2 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -959,73 +959,29 @@ function _wp_dashboard_recent_notes_row( &$comment, $show_date = true ) { comment_type ) ); ?> - - comment_type || 'comment' === $comment->comment_type ) : ?> - -
    -

    - ' . get_comment_author( $comment ) . '', - $comment_post_link - ); - } else { - printf( - /* translators: 1: Comment author, 2: Notification if the comment is pending. */ - __( 'From %1$s %2$s' ), - '' . get_comment_author( $comment ) . '', - '' . __( '[Pending]' ) . '' - ); - } - ?> -

    - - comment_type ) { - case 'pingback': - $type = __( 'Pingback' ); - break; - case 'trackback': - $type = __( 'Trackback' ); - break; - default: - $type = ucwords( $comment->comment_type ); - } - $type = esc_html( $type ); - ?>

    $type", - $comment_post_link, - '' . __( '[Pending]' ) . '' + _x( ' - From %1$s on %2$s', 'dashboard' ), + get_comment_author( $comment ), + $comment_post_link ); } else { printf( /* translators: 1: Type of comment, 2: Notification if the comment is pending. */ - _x( '%1$s %2$s', 'dashboard' ), - "$type", - '' . __( '[Pending]' ) . '' + _x( ' - From %1$s', 'dashboard' ), + get_comment_author( $comment ) ); } ?>

    -

    - -

    From 6488085e21161cf4ef3bc1a1d2beec1c153a29a7 Mon Sep 17 00:00:00 2001 From: Anca Mosoiu Date: Sun, 16 Aug 2026 12:48:09 -0700 Subject: [PATCH 05/15] cleaned up the styling for the notes activity --- src/wp-admin/css/dashboard.css | 15 +++++++++++++++ src/wp-admin/includes/dashboard.php | 28 +++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/wp-admin/css/dashboard.css b/src/wp-admin/css/dashboard.css index 17a9e312b85c6..771f5deac57a7 100644 --- a/src/wp-admin/css/dashboard.css +++ b/src/wp-admin/css/dashboard.css @@ -1025,6 +1025,21 @@ body #dashboard-widgets .postbox form .submit { top: 0; } +/* Dashboard activity widget - notes */ + +#latest-notes ul { + margin: 8px -12px 0 -12px +} + +#latest-notes li{ + padding: 4px 12px; + color: #646970; +} + +#latest-notes li:nth-child(odd) { + background-color: #f6f7f7; +} + /* Browse happy box */ #dashboard-widgets #dashboard_browser_nag.postbox .inside { diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index 4a3c502cdaef2..c08730ef5ceca 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -953,6 +953,28 @@ function _wp_dashboard_recent_notes_row( &$comment, $show_date = true ) { } else { $comment_post_link = ''; } + + + $today = current_time( 'Y-m-d' ); + $year = current_time( 'Y' ); + + $time = get_comment_date( 'U' ); + + if ( ! is_int( $time ) ) { + /* translators: Date and time format for recent posts on the dashboard, from a different calendar year, see https://www.php.net/manual/datetime.format.php */ + $date = get_comment_date( __( 'M jS Y' ) ); + } elseif ( gmdate( 'Y-m-d', $time ) === $today ) { + $date = __( 'Today' ); + } elseif ( gmdate( 'Y-m-d', $time ) === $tomorrow ) { + $date = __( 'Tomorrow' ); + } elseif ( gmdate( 'Y', $time ) !== $year ) { + /* translators: Date and time format for recent posts on the dashboard, from a different calendar year, see https://www.php.net/manual/datetime.format.php */ + $date = date_i18n( __( 'M jS Y' ), $time ); + } else { + /* translators: Date and time format for recent posts on the dashboard, see https://www.php.net/manual/datetime.format.php */ + $date = date_i18n( __( 'M jS' ), $time ); + } + ?>
  • > @@ -964,19 +986,19 @@ function _wp_dashboard_recent_notes_row( &$comment, $show_date = true ) {

    %1$s on %2$s', 'dashboard' ), get_comment_author( $comment ), $comment_post_link ); } else { printf( /* translators: 1: Type of comment, 2: Notification if the comment is pending. */ - _x( ' - From %1$s', 'dashboard' ), + _x( ' - From %1$s', 'dashboard' ), get_comment_author( $comment ) ); } From ea90ef6863e1052c2dd4b517a919d6f6f50ac95b Mon Sep 17 00:00:00 2001 From: aosmichenko Date: Sun, 16 Aug 2026 13:36:19 -0700 Subject: [PATCH 06/15] update naming of the variable and fix formating --- src/wp-admin/includes/dashboard.php | 80 ++++++++++++++--------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index c08730ef5ceca..e24cb5628122f 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -940,71 +940,71 @@ function _wp_dashboard_recent_comments_row( &$comment, $show_date = true ) { * * @global WP_Comment $comment Global comment object. * - * @param WP_Comment $comment The current comment. + * @param WP_Comment $note The current note. * @param bool $show_date Optional. Whether to display the date. */ -function _wp_dashboard_recent_notes_row( &$comment, $show_date = true ) { - $GLOBALS['comment'] = clone $comment; +function _wp_dashboard_recent_notes_row( &$note, $show_date = true ) { + $GLOBALS['comment'] = clone $note; - if ( $comment->comment_post_ID > 0 ) { - $comment_post_title = _draft_or_post_title( $comment->comment_post_ID ); - $comment_post_url = get_edit_post_link( $comment->comment_post_ID ); - $comment_post_link = '' . $comment_post_title . ''; + if ( $note->comment_post_ID > 0 ) { + $note_post_title = _draft_or_post_title( $note->comment_post_ID ); + $note_post_url = get_edit_post_link( $note->comment_post_ID ); + $note_post_link = '' . $note_post_title . ''; } else { - $comment_post_link = ''; + $note_post_link = ''; } - - $today = current_time( 'Y-m-d' ); - $year = current_time( 'Y' ); - - $time = get_comment_date( 'U' ); - - if ( ! is_int( $time ) ) { - /* translators: Date and time format for recent posts on the dashboard, from a different calendar year, see https://www.php.net/manual/datetime.format.php */ - $date = get_comment_date( __( 'M jS Y' ) ); - } elseif ( gmdate( 'Y-m-d', $time ) === $today ) { - $date = __( 'Today' ); - } elseif ( gmdate( 'Y-m-d', $time ) === $tomorrow ) { - $date = __( 'Tomorrow' ); - } elseif ( gmdate( 'Y', $time ) !== $year ) { - /* translators: Date and time format for recent posts on the dashboard, from a different calendar year, see https://www.php.net/manual/datetime.format.php */ - $date = date_i18n( __( 'M jS Y' ), $time ); - } else { - /* translators: Date and time format for recent posts on the dashboard, see https://www.php.net/manual/datetime.format.php */ - $date = date_i18n( __( 'M jS' ), $time ); - } + $today = current_time( 'Y-m-d' ); + $tomorrow = current_datetime()->modify( '+1 day' )->format( 'Y-m-d' ); + $year = current_time( 'Y' ); + + $time = get_comment_date( 'U', $note ); + + if ( ! is_int( $time ) ) { + /* translators: Date and time format for recent posts on the dashboard, from a different calendar year, see https://www.php.net/manual/datetime.format.php */ + $date = get_comment_date( __( 'M jS Y' ), $note ); + } elseif ( gmdate( 'Y-m-d', $time ) === $today ) { + $date = __( 'Today' ); + } elseif ( gmdate( 'Y-m-d', $time ) === $tomorrow ) { + $date = __( 'Tomorrow' ); + } elseif ( gmdate( 'Y', $time ) !== $year ) { + /* translators: Date and time format for recent posts on the dashboard, from a different calendar year, see https://www.php.net/manual/datetime.format.php */ + $date = date_i18n( __( 'M jS Y' ), $time ); + } else { + /* translators: Date and time format for recent posts on the dashboard, see https://www.php.net/manual/datetime.format.php */ + $date = date_i18n( __( 'M jS' ), $time ); + } ?> -

  • > +
  • > comment_type ) ); + $note_row_class = ''; + $note_type = esc_html( ucwords( $note->comment_type ) ); ?>

    %1$s on %2$s', 'dashboard' ), - get_comment_author( $comment ), - $comment_post_link + get_comment_author( $note ), + $note_post_link ); } else { printf( - /* translators: 1: Type of comment, 2: Notification if the comment is pending. */ + /* translators: 1: Note author. */ _x( ' - From %1$s', 'dashboard' ), - get_comment_author( $comment ) + get_comment_author( $note ) ); } ?>

    -

    +

  • Date: Sun, 16 Aug 2026 14:15:49 -0700 Subject: [PATCH 07/15] include notes to widget content check --- src/wp-admin/includes/dashboard.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index e24cb5628122f..a77a274d9a34d 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -1045,7 +1045,7 @@ function wp_dashboard_site_activity() { $recent_notes = wp_dashboard_recent_notes(); - if ( ! $future_posts && ! $recent_posts && ! $recent_comments ) { + if ( ! $future_posts && ! $recent_posts && ! $recent_comments && ! $recent_notes ) { echo '
    '; echo '

    ' . __( 'No activity yet!' ) . '

    '; echo '
    '; @@ -1246,6 +1246,7 @@ function wp_dashboard_recent_notes( $total_items = 5 ) { 'number' => $total_items * 5, 'type' => 'note', 'offset' => 0, + ); if ( ! current_user_can( 'edit_posts' ) ) { From 7fa824af38e978cd9edd386d5d6cd52defe6b6ca Mon Sep 17 00:00:00 2001 From: Matt Crisp Date: Sun, 16 Aug 2026 14:46:56 -0700 Subject: [PATCH 08/15] merge conflict --- src/wp-admin/includes/dashboard.php | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index a77a274d9a34d..2defa65e46c58 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -1243,10 +1243,12 @@ function wp_dashboard_recent_notes( $total_items = 5 ) { $notes = array(); $notes_query = array( - 'number' => $total_items * 5, - 'type' => 'note', - 'offset' => 0, - + 'number' => $total_items * 5, + 'offset' => 0, + 'type' => 'note', + 'status' => 'hold', + 'orderby' => 'comment_date', + 'order' => 'DESC', ); if ( ! current_user_can( 'edit_posts' ) ) { @@ -1262,14 +1264,18 @@ function wp_dashboard_recent_notes( $total_items = 5 ) { } foreach ( $possible as $note ) { - if ( ! current_user_can( 'edit_post', $note->comment_post_ID ) - && ( post_password_required( $note->comment_post_ID ) - || ! current_user_can( 'read_post', $note->comment_post_ID ) ) - ) { - // The user has no access to the post and thus cannot see the comments. + if ( ! current_user_can( 'edit_post', $note->comment_post_ID ) ) { continue; } + // if ( $note->comment_parent ) { + // $parent_note = get_comment( (int) $note->comment_parent ); + + // if ( ! $parent_note || 'hold' !== wp_get_comment_status( $parent_note ) ) { + // continue; + // } + // } + $notes[] = $note; $notes_count = count( $notes ); From c104eb1b152788778b3bcec73efbd9b72b050cd1 Mon Sep 17 00:00:00 2001 From: aosmichenko Date: Sun, 16 Aug 2026 15:02:47 -0700 Subject: [PATCH 09/15] Update the layout and view --- src/wp-admin/css/dashboard.css | 14 ++- src/wp-admin/includes/dashboard.php | 169 ++++++++++++++-------------- 2 files changed, 95 insertions(+), 88 deletions(-) diff --git a/src/wp-admin/css/dashboard.css b/src/wp-admin/css/dashboard.css index 771f5deac57a7..b2195daa84218 100644 --- a/src/wp-admin/css/dashboard.css +++ b/src/wp-admin/css/dashboard.css @@ -934,12 +934,14 @@ body #dashboard-widgets .postbox form .submit { } #future-posts ul, -#published-posts ul { +#published-posts ul, +#latest-notes ul { margin: 8px -12px 0 -12px; } #future-posts li, -#published-posts li { +#published-posts li, +#latest-notes li { display: grid; grid-template-columns: clamp(160px, calc(2vw + 140px), 200px) auto; column-gap: 10px; @@ -947,8 +949,14 @@ body #dashboard-widgets .postbox form .submit { padding: 4px 12px; } +/* Recent Notes rows add a third column for the number of open notes. */ +#latest-notes li { + grid-template-columns: clamp(160px, calc(2vw + 140px), 200px) auto max-content; +} + #future-posts li:nth-child(odd), -#published-posts li:nth-child(odd) { +#published-posts li:nth-child(odd), +#latest-notes li:nth-child(odd) { background-color: #f6f7f7; } diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index a77a274d9a34d..6b596827268b4 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -933,26 +933,18 @@ function _wp_dashboard_recent_comments_row( &$comment, $show_date = true ) { } /** - * Outputs a row for the Recent Notes widget. Notes are implemented as a type of comment. + * Outputs a row for the Recent Notes widget. Notes are implemented as a type of comment. + * + * Each row represents a post with open notes, not an individual note. * * @access private * @since 7.2.0 * - * @global WP_Comment $comment Global comment object. - * - * @param WP_Comment $note The current note. - * @param bool $show_date Optional. Whether to display the date. + * @param WP_Comment $note The most recent open note of the post. + * @param int $open_notes Optional. Number of open notes the post has. Default 1. */ -function _wp_dashboard_recent_notes_row( &$note, $show_date = true ) { - $GLOBALS['comment'] = clone $note; - - if ( $note->comment_post_ID > 0 ) { - $note_post_title = _draft_or_post_title( $note->comment_post_ID ); - $note_post_url = get_edit_post_link( $note->comment_post_ID ); - $note_post_link = '' . $note_post_title . ''; - } else { - $note_post_link = ''; - } +function _wp_dashboard_recent_notes_row( $note, $open_notes = 1 ) { + $note_post_id = (int) $note->comment_post_ID; $today = current_time( 'Y-m-d' ); $tomorrow = current_datetime()->modify( '+1 day' )->format( 'Y-m-d' ); @@ -961,54 +953,36 @@ function _wp_dashboard_recent_notes_row( &$note, $show_date = true ) { $time = get_comment_date( 'U', $note ); if ( ! is_int( $time ) ) { - /* translators: Date and time format for recent posts on the dashboard, from a different calendar year, see https://www.php.net/manual/datetime.format.php */ + /* translators: Date and time format for recent notes on the dashboard, from a different calendar year, see https://www.php.net/manual/datetime.format.php */ $date = get_comment_date( __( 'M jS Y' ), $note ); } elseif ( gmdate( 'Y-m-d', $time ) === $today ) { $date = __( 'Today' ); } elseif ( gmdate( 'Y-m-d', $time ) === $tomorrow ) { $date = __( 'Tomorrow' ); } elseif ( gmdate( 'Y', $time ) !== $year ) { - /* translators: Date and time format for recent posts on the dashboard, from a different calendar year, see https://www.php.net/manual/datetime.format.php */ + /* translators: Date and time format for recent notes on the dashboard, from a different calendar year, see https://www.php.net/manual/datetime.format.php */ $date = date_i18n( __( 'M jS Y' ), $time ); } else { - /* translators: Date and time format for recent posts on the dashboard, see https://www.php.net/manual/datetime.format.php */ + /* translators: Date and time format for recent notes on the dashboard, see https://www.php.net/manual/datetime.format.php */ $date = date_i18n( __( 'M jS' ), $time ); } - ?> - -
  • > - - comment_type ) ); - ?> -
    -

    - %1$s on %2$s', 'dashboard' ), - get_comment_author( $note ), - $note_post_link - ); - } else { - printf( - /* translators: 1: Note author. */ - _x( ' - From %1$s', 'dashboard' ), - get_comment_author( $note ) - ); - } - ?> -

    -

    -
    -
  • - %4$s %5$s %1$s', + /* translators: 1: Relative date, 2: Time. */ + sprintf( _x( '%1$s, %2$s', 'dashboard' ), $date, get_comment_time( '', false, true, $note ) ), + esc_url( get_edit_post_link( $note_post_id ) ), + /* translators: %s: Post title. */ + esc_attr( sprintf( __( 'Edit “%s”' ), $note_post_title ) ), + $note_post_title, + sprintf( + /* translators: %s: Number of open notes. */ + _n( '%s open note', '%s open notes', $open_notes ), + number_format_i18n( $open_notes ) + ) + ); } /** @@ -1233,27 +1207,32 @@ function wp_dashboard_recent_comments( $total_items = 5 ) { /** * Show Notes section. * + * Lists the posts the current user can edit that have at least one open note, + * ordered by the most recently added open note. + * * @since 7.2.0 * - * @param int $total_items Optional. Number of notes to query. Default 5. - * @return bool False if no notes were found. True otherwise. + * @param int $total_items Optional. Number of posts to display. Default 5. + * @return bool False if no posts with open notes were found. True otherwise. */ function wp_dashboard_recent_notes( $total_items = 5 ) { - // Select all notes. - $notes = array(); - + /* + * Only top level notes are queried: replies live in the same thread, and + * resolving a note approves it, so open notes are the ones still on hold. + */ $notes_query = array( - 'number' => $total_items * 5, - 'type' => 'note', - 'offset' => 0, - + 'type' => 'note', + 'parent' => 0, + 'status' => 'hold', + 'orderby' => 'comment_date_gmt', + 'order' => 'DESC', + 'number' => $total_items * 5, + 'offset' => 0, ); - if ( ! current_user_can( 'edit_posts' ) ) { - $notes_query['status'] = 'approve'; - } + // The most recent open note of each post, keyed by post ID. + $latest_notes = array(); - $notes_count = 0; do { $possible = get_comments( $notes_query ); @@ -1262,40 +1241,60 @@ function wp_dashboard_recent_notes( $total_items = 5 ) { } foreach ( $possible as $note ) { - if ( ! current_user_can( 'edit_post', $note->comment_post_ID ) - && ( post_password_required( $note->comment_post_ID ) - || ! current_user_can( 'read_post', $note->comment_post_ID ) ) - ) { - // The user has no access to the post and thus cannot see the comments. + $note_post_id = (int) $note->comment_post_ID; + + // Only the first note of a post is kept, as notes are ordered by date. + if ( isset( $latest_notes[ $note_post_id ] ) ) { + continue; + } + + // Notes are only visible to users who can edit the post they belong to. + if ( ! current_user_can( 'edit_post', $note_post_id ) ) { continue; } - $notes[] = $note; - $notes_count = count( $notes ); + $latest_notes[ $note_post_id ] = $note; - if ( $notes_count === $total_items ) { + if ( count( $latest_notes ) === $total_items ) { break 2; } } $notes_query['offset'] += $notes_query['number']; $notes_query['number'] = $total_items * 10; - } while ( $notes_count < $total_items ); + } while ( count( $latest_notes ) < $total_items ); - if ( $notes ) { - echo '
    '; - echo '

    ' . __( 'Recent Notes' ) . '

    '; + if ( ! $latest_notes ) { + return false; + } - echo '
      '; - foreach ( $notes as $note ) { - _wp_dashboard_recent_notes_row( $note ); - } - echo '
    '; + // Count the open notes of each of the posts about to be displayed. + $open_notes = array_fill_keys( array_keys( $latest_notes ), 0 ); - echo '
    '; - } else { - return false; + $post_notes = get_comments( + array( + 'type' => 'note', + 'parent' => 0, + 'status' => 'hold', + 'post__in' => array_keys( $latest_notes ), + ) + ); + + foreach ( $post_notes as $post_note ) { + ++$open_notes[ (int) $post_note->comment_post_ID ]; } + + echo '
    '; + echo '

    ' . __( 'Recent Notes' ) . '

    '; + + echo '
      '; + foreach ( $latest_notes as $note_post_id => $note ) { + _wp_dashboard_recent_notes_row( $note, $open_notes[ $note_post_id ] ); + } + echo '
    '; + + echo '
    '; + return true; } From 4acbc98c138d7b8097533cb674bf88759c43bf5c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 16 Aug 2026 15:02:50 -0700 Subject: [PATCH 10/15] Dashboard: Cast the note post ID to an integer before use. WP_Comment exposes its columns as strings, so passing comment_post_ID straight to _draft_or_post_title() and get_edit_post_link() fails static analysis. It also repeated an error pattern the PHPStan baseline records once for the comments widget, pushing that entry out of date. --- src/wp-admin/includes/dashboard.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index 2defa65e46c58..3c43f4437c544 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -946,9 +946,12 @@ function _wp_dashboard_recent_comments_row( &$comment, $show_date = true ) { function _wp_dashboard_recent_notes_row( &$note, $show_date = true ) { $GLOBALS['comment'] = clone $note; - if ( $note->comment_post_ID > 0 ) { - $note_post_title = _draft_or_post_title( $note->comment_post_ID ); - $note_post_url = get_edit_post_link( $note->comment_post_ID ); + // WP_Comment exposes its columns as strings, so the post ID is cast before use. + $note_post_id = (int) $note->comment_post_ID; + + if ( $note_post_id > 0 ) { + $note_post_title = _draft_or_post_title( $note_post_id ); + $note_post_url = get_edit_post_link( $note_post_id ); $note_post_link = '' . $note_post_title . ''; } else { $note_post_link = ''; From d529b4be49c77f3b31538d9605182a8fe0a63d47 Mon Sep 17 00:00:00 2001 From: aosmichenko Date: Sun, 16 Aug 2026 15:15:07 -0700 Subject: [PATCH 11/15] remove comments --- src/wp-admin/includes/dashboard.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index 7ca2cfad68ef6..6b596827268b4 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -1248,14 +1248,6 @@ function wp_dashboard_recent_notes( $total_items = 5 ) { continue; } - // if ( $note->comment_parent ) { - // $parent_note = get_comment( (int) $note->comment_parent ); - - // if ( ! $parent_note || 'hold' !== wp_get_comment_status( $parent_note ) ) { - // continue; - // } - // } - // Notes are only visible to users who can edit the post they belong to. if ( ! current_user_can( 'edit_post', $note_post_id ) ) { continue; From cef6eee4c43736886a50fd4b6ba0641d0869a7f3 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 16 Aug 2026 15:25:13 -0700 Subject: [PATCH 12/15] Dashboard: Count open notes with a count query per post. Tallying the open notes of the displayed posts loaded every one of those notes just to count rows, an unbounded query on a busy post. Query each post's count instead, which returns a single value and skips priming the comment and meta caches. --- src/wp-admin/includes/dashboard.php | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index 6b596827268b4..50035ff8d2561 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -1268,20 +1268,23 @@ function wp_dashboard_recent_notes( $total_items = 5 ) { return false; } - // Count the open notes of each of the posts about to be displayed. - $open_notes = array_fill_keys( array_keys( $latest_notes ), 0 ); - - $post_notes = get_comments( - array( - 'type' => 'note', - 'parent' => 0, - 'status' => 'hold', - 'post__in' => array_keys( $latest_notes ), - ) - ); + /* + * Count the open notes of each of the posts about to be displayed. A count + * query per post is cheaper than one query for every open note on them: the + * count is a single value, so the notes themselves are never loaded. + */ + $open_notes = array(); - foreach ( $post_notes as $post_note ) { - ++$open_notes[ (int) $post_note->comment_post_ID ]; + foreach ( array_keys( $latest_notes ) as $note_post_id ) { + $open_notes[ $note_post_id ] = (int) get_comments( + array( + 'type' => 'note', + 'parent' => 0, + 'status' => 'hold', + 'post_id' => $note_post_id, + 'count' => true, + ) + ); } echo '
    '; From 7718c53460da1fc1c93548b6c62818f45a3999ad Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 16 Aug 2026 15:46:12 -0700 Subject: [PATCH 13/15] Dashboard: Add tests for the Notes section rows. Covers the edit link, the post title and its placeholder, escaping, the open note count and its singular and plural forms, and the relative date each row ends with, including its use of the timezone of the site. The dashboard had no test coverage of its own before this. --- .../WpDashboardRecentNotesRow_Test.php | 371 ++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 tests/phpunit/tests/admin/includes/dashboard/WpDashboardRecentNotesRow_Test.php diff --git a/tests/phpunit/tests/admin/includes/dashboard/WpDashboardRecentNotesRow_Test.php b/tests/phpunit/tests/admin/includes/dashboard/WpDashboardRecentNotesRow_Test.php new file mode 100644 index 0000000000000..575449fff6330 --- /dev/null +++ b/tests/phpunit/tests/admin/includes/dashboard/WpDashboardRecentNotesRow_Test.php @@ -0,0 +1,371 @@ +user->create( array( 'role' => 'administrator' ) ); + + self::$post_id = $factory->post->create( + array( + 'post_title' => 'A post with notes', + 'post_status' => 'draft', + 'post_author' => self::$admin_id, + ) + ); + } + + public function set_up() { + parent::set_up(); + + require_once ABSPATH . 'wp-admin/includes/dashboard.php'; + + // Notes are only shown to users who can edit the post they belong to. + wp_set_current_user( self::$admin_id ); + } + + /** + * Creates an open note on the shared post. + * + * @param string $date Optional. Local date for the note, in MySQL format. + * Default is the current local time. + * @return WP_Comment The note. + */ + private function create_note( $date = '' ) { + if ( '' === $date ) { + $date = current_time( 'mysql' ); + } + + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_content' => 'A note.', + // Notes are stored approved; resolution is tracked in comment meta. + 'comment_approved' => '1', + 'comment_date' => $date, + 'comment_date_gmt' => get_gmt_from_date( $date ), + ) + ); + + return get_comment( $note_id ); + } + + /** + * Renders a row and returns its markup. + * + * @param WP_Comment $note The note to render. + * @param int|null $open_notes Optional. Open note count. Default null, to + * call the function without the argument. + * @return string The rendered row. + */ + private function render( $note, $open_notes = null ) { + ob_start(); + + if ( null === $open_notes ) { + _wp_dashboard_recent_notes_row( $note ); + } else { + _wp_dashboard_recent_notes_row( $note, $open_notes ); + } + + return ob_get_clean(); + } + + /** + * Returns the date and time a rendered row ends with. + * + * @param string $output The rendered row. + * @return string The contents of the date element. + */ + private function get_rendered_date( $output ) { + preg_match( '#([^<]*)#', $output, $matches ); + + $this->assertNotEmpty( $matches, 'The row did not render a date.' ); + + return $matches[1]; + } + + /** + * Returns the relative date a rendered row ends with, without the time. + * + * @param string $output The rendered row. + * @return string The date, without the trailing time. + */ + private function get_rendered_day( $output ) { + $date = explode( ', ', $this->get_rendered_date( $output ), 2 ); + + return $date[0]; + } + + /** + * @ticket 65890 + */ + public function test_should_link_to_the_post_edit_screen() { + $output = $this->render( $this->create_note() ); + + $this->assertStringContainsString( + 'href="' . esc_url( get_edit_post_link( self::$post_id ) ) . '"', + $output, + 'The row did not link to the edit screen of the post.' + ); + } + + /** + * @ticket 65890 + */ + public function test_should_use_the_post_title_as_the_link_text() { + $output = $this->render( $this->create_note() ); + + $this->assertStringContainsString( + '>A post with notes', + $output, + 'The link text was not the post title.' + ); + } + + /** + * @ticket 65890 + */ + public function test_should_label_the_link_with_the_post_title() { + $output = $this->render( $this->create_note() ); + + $this->assertStringContainsString( + 'aria-label="Edit “A post with notes”"', + $output, + 'The link was not labelled with the post title.' + ); + } + + /** + * @ticket 65890 + */ + public function test_should_use_the_placeholder_title_for_an_untitled_post() { + $post_id = self::factory()->post->create( + array( + 'post_title' => '', + 'post_status' => 'draft', + 'post_author' => self::$admin_id, + ) + ); + + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => '1', + ) + ); + + $output = $this->render( get_comment( $note_id ) ); + + $this->assertStringContainsString( + '>(no title)', + $output, + 'An untitled post did not fall back to the placeholder title.' + ); + } + + /** + * @ticket 65890 + */ + public function test_should_escape_the_post_title() { + $post_id = self::factory()->post->create( + array( + 'post_title' => '', + 'post_status' => 'draft', + 'post_author' => self::$admin_id, + ) + ); + + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => '1', + ) + ); + + $output = $this->render( get_comment( $note_id ) ); + + $this->assertStringNotContainsString( + '