From 859ba08cff9008d0c15de9651add1ab07f9d2f9b Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 14 Jul 2026 08:51:39 -0700 Subject: [PATCH] Comments: Notify mentioned users and thread followers about new notes. Port the notification layer from the Gutenberg Notes @mention work. On rest_insert_comment for note comments, parse the user-N mention classes out of the saved content, email each mentioned user plus any existing followers of the thread, and subscribe the note author and everyone they mention. Followers are stored as one _wp_note_followers meta row per user on the thread's top-level note so concurrent replies cannot clobber each other, and the meta is registered for REST so follower management UI can build on it. The post author is excluded because wp_new_comment_via_rest_notify_postauthor() already notifies them of every note, the note's own author is never notified about their own note, and recipients are limited to users who can edit_comment the note so note content cannot leak. Everything honors the wp_notes_notify option. See related Gutenberg pull request: https://github.com/WordPress/gutenberg/pull/79606. Props mamaduka. Fixes #65639. --- src/wp-includes/comment.php | 343 ++++++++++++++++++ src/wp-includes/default-filters.php | 1 + .../tests/comment/wpNotifyNoteMentions.php | 336 +++++++++++++++++ 3 files changed, 680 insertions(+) create mode 100644 tests/phpunit/tests/comment/wpNotifyNoteMentions.php diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 20303dc1e7e17..5d36f8b0788a9 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2518,6 +2518,329 @@ function wp_new_comment_via_rest_notify_postauthor( $comment ) { } } +/** + * Extracts the mentioned user IDs from note content. + * + * Mentions are stored as links carrying the `wp-note-mention` class plus a + * `user-N` class token holding the mentioned user's ID: + * `@Name`. Only anchors that + * carry both classes are treated as mentions so that ordinary links cannot be + * used to address notifications. + * + * @since 7.1.0 + * + * @param string $content Note (comment) content, as stored. + * @return int[] Unique, positive mentioned user IDs. + */ +function wp_get_note_mentioned_user_ids( $content ) { + if ( ! str_contains( $content, 'next_tag( + array( + 'tag_name' => 'A', + 'class_name' => 'wp-note-mention', + ) + ) + ) { + foreach ( $processor->class_list() as $class_name ) { + if ( 1 === preg_match( '/^user-([1-9][0-9]*)$/', $class_name, $matches ) ) { + $user_ids[] = (int) $matches[1]; + break; + } + } + } + + return array_values( array_unique( $user_ids, SORT_NUMERIC ) ); +} + +/** + * Returns the ID of the top-level note that anchors a thread. + * + * Notes are a single level deep: a top-level note (`comment_parent` of 0) + * with replies hanging directly off it. Followers are tracked on the + * top-level note so a single list covers the whole thread. + * + * @since 7.1.0 + * + * @param WP_Comment $comment A note comment. + * @return int The top-level note ID for the thread. + */ +function wp_get_note_thread_root_id( $comment ) { + $parent = (int) $comment->comment_parent; + return $parent > 0 ? $parent : (int) $comment->comment_ID; +} + +/** + * Returns the user IDs following a note thread. + * + * Followers are stored as one meta row per user so that concurrent + * replies can subscribe users independently, without the lost updates a + * read-modify-write of a single array value would allow. + * + * @since 7.1.0 + * + * @param int $root_id Top-level note ID. + * @return int[] Follower user IDs. + */ +function wp_get_note_followers( $root_id ) { + $root_id = (int) $root_id; + $followers = get_comment_meta( $root_id, '_wp_note_followers' ); + if ( ! is_array( $followers ) ) { + return array(); + } + + $user_ids = array(); + foreach ( $followers as $user_id ) { + $user_id = (int) $user_id; + if ( $user_id > 0 ) { + $user_ids[] = $user_id; + } + } + + return array_values( array_unique( $user_ids, SORT_NUMERIC ) ); +} + +/** + * Adds user IDs to a note thread's follower list. + * + * @since 7.1.0 + * + * @param int $root_id Top-level note ID. + * @param int[] $user_ids User IDs to subscribe to the thread. + * @return int[] The updated follower list. + */ +function wp_add_note_followers( $root_id, $user_ids ) { + $root_id = (int) $root_id; + $followers = wp_get_note_followers( $root_id ); + + foreach ( $user_ids as $user_id ) { + $user_id = (int) $user_id; + if ( $user_id > 0 && ! in_array( $user_id, $followers, true ) ) { + add_comment_meta( $root_id, '_wp_note_followers', $user_id ); + $followers[] = $user_id; + } + } + + return $followers; +} + +/** + * Removes user IDs from a note thread's follower list. + * + * Lets a user unfollow a thread. The thread's own followers meta is deleted + * automatically with the note when the note is trashed or deleted (comment + * meta is removed alongside the comment, and deleting a top-level note also + * removes its replies), so this only needs to handle explicit unsubscribes. + * + * @since 7.1.0 + * + * @param int $root_id Top-level note ID. + * @param int[] $user_ids User IDs to unsubscribe from the thread. + * @return int[] The updated follower list. + */ +function wp_remove_note_followers( $root_id, $user_ids ) { + $root_id = (int) $root_id; + foreach ( $user_ids as $user_id ) { + $user_id = (int) $user_id; + if ( $user_id > 0 ) { + delete_comment_meta( $root_id, '_wp_note_followers', $user_id ); + } + } + + return wp_get_note_followers( $root_id ); +} + +/** + * Notifies mentioned users and thread followers about a new note. + * + * Runs on `rest_insert_comment` alongside the post-author notification sent + * by wp_new_comment_via_rest_notify_postauthor(). The recipient set is the + * union of users mentioned in this note and the existing followers of its + * thread, minus the note's own author (you are not notified about your own + * note) and the post author (already notified of every note). Mentioned + * users and the note author are then subscribed to the thread so they + * receive notifications about later replies. + * + * Only fires when a note is created, not when an existing one is edited, + * so correcting a note does not re-notify everyone who already received it. + * + * @since 7.1.0 + * + * @param WP_Comment $comment The note that was just inserted. + * @param mixed $request The REST request (unused). + * @param bool $creating Whether this is a create (true) or update (false). + */ +function wp_notify_note_mentions( $comment, $request = null, $creating = true ) { + if ( ! $creating ) { + return; + } + + if ( ! $comment instanceof WP_Comment || 'note' !== $comment->comment_type ) { + return; + } + + // Share the single user-facing notes notification preference. + if ( ! get_option( 'wp_notes_notify', 1 ) ) { + return; + } + + $root_id = wp_get_note_thread_root_id( $comment ); + $mentioned = wp_get_note_mentioned_user_ids( $comment->comment_content ); + $followers = wp_get_note_followers( $root_id ); + + $author_id = (int) $comment->user_id; + $post = get_post( (int) $comment->comment_post_ID ); + $post_author_id = $post ? (int) $post->post_author : 0; + + // Build the recipient set: mentions + current followers. + $recipient_ids = array_values( array_unique( array_merge( $mentioned, $followers ) ) ); + + /** + * Filters the user IDs notified about a new note. + * + * Receives the union of users mentioned in the note and the thread's + * existing followers. Developers can add or remove recipients, for + * example to integrate a different audience or notification channel. + * + * @since 7.1.0 + * + * @param int[] $recipient_ids Candidate recipient user IDs. + * @param WP_Comment $comment The note that was inserted. + * @param int $root_id The thread's top-level note ID. + */ + $recipient_ids = apply_filters( 'wp_note_notification_recipients', $recipient_ids, $comment, $root_id ); + + /* + * The recipient set is bounded and small (one note's mentions plus that + * thread's followers), so emails are sent synchronously here. If + * notification volume ever warrants it, the right fix is to offload + * delivery to a background queue rather than throttle within the request. + */ + foreach ( $recipient_ids as $user_id ) { + $user_id = (int) $user_id; + + // Never notify the author about their own note. + if ( $user_id === $author_id ) { + continue; + } + + // The post author is already notified of every note. + if ( $user_id === $post_author_id ) { + continue; + } + + $user = get_userdata( $user_id ); + if ( ! $user || empty( $user->user_email ) ) { + continue; + } + + /* + * Only notify users who can actually read the note. Notes are + * internal: WP_REST_Comments_Controller::check_read_permission() + * only exposes a note to its author or to users who can edit it, so + * the email audience is held to the same bar. A plain read_post + * check would leak note content to e.g. subscribers on a public + * post, who cannot see the note in the editor. + */ + if ( ! user_can( $user_id, 'edit_comment', $comment->comment_ID ) ) { + continue; + } + + $was_mentioned = in_array( $user_id, $mentioned, true ); + wp_send_note_notification( $user, $comment, $post, $was_mentioned ); + } + + /* + * Subscribe the note author and everyone mentioned to the thread. To opt + * out later, a user removes their ID from the thread root's + * `_wp_note_followers` meta: either via wp_remove_note_followers() or by + * editing that meta through the REST API (it is registered as editable + * by users who can edit the note). Removing an `@` mention on a later + * edit intentionally does not unfollow: once pulled into a thread a user + * stays subscribed until they explicitly opt out. A follower is also + * dropped automatically when the thread is trashed or deleted, since + * comment meta is removed alongside the comment. + */ + $new_followers = $mentioned; + if ( $author_id > 0 ) { + $new_followers[] = $author_id; + } + wp_add_note_followers( $root_id, $new_followers ); +} + +/** + * Sends a single note notification email. + * + * @since 7.1.0 + * + * @param WP_User $user The recipient. + * @param WP_Comment $comment The note that triggered the notification. + * @param WP_Post|null $post The post the note belongs to. + * @param bool $was_mentioned Whether the recipient was mentioned in this note. + * @return bool Whether the email was accepted for delivery by wp_mail(). + */ +function wp_send_note_notification( $user, $comment, $post, $was_mentioned ) { + $blogname = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); + $post_title = $post ? wp_specialchars_decode( get_the_title( $post ), ENT_QUOTES ) : ''; + $author_name = $comment->comment_author ? $comment->comment_author : __( 'Someone' ); + $content = wp_strip_all_tags( wp_specialchars_decode( $comment->comment_content ) ); + $edit_link = $post ? get_edit_post_link( $post->ID, 'raw' ) : ''; + + if ( $was_mentioned ) { + /* translators: %1$s: commenter name, %2$s: post title. */ + $message = sprintf( __( '%1$s mentioned you in a note on "%2$s".' ), $author_name, $post_title ); + /* translators: %1$s: site name, %2$s: post title. */ + $subject = sprintf( __( '[%1$s] You were mentioned in a note on "%2$s"' ), $blogname, $post_title ); + } else { + /* translators: %1$s: commenter name, %2$s: post title. */ + $message = sprintf( __( '%1$s added a note to a thread you follow on "%2$s".' ), $author_name, $post_title ); + /* translators: %1$s: site name, %2$s: post title. */ + $subject = sprintf( __( '[%1$s] New activity on a note you follow on "%2$s"' ), $blogname, $post_title ); + } + + $lines = array( $message, '' ); + if ( '' !== $content ) { + $lines[] = $content; + $lines[] = ''; + } + if ( $edit_link ) { + $lines[] = $edit_link; + } + + $body = implode( "\n", $lines ); + + /** + * Filters the note notification email subject. + * + * @since 7.1.0 + * + * @param string $subject Email subject. + * @param WP_User $user Recipient. + * @param WP_Comment $comment The note. + * @param bool $was_mentioned Whether the recipient was mentioned. + */ + $subject = apply_filters( 'wp_note_notification_subject', $subject, $user, $comment, $was_mentioned ); + + /** + * Filters the note notification email body. + * + * @since 7.1.0 + * + * @param string $body Email body. + * @param WP_User $user Recipient. + * @param WP_Comment $comment The note. + * @param bool $was_mentioned Whether the recipient was mentioned. + */ + $body = apply_filters( 'wp_note_notification_text', $body, $user, $comment, $was_mentioned ); + + return wp_mail( $user->user_email, wp_specialchars_decode( $subject ), $body ); +} + /** * Sets the status of a comment. * @@ -4250,6 +4573,7 @@ function _wp_check_for_scheduled_update_comment_type() { * Register initial note status meta. * * @since 6.9.0 + * @since 7.1.0 Registers the note followers meta. */ function wp_create_initial_comment_meta() { register_meta( @@ -4270,6 +4594,25 @@ function wp_create_initial_comment_meta() { }, ) ); + + register_meta( + 'comment', + '_wp_note_followers', + array( + 'type' => 'integer', + 'description' => __( 'User IDs following the note thread.' ), + 'single' => false, + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + ), + 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { + return current_user_can( 'edit_comment', $object_id ); + }, + ) + ); } /** diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 3702c17b418e8..965ff3b12ae03 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -542,6 +542,7 @@ add_action( 'comment_post', 'wp_new_comment_notify_moderator' ); add_action( 'comment_post', 'wp_new_comment_notify_postauthor' ); add_action( 'rest_insert_comment', 'wp_new_comment_via_rest_notify_postauthor' ); +add_action( 'rest_insert_comment', 'wp_notify_note_mentions', 10, 3 ); add_action( 'after_password_reset', 'wp_password_change_notification' ); add_action( 'register_new_user', 'wp_send_new_user_notifications' ); add_action( 'edit_user_created_user', 'wp_send_new_user_notifications', 10, 2 ); diff --git a/tests/phpunit/tests/comment/wpNotifyNoteMentions.php b/tests/phpunit/tests/comment/wpNotifyNoteMentions.php new file mode 100644 index 0000000000000..16897c3c43ef1 --- /dev/null +++ b/tests/phpunit/tests/comment/wpNotifyNoteMentions.php @@ -0,0 +1,336 @@ +user->create_and_get( array( 'role' => 'editor' ) ); + self::$commenter = $factory->user->create_and_get( array( 'role' => 'editor' ) ); + self::$mentioned = $factory->user->create_and_get( array( 'role' => 'editor' ) ); + + self::$post = $factory->post->create_and_get( array( 'post_author' => self::$post_author->ID ) ); + } + + public function set_up() { + parent::set_up(); + $this->sent_to = array(); + // Short-circuit wp_mail() and record who would have been emailed. + add_filter( 'pre_wp_mail', array( $this, 'capture_mail' ), 10, 2 ); + } + + /** + * Records wp_mail() recipients and short-circuits delivery. + * + * @param null $short_circuit Short-circuit value. + * @param array $atts wp_mail() arguments. + * @return bool Always true to indicate a "sent" message. + */ + public function capture_mail( $short_circuit, $atts ) { + foreach ( (array) $atts['to'] as $to ) { + $this->sent_to[] = $to; + } + return true; + } + + /** + * Builds a note comment for the shared post. + * + * @param string $content Note content. + * @param int $user_id Author user ID. + * @param int $parent_id Parent note ID (0 for a top-level note). + * @return WP_Comment The inserted note. + */ + private function insert_note( $content, $user_id, $parent_id = 0 ) { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post->ID, + 'comment_type' => 'note', + 'comment_content' => $content, + 'comment_parent' => $parent_id, + 'user_id' => $user_id, + ) + ); + + return get_comment( $comment_id ); + } + + /** + * Builds the stored markup for a mention of the given user. + * + * @param int $user_id User ID to mention. + * @param string $label Optional. The mention's visible text. + * @return string The mention anchor markup. + */ + private function get_mention_markup( $user_id, $label = '@Mentioned' ) { + return sprintf( '%s', $user_id, $label ); + } + + /** + * @ticket 65622 + * + * @covers ::wp_get_note_mentioned_user_ids + */ + public function test_parses_mentioned_user_ids(): void { + $content = '

Hi @Jane and ' + . '@Bob.

'; + + $this->assertSame( array( 5, 9 ), wp_get_note_mentioned_user_ids( $content ) ); + } + + /** + * @ticket 65622 + * + * @covers ::wp_get_note_mentioned_user_ids + */ + public function test_ignores_plain_links_and_deduplicates(): void { + $content = '

not a mention ' + . '@Jane ' + . '@Jane again ' + . 'no user class

'; + + $this->assertSame( array( 5 ), wp_get_note_mentioned_user_ids( $content ) ); + } + + /** + * @ticket 65622 + * + * @covers ::wp_get_note_thread_root_id + */ + public function test_thread_root_is_parent_for_replies(): void { + $root = $this->insert_note( 'Top level', self::$commenter->ID ); + $reply = $this->insert_note( 'A reply', self::$commenter->ID, $root->comment_ID ); + + $this->assertSame( (int) $root->comment_ID, wp_get_note_thread_root_id( $reply ) ); + $this->assertSame( (int) $root->comment_ID, wp_get_note_thread_root_id( $root ) ); + } + + /** + * @ticket 65622 + * + * @covers ::wp_notify_note_mentions + * @covers ::wp_send_note_notification + * @covers ::wp_add_note_followers + * @covers ::wp_get_note_followers + */ + public function test_mentioned_user_is_emailed_and_subscribed(): void { + $mention = $this->get_mention_markup( self::$mentioned->ID ); + $note = $this->insert_note( "Ping $mention", self::$commenter->ID ); + + wp_notify_note_mentions( $note ); + + $this->assertContains( self::$mentioned->user_email, $this->sent_to ); + + // The mentioned user and the note author both follow the thread now. + $followers = wp_get_note_followers( $note->comment_ID ); + $this->assertContains( self::$mentioned->ID, $followers ); + $this->assertContains( self::$commenter->ID, $followers ); + } + + /** + * @ticket 65622 + * + * @covers ::wp_notify_note_mentions + */ + public function test_author_is_not_notified_about_their_own_note(): void { + $self_mention = $this->get_mention_markup( self::$commenter->ID, '@Me' ); + $note = $this->insert_note( "Note to $self_mention", self::$commenter->ID ); + + wp_notify_note_mentions( $note ); + + $this->assertNotContains( self::$commenter->user_email, $this->sent_to ); + } + + /** + * @ticket 65622 + * + * @covers ::wp_notify_note_mentions + */ + public function test_post_author_is_left_to_the_postauthor_notification(): void { + $mention = $this->get_mention_markup( self::$post_author->ID, '@Author' ); + $note = $this->insert_note( "Hey $mention", self::$commenter->ID ); + + wp_notify_note_mentions( $note ); + + // wp_new_comment_via_rest_notify_postauthor() notifies the post author + // of every note; the mention path must not also email them or they + // would receive a duplicate. + $this->assertNotContains( self::$post_author->user_email, $this->sent_to ); + } + + /** + * @ticket 65622 + * + * @covers ::wp_notify_note_mentions + * @covers ::wp_get_note_followers + */ + public function test_mentioned_user_without_note_access_is_not_emailed(): void { + $subscriber_user = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + + $mention = $this->get_mention_markup( $subscriber_user->ID, '@Subscriber' ); + $note = $this->insert_note( "Ping $mention", self::$commenter->ID ); + + wp_notify_note_mentions( $note ); + + // Notes are only readable by users who can edit them; a subscriber + // cannot, so emailing them would leak content they cannot see. + $this->assertNotContains( $subscriber_user->user_email, $this->sent_to ); + + // They are still recorded as a follower in case their role changes. + $this->assertContains( $subscriber_user->ID, wp_get_note_followers( $note->comment_ID ) ); + } + + /** + * @ticket 65622 + * + * @covers ::wp_notify_note_mentions + * @covers ::wp_get_note_thread_root_id + */ + public function test_followers_are_notified_of_replies(): void { + $mention = $this->get_mention_markup( self::$mentioned->ID ); + $root = $this->insert_note( "Start $mention", self::$commenter->ID ); + wp_notify_note_mentions( $root ); + + // A different user replies; the mentioned user follows and should be + // notified even though they are not mentioned in the reply itself. + $this->sent_to = array(); + $replier = self::factory()->user->create_and_get( array( 'role' => 'editor' ) ); + $reply = $this->insert_note( 'Following up', $replier->ID, $root->comment_ID ); + wp_notify_note_mentions( $reply ); + + $this->assertContains( self::$mentioned->user_email, $this->sent_to ); + } + + /** + * @ticket 65622 + * + * @covers ::wp_notify_note_mentions + */ + public function test_no_notifications_when_disabled(): void { + update_option( 'wp_notes_notify', 0 ); + + $mention = $this->get_mention_markup( self::$mentioned->ID ); + $note = $this->insert_note( "Ping $mention", self::$commenter->ID ); + wp_notify_note_mentions( $note ); + + $this->assertEmpty( $this->sent_to ); + + update_option( 'wp_notes_notify', 1 ); + } + + /** + * @ticket 65622 + * + * @covers ::wp_notify_note_mentions + */ + public function test_editing_a_note_does_not_renotify(): void { + $mention = $this->get_mention_markup( self::$mentioned->ID ); + $note = $this->insert_note( "Ping $mention", self::$commenter->ID ); + + // Simulate the update path of rest_insert_comment ($creating false). + wp_notify_note_mentions( $note, null, false ); + + $this->assertEmpty( $this->sent_to ); + } + + /** + * @ticket 65622 + * + * @covers ::wp_notify_note_mentions + */ + public function test_recipients_filter_can_add_and_remove(): void { + $extra_user = self::factory()->user->create_and_get( array( 'role' => 'editor' ) ); + + $filter = function ( $ids ) use ( $extra_user ) { + $ids[] = $extra_user->ID; + return array_values( array_diff( $ids, array( self::$mentioned->ID ) ) ); + }; + add_filter( 'wp_note_notification_recipients', $filter ); + + $mention = $this->get_mention_markup( self::$mentioned->ID ); + $note = $this->insert_note( "Ping $mention", self::$commenter->ID ); + wp_notify_note_mentions( $note ); + + $this->assertContains( $extra_user->user_email, $this->sent_to ); + $this->assertNotContains( self::$mentioned->user_email, $this->sent_to ); + } + + /** + * @ticket 65622 + * + * @covers ::wp_add_note_followers + * @covers ::wp_remove_note_followers + * @covers ::wp_get_note_followers + */ + public function test_followers_can_be_removed(): void { + $note = $this->insert_note( 'Top level', self::$commenter->ID ); + wp_add_note_followers( $note->comment_ID, array( self::$commenter->ID, self::$mentioned->ID ) ); + + $remaining = wp_remove_note_followers( $note->comment_ID, array( self::$mentioned->ID ) ); + + $this->assertSame( array( self::$commenter->ID ), $remaining ); + $this->assertSame( array( self::$commenter->ID ), wp_get_note_followers( $note->comment_ID ) ); + + // Removing the last follower clears the meta entirely. + wp_remove_note_followers( $note->comment_ID, array( self::$commenter->ID ) ); + $this->assertSame( '', get_comment_meta( $note->comment_ID, '_wp_note_followers', true ) ); + } + + /** + * @ticket 65622 + * + * @covers ::wp_create_initial_comment_meta + */ + public function test_followers_meta_is_registered_for_rest(): void { + wp_create_initial_comment_meta(); + + $registered = get_registered_meta_keys( 'comment' ); + + $this->assertArrayHasKey( '_wp_note_followers', $registered ); + $this->assertNotFalse( $registered['_wp_note_followers']['show_in_rest'] ); + } +}