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 = '
'; + + $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'] ); + } +}