diff --git a/includes/class-newspack.php b/includes/class-newspack.php
index 1b41a53b9d..3bde777a1f 100644
--- a/includes/class-newspack.php
+++ b/includes/class-newspack.php
@@ -105,9 +105,9 @@ private function includes() {
include_once NEWSPACK_ABSPATH . 'includes/reader-activation/sync/class-woocommerce.php';
include_once NEWSPACK_ABSPATH . 'includes/reader-activation/sync/class-contact-sync.php';
include_once NEWSPACK_ABSPATH . 'includes/reader-activation/sync/class-contact-sync-admin.php';
+ include_once NEWSPACK_ABSPATH . 'includes/reader-activation/class-comment-display-name.php';
include_once NEWSPACK_ABSPATH . 'includes/class-action-scheduler.php';
include_once NEWSPACK_ABSPATH . 'includes/reader-activation/class-integrations.php';
- \Newspack\Reader_Activation\Integrations::init();
include_once NEWSPACK_ABSPATH . 'includes/reader-activation/class-promoted-fields.php';
include_once NEWSPACK_ABSPATH . 'includes/reader-activation/class-session-hydration.php';
include_once NEWSPACK_ABSPATH . 'includes/data-events/class-utils.php';
diff --git a/includes/reader-activation/class-comment-display-name.php b/includes/reader-activation/class-comment-display-name.php
new file mode 100644
index 0000000000..c968be5999
--- /dev/null
+++ b/includes/reader-activation/class-comment-display-name.php
@@ -0,0 +1,122 @@
+ID );
+ }
+
+ /**
+ * Validate and save the display name before a comment is inserted.
+ *
+ * Runs on `preprocess_comment` so the updated display name is used as the
+ * comment author, rather than the stale email-derived name WordPress read
+ * from the user profile earlier in the request.
+ *
+ * @param array $commentdata Comment data.
+ * @return array Comment data with updated comment_author.
+ */
+ public static function validate_display_name( $commentdata ) {
+ if ( ! self::should_prompt() ) {
+ return $commentdata;
+ }
+
+ $display_name = isset( $_POST['comment_display_name'] ) ? \sanitize_text_field( \wp_unslash( $_POST['comment_display_name'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
+
+ if ( empty( $display_name ) ) {
+ \wp_die(
+ esc_html__( 'Please enter a display name.', 'newspack-plugin' ),
+ esc_html__( 'Comment Submission Failure', 'newspack-plugin' ),
+ [ 'back_link' => true ]
+ );
+ }
+
+ $user = \wp_get_current_user();
+ $email = $user->user_email;
+ if (
+ Reader_Activation::generate_user_nicename( $email ) === $display_name ||
+ Reader_Activation::strip_email_domain( $email ) === $display_name
+ ) {
+ \wp_die(
+ esc_html__( 'Please choose a display name that is not derived from your email address.', 'newspack-plugin' ),
+ esc_html__( 'Comment Submission Failure', 'newspack-plugin' ),
+ [ 'back_link' => true ]
+ );
+ }
+
+ // Update the user profile.
+ $user_data = [
+ 'ID' => $user->ID,
+ 'display_name' => $display_name,
+ ];
+
+ $name_parts = explode( ' ', $display_name, 2 );
+ $user_data['first_name'] = $name_parts[0];
+ $user_data['last_name'] = $name_parts[1] ?? '';
+
+ \wp_update_user( $user_data );
+
+ // Override the comment author so this comment uses the new name.
+ $commentdata['comment_author'] = $display_name;
+
+ return $commentdata;
+ }
+
+ /**
+ * Render the display name field before the comment form submit button.
+ *
+ * @param string $submit_field The submit field HTML.
+ * @return string The submit field HTML, with display name field prepended if needed.
+ */
+ public static function render_display_name_field( $submit_field ) {
+ if ( ! self::should_prompt() ) {
+ return $submit_field;
+ }
+
+ $field = '
';
+
+ return $field . $submit_field;
+ }
+}
+Comment_Display_Name::init();
diff --git a/includes/reader-activation/class-integrations.php b/includes/reader-activation/class-integrations.php
index ff2b89d609..18fb915efd 100644
--- a/includes/reader-activation/class-integrations.php
+++ b/includes/reader-activation/class-integrations.php
@@ -546,3 +546,4 @@ public static function run_health_checks() {
}
}
}
+Integrations::init();
diff --git a/tests/unit-tests/comment-display-name.php b/tests/unit-tests/comment-display-name.php
new file mode 100644
index 0000000000..742f5b8544
--- /dev/null
+++ b/tests/unit-tests/comment-display-name.php
@@ -0,0 +1,231 @@
+create_generic_reader();
+
+ $output = Comment_Display_Name::render_display_name_field( '' );
+
+ $this->assertStringContainsString( 'name="comment_display_name"', $output );
+ $this->assertStringContainsString( 'required', $output );
+ $this->assertStringContainsString( '', $output );
+
+ wp_delete_user( $user_id );
+ }
+
+ /**
+ * Test that the display name field does not render for a reader with a custom display name.
+ */
+ public function test_does_not_render_for_custom_display_name() {
+ $user_id = $this->create_generic_reader();
+ wp_update_user(
+ [
+ 'ID' => $user_id,
+ 'display_name' => 'Jane Doe',
+ ]
+ );
+
+ $output = Comment_Display_Name::render_display_name_field( '' );
+
+ $this->assertEquals( '', $output );
+
+ wp_delete_user( $user_id );
+ }
+
+ /**
+ * Test that the display name field does not render for non-reader users.
+ */
+ public function test_does_not_render_for_non_reader() {
+ $admin_id = wp_insert_user(
+ [
+ 'user_login' => 'test-admin',
+ 'user_pass' => wp_generate_password(),
+ 'user_email' => 'admin@example.com',
+ 'role' => 'administrator',
+ ]
+ );
+ wp_set_current_user( $admin_id );
+
+ $output = Comment_Display_Name::render_display_name_field( '' );
+
+ $this->assertEquals( '', $output );
+
+ wp_delete_user( $admin_id );
+ }
+
+ /**
+ * Test that the display name field does not render for logged-out users.
+ */
+ public function test_does_not_render_for_logged_out() {
+ wp_set_current_user( 0 );
+
+ $output = Comment_Display_Name::render_display_name_field( '' );
+
+ $this->assertEquals( '', $output );
+ }
+
+ /**
+ * Test that validation rejects an empty display name.
+ */
+ public function test_validate_rejects_empty_display_name() {
+ $user_id = $this->create_generic_reader();
+ $_POST['comment_display_name'] = '';
+
+ $commentdata = [
+ 'comment_post_ID' => $this->factory->post->create(),
+ 'user_id' => $user_id,
+ ];
+
+ $this->expectException( WPDieException::class );
+ Comment_Display_Name::validate_display_name( $commentdata );
+
+ wp_delete_user( $user_id );
+ unset( $_POST['comment_display_name'] );
+ }
+
+ /**
+ * Test that validation rejects a display name that matches the generic pattern.
+ */
+ public function test_validate_rejects_generic_display_name() {
+ $user_id = $this->create_generic_reader( 'john.smith@example.com' );
+ $_POST['comment_display_name'] = 'john.smith';
+
+ $commentdata = [
+ 'comment_post_ID' => $this->factory->post->create(),
+ 'user_id' => $user_id,
+ ];
+
+ $this->expectException( WPDieException::class );
+ Comment_Display_Name::validate_display_name( $commentdata );
+
+ wp_delete_user( $user_id );
+ unset( $_POST['comment_display_name'] );
+ }
+
+ /**
+ * Test that validation passes with a valid display name and sets comment_author.
+ */
+ public function test_validate_accepts_valid_display_name() {
+ $user_id = $this->create_generic_reader();
+ $_POST['comment_display_name'] = 'Jane Doe';
+
+ $commentdata = [
+ 'comment_post_ID' => $this->factory->post->create(),
+ 'user_id' => $user_id,
+ ];
+
+ $result = Comment_Display_Name::validate_display_name( $commentdata );
+ $this->assertEquals( 'Jane Doe', $result['comment_author'] );
+
+ wp_delete_user( $user_id );
+ unset( $_POST['comment_display_name'] );
+ }
+
+ /**
+ * Test that validation passes for non-reader users without the field.
+ */
+ public function test_validate_skips_non_reader() {
+ $admin_id = wp_insert_user(
+ [
+ 'user_login' => 'test-admin-2',
+ 'user_pass' => wp_generate_password(),
+ 'user_email' => 'admin2@example.com',
+ 'role' => 'administrator',
+ ]
+ );
+ wp_set_current_user( $admin_id );
+
+ $commentdata = [
+ 'comment_post_ID' => $this->factory->post->create(),
+ 'user_id' => $admin_id,
+ ];
+
+ $result = Comment_Display_Name::validate_display_name( $commentdata );
+ $this->assertEquals( $commentdata, $result );
+
+ wp_delete_user( $admin_id );
+ }
+
+ /**
+ * Test that validation saves the display name to the user profile and updates comment_author.
+ */
+ public function test_validate_saves_display_name() {
+ $user_id = $this->create_generic_reader();
+ $_POST['comment_display_name'] = 'Jane Doe';
+
+ $commentdata = [
+ 'comment_post_ID' => $this->factory->post->create(),
+ 'user_id' => $user_id,
+ 'comment_author' => 'jane-doe', // Generic name set by WP before preprocess_comment.
+ ];
+
+ $result = Comment_Display_Name::validate_display_name( $commentdata );
+
+ // Comment author should be updated.
+ $this->assertEquals( 'Jane Doe', $result['comment_author'] );
+
+ // User profile should be updated.
+ $user = get_userdata( $user_id );
+ $this->assertEquals( 'Jane Doe', $user->display_name );
+ $this->assertEquals( 'Jane', $user->first_name );
+ $this->assertEquals( 'Doe', $user->last_name );
+ $this->assertFalse( Reader_Activation::reader_has_generic_display_name( $user_id ) );
+
+ wp_delete_user( $user_id );
+ unset( $_POST['comment_display_name'] );
+ }
+
+ /**
+ * Test that validation handles single-word display names.
+ */
+ public function test_validate_saves_single_word_name() {
+ $user_id = $this->create_generic_reader();
+ $_POST['comment_display_name'] = 'Madonna';
+
+ $commentdata = [
+ 'comment_post_ID' => $this->factory->post->create(),
+ 'user_id' => $user_id,
+ 'comment_author' => 'jane-doe',
+ ];
+
+ $result = Comment_Display_Name::validate_display_name( $commentdata );
+
+ $this->assertEquals( 'Madonna', $result['comment_author'] );
+
+ $user = get_userdata( $user_id );
+ $this->assertEquals( 'Madonna', $user->display_name );
+ $this->assertFalse( Reader_Activation::reader_has_generic_display_name( $user_id ) );
+
+ wp_delete_user( $user_id );
+ unset( $_POST['comment_display_name'] );
+ }
+}