Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
19b54cf
initial function scaffold
mcrisp1972 Aug 16, 2026
ca542fb
notes query now actually queries notes
mcrisp1972 Aug 16, 2026
ee50531
formatting for notes in dashboard
ammist Aug 16, 2026
fea7334
Merge branch 'ticket/65890' of github.com:mcrisp1972/wordpress-develo…
ammist Aug 16, 2026
718a4d3
formatting for notes - first pass
ammist Aug 16, 2026
6488085
cleaned up the styling for the notes activity
ammist Aug 16, 2026
ea90ef6
update naming of the variable and fix formating
aosmichenko Aug 16, 2026
e8f2308
include notes to widget content check
aosmichenko Aug 16, 2026
7fa824a
merge conflict
mcrisp1972 Aug 16, 2026
c104eb1
Update the layout and view
aosmichenko Aug 16, 2026
4acbc98
Dashboard: Cast the note post ID to an integer before use.
adamsilverstein Aug 16, 2026
00da3a9
Merge branch 'ticket/65890' of github.com:mcrisp1972/wordpress-develo…
aosmichenko Aug 16, 2026
d529b4b
remove comments
aosmichenko Aug 16, 2026
cef6eee
Dashboard: Count open notes with a count query per post.
adamsilverstein Aug 16, 2026
7718c53
Dashboard: Add tests for the Notes section rows.
adamsilverstein Aug 16, 2026
8e1a922
Dashboard: Cover the Notes section listing, and store test notes on h…
adamsilverstein Aug 16, 2026
8162132
Dashboard: Date the Notes section by the last reply to a thread.
adamsilverstein Aug 16, 2026
d4d4678
Dashboard: Bound the note paging of the Notes section.
adamsilverstein Aug 17, 2026
c8c9b7f
Dashboard: Lay Notes rows out like the sections above them.
adamsilverstein Aug 17, 2026
f455b6e
Dashboard: Date the Notes rows with a human-readable time difference.
adamsilverstein Aug 31, 2026
38c132f
Merge remote-tracking branch 'origin/trunk' into pr13076
adamsilverstein Aug 31, 2026
b74996a
Merge branch 'trunk' into ticket/65890
adamsilverstein Oct 17, 2026
36bab05
Tests: Trim the Recent Notes tests to the behavior the dashboard adds.
adamsilverstein Oct 17, 2026
2a79ca3
Apply batched suggestions from code review
adamsilverstein Oct 17, 2026
8afe348
Dashboard: Render Recent Notes rows inline with PHP templating.
adamsilverstein Oct 17, 2026
67b924f
Merge branch 'trunk' into ticket/65890
adamsilverstein Oct 18, 2026
1ef3aea
Dashboard: Guard the note date before computing its age.
adamsilverstein Oct 18, 2026
dcef80f
Upgrade/Install: Add a default note to the first post.
adamsilverstein Oct 18, 2026
c0f4aea
Apply suggestion from @adamsilverstein
adamsilverstein Oct 18, 2026
aeff5dc
Merge branch 'trunk' into ticket/65890
westonruter Oct 18, 2026
1273a5c
Fix argument.type PHPStan error when `get_edit_post_link()` returns `…
westonruter Oct 18, 2026
6e6cc5a
Add void return types to wp_dashboard_recent_notes() test methods
westonruter Oct 18, 2026
da011b1
Specify the return shape of the recent notes test's render() helper
westonruter Oct 18, 2026
11157bc
Assert the post has an edit link before escaping it in the recent not…
westonruter Oct 18, 2026
5b86d0d
Assert the first post exists in the multisite install defaults tests
westonruter Oct 18, 2026
d15b148
Compare whole block attributes in the multisite first post note tests
westonruter Oct 18, 2026
c630705
Add native parameter types to the first post note test helpers
westonruter Oct 18, 2026
df15644
Add native return and property types to the first post note tests
westonruter Oct 18, 2026
87ae168
Use null for default value for author arg
westonruter Oct 18, 2026
b0723a2
Narrow type for admin_id and author_id properties
westonruter Oct 18, 2026
5fe68ed
Add native type hint for $total_items param for wp_dashboard_recent_n…
westonruter Oct 18, 2026
00aac32
Query posts with open notes in one grouped query.
adamsilverstein Oct 19, 2026
146ad24
Only rewrite the first block delimiter when adding the default note.
adamsilverstein Oct 19, 2026
244f4a7
Add a First Note network setting for the default note.
adamsilverstein Oct 19, 2026
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
14 changes: 11 additions & 3 deletions src/wp-admin/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -929,21 +929,29 @@ 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;
color: #646970;
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;
}

Expand Down
170 changes: 169 additions & 1 deletion src/wp-admin/includes/dashboard.php
Original file line number Diff line number Diff line change
Expand Up @@ -967,7 +967,9 @@ function wp_dashboard_site_activity() {

$recent_comments = wp_dashboard_recent_comments();

if ( ! $future_posts && ! $recent_posts && ! $recent_comments ) {
$recent_notes = wp_dashboard_recent_notes();

if ( ! $future_posts && ! $recent_posts && ! $recent_comments && ! $recent_notes ) {
echo '<div class="no-activity">';
echo '<p>' . __( 'No activity yet!' ) . '</p>';
echo '</div>';
Expand Down Expand Up @@ -1152,6 +1154,172 @@ function wp_dashboard_recent_comments( $total_items = 5 ) {
return true;
}

/**
* Show Notes section.
*
* Lists the posts the current user can edit that have at least one open note,
* ordered by the note or reply most recently added to them.
*
* @since 7.2.0
*
* @global wpdb $wpdb WordPress database abstraction object.
*
* @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.
Comment thread
adamsilverstein marked this conversation as resolved.
Resolved
*
* @phpstan-param positive-int $total_items
*/
function wp_dashboard_recent_notes( int $total_items = 5 ): bool {
global $wpdb;

if ( $total_items < 1 ) {
return false;
}

/*
* Only look at posts the current user may be able to edit: any post of a type
* they can edit others' posts of, and their own posts of the other types.
*/
$any_author_types = array();
$own_types = array();

foreach ( get_post_types( array( 'show_ui' => true ), 'objects' ) as $post_type ) {
if ( current_user_can( $post_type->cap->edit_others_posts ) ) {
$any_author_types[] = $post_type->name;
} elseif ( current_user_can( $post_type->cap->edit_posts ) ) {
$own_types[] = $post_type->name;
}
}

if ( ! $any_author_types && ! $own_types ) {
return false;
}

$post_type_clauses = array();

if ( $any_author_types ) {
$post_type_clauses[] = $wpdb->prepare(
'posts.post_type IN (' . implode( ', ', array_fill( 0, count( $any_author_types ), '%s' ) ) . ')',
$any_author_types
);
}

if ( $own_types ) {
$post_type_clauses[] = $wpdb->prepare(
'( posts.post_type IN (' . implode( ', ', array_fill( 0, count( $own_types ), '%s' ) ) . ') AND posts.post_author = %d )',
array_merge( $own_types, array( get_current_user_id() ) )
);
}

/*
* One row per post, dated by its most recent open note or reply, with the
* number of open threads on it. Resolving a thread approves the note that
* starts it while its replies stay on hold, so a reply only counts while its
* thread is open.
*/
$notes_sql = "SELECT notes.comment_post_ID AS post_id, MAX( notes.comment_date_gmt ) AS latest_date_gmt, SUM( notes.comment_parent = 0 ) AS open_threads
FROM {$wpdb->comments} AS notes
INNER JOIN {$wpdb->posts} AS posts ON ( posts.ID = notes.comment_post_ID )
LEFT JOIN {$wpdb->comments} AS threads ON ( threads.comment_ID = notes.comment_parent )
WHERE notes.comment_type = 'note'
AND notes.comment_approved = '0'
AND ( notes.comment_parent = 0 OR threads.comment_approved = '0' )
AND ( " . implode( ' OR ', $post_type_clauses ) . ' )
GROUP BY notes.comment_post_ID
ORDER BY latest_date_gmt DESC';

$salt = array( wp_cache_get_last_changed( 'comment' ), wp_cache_get_last_changed( 'posts' ) );
$rows = array();

/*
* A post can still be one the user cannot edit, such as a private post of
* someone else, so the posts are checked and paged through. Paging stops
* after a few pages, so the section costs a bounded number of queries.
*/
$page_size = $total_items * 2;

for ( $page = 0; $page < 5; $page++ ) {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The query is built from prepared clauses above.
$page_sql = $wpdb->prepare( $notes_sql . ' LIMIT %d, %d', $page * $page_size, $page_size );
$cache_key = 'wp_dashboard_recent_notes:' . md5( $page_sql );
$page_rows = wp_cache_get_salted( $cache_key, 'comment-queries', $salt );

if ( ! is_array( $page_rows ) ) {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Prepared above.
$page_rows = $wpdb->get_results( $page_sql );
wp_cache_set_salted( $cache_key, $page_rows, 'comment-queries', $salt );
}

_prime_post_caches( wp_list_pluck( $page_rows, 'post_id' ), false, false );

foreach ( $page_rows as $row ) {
$edit_post_link = get_edit_post_link( (int) $row->post_id );

// Notes are only visible to users who can edit the post they belong to.
if ( ! $edit_post_link ) {
continue;
}

$rows[] = array(
'post_id' => (int) $row->post_id,
'latest_date_gmt' => $row->latest_date_gmt,
'open_threads' => (int) $row->open_threads,
'edit_post_link' => $edit_post_link,
);

if ( count( $rows ) === $total_items ) {
break 2;
}
}

if ( count( $page_rows ) < $page_size ) {
break;
}
}

if ( ! $rows ) {
return false;
}

?>
<div id="latest-notes" class="activity-block">
<h3><?php _e( 'Recent Notes' ); ?></h3>
<ul>
<?php
foreach ( $rows as $row ) :
$note_time = strtotime( $row['latest_date_gmt'] . ' +0000' );
$note_time_diff = '';

// Leave the date out for a note with no valid date.
if ( is_int( $note_time ) && $note_time > 0 ) {
/* translators: %s: Human-readable time difference. */
$note_time_diff = sprintf( __( '%s ago' ), human_time_diff( min( $note_time, time() ) ) );
}

$note_post_title = _draft_or_post_title( $row['post_id'] );

/* translators: %s: Post title. */
$note_post_label = sprintf( __( 'Edit &#8220;%s&#8221;' ), $note_post_title );

$note_count = sprintf(
/* translators: %s: Number of open note threads. */
_n( '%s open thread', '%s open threads', $row['open_threads'] ),
number_format_i18n( $row['open_threads'] )
);
?>
<li>
<span><?php echo $note_time_diff; ?></span>
<a href="<?php echo esc_url( $row['edit_post_link'] ); ?>" aria-label="<?php echo esc_attr( $note_post_label ); ?>"><?php echo $note_post_title; ?></a>
<span class="open-notes-count"><?php echo $note_count; ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php

return true;
}

/**
* Display generic dashboard RSS widget feed.
*
Expand Down
68 changes: 66 additions & 2 deletions src/wp-admin/includes/upgrade.php
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,12 @@ function wp_install(
/**
* Creates the initial content for a newly-installed site.
*
* Adds the default "Uncategorized" category, the first post (with comment),
* first page, and default widgets for default theme for the current version.
* Adds the default "Uncategorized" category, the first post (with comment
* and note), first page, and default widgets for default theme for the
* current version.
*
* @since 2.1.0
* @since 7.2.0 Adds a note to the first block of the first post.
*
* @global wpdb $wpdb WordPress database abstraction object.
* @global WP_Rewrite $wp_rewrite WordPress rewrite component.
Expand Down Expand Up @@ -306,6 +308,68 @@ function wp_install_defaults( $user_id ) {
)
);

/*
* Default note, attached to the first block of the first post.
* A note is linked to its block through the block's `metadata.noteId`
* attribute, so no note is added when the post has no blocks.
*/
if ( is_multisite() ) {
$first_note = get_site_option( 'first_note' );
}

$first_note = ! empty( $first_note ) ? $first_note : __( 'Hi, this is a note. Notes let you leave feedback on a block for anyone else working on this post. To add one, select some text or an entire block and choose Add note from its options menu. Resolve a note once its feedback has been addressed.' );

$first_block = new WP_Block_Processor( $first_post );
$note_author = get_userdata( $user_id );

if ( $first_block->next_block() && $note_author ) {
$first_block_span = $first_block->get_span();
$first_block_name = $first_block->get_printable_block_type();
$first_block_attrs = $first_block->allocate_and_return_parsed_attributes() ?? array();
$first_block_void = WP_Block_Processor::VOID === $first_block->get_delimiter_type();

// Leave a block with attributes that cannot be parsed untouched.
if ( JSON_ERROR_NONE === $first_block->get_last_json_error() && $first_block_span && $first_block_name ) {
$note_inserted = $wpdb->insert(
$wpdb->comments,
array(
'comment_post_ID' => 1,
'comment_author' => $note_author->display_name,
'comment_author_email' => $note_author->user_email,
'comment_author_url' => $note_author->user_url,
'comment_date' => $now,
'comment_date_gmt' => $now_gmt,
'comment_content' => $first_note,
// Notes are open until they are resolved, which approves them.
'comment_approved' => '0',
'comment_type' => 'note',
'user_id' => $user_id,
)
);

if ( $note_inserted ) {
$first_block_meta = isset( $first_block_attrs['metadata'] ) && is_array( $first_block_attrs['metadata'] ) ? $first_block_attrs['metadata'] : array();

$first_block_meta['noteId'] = $wpdb->insert_id;
$first_block_attrs['metadata'] = $first_block_meta;

// Only the first block's opening delimiter is rewritten, the rest of the post is kept as is.
$first_block_delimiter = sprintf(
'<!-- wp:%s %s %s-->',
strip_core_block_namespace( $first_block_name ),
serialize_block_attributes( $first_block_attrs ),
$first_block_void ? '/' : ''
);

$wpdb->update(
$wpdb->posts,
array( 'post_content' => substr_replace( $first_post, $first_block_delimiter, $first_block_span->start, $first_block_span->length ) ),
array( 'ID' => 1 )
);
}
}
}

// First page.
if ( is_multisite() ) {
$first_page = get_site_option( 'first_page' );
Expand Down
11 changes: 11 additions & 0 deletions src/wp-admin/network/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
'WPLANG',
'new_admin_email',
'first_comment_email',
'first_note',
);

// Handle translation installation.
Expand Down Expand Up @@ -395,6 +396,16 @@
</p>
</td>
</tr>
<tr>
<th scope="row"><label for="first_note"><?php _e( 'First Note' ); ?></label></th>
<td>
<textarea name="first_note" id="first_note" aria-describedby="first-note-desc" rows="5" cols="45" class="large-text">
<?php echo esc_textarea( get_site_option( 'first_note' ) ); ?></textarea>
<p class="description" id="first-note-desc">
<?php _e( 'The first note on the first post of a new site.' ); ?>
</p>
</td>
</tr>
</table>
<h2 id="wp-settings-section-upload-settings"><?php _e( 'Upload Settings' ); ?></h2>
<table class="form-table" role="presentation">
Expand Down
Loading
Loading