From 5956aa5671592e722970d6c06258cdf42002110a Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 2 Oct 2025 07:21:26 +0300 Subject: [PATCH 01/21] Update the feedback class to have is_unread property --- .../forms/src/contact-form/class-feedback.php | 90 +++++++++++++++++++ .../tests/php/contact-form/Feedback_Test.php | 81 +++++++++++++++++ .../tests/php/contact-form/class-utility.php | 18 ++-- 3 files changed, 181 insertions(+), 8 deletions(-) diff --git a/projects/packages/forms/src/contact-form/class-feedback.php b/projects/packages/forms/src/contact-form/class-feedback.php index 876b3debe3762..32502261a402c 100644 --- a/projects/packages/forms/src/contact-form/class-feedback.php +++ b/projects/packages/forms/src/contact-form/class-feedback.php @@ -17,6 +17,20 @@ class Feedback { const POST_TYPE = 'feedback'; + /** + * Comment status for unread feedback. + * + * @var string + */ + private const STATUS_UNREAD = 'open'; + + /** + * Comment status for read feedback. + * + * @var string + */ + private const STATUS_READ = 'closed'; + /** * The form field values. * @@ -111,6 +125,20 @@ class Feedback { */ protected $has_consent = false; + /** + * Whether the feedback entry is unread. + * + * @var bool + */ + protected $is_unread = true; + + /** + * The post ID of the feedback entry. + * + * @var int|null + */ + protected $post_id = null; + /** * The entry object of the post that the feedback was submitted from. * @@ -151,9 +179,11 @@ private function load_from_post( WP_Post $feedback_post ) { $parsed_content = $this->parse_content( $feedback_post->post_content, $feedback_post->post_mime_type ); + $this->post_id = $feedback_post->ID; $this->status = $feedback_post->post_status; $this->legacy_feedback_id = $feedback_post->post_name; $this->feedback_time = $feedback_post->post_date; + $this->is_unread = $feedback_post->comment_status === self::STATUS_UNREAD; $this->fields = $parsed_content['fields'] ?? array(); @@ -706,6 +736,65 @@ public function has_file() { return $this->has_file; } + /** + * Check if the feedback is unread. + * + * @return bool + */ + public function is_unread() { + return $this->is_unread; + } + + /** + * Mark the feedback as read. + * + * @return bool True on success, false on failure. + */ + public function mark_as_read() { + if ( ! $this->post_id ) { + return false; + } + + $updated = wp_update_post( + array( + 'ID' => $this->post_id, + 'comment_status' => self::STATUS_READ, + ) + ); + + if ( ! is_wp_error( $updated ) && $updated ) { + $this->is_unread = false; + return true; + } + + return false; + } + + /** + * Mark the feedback as unread. + * + * @return bool True on success, false on failure. + */ + public function mark_as_unread() { + if ( ! $this->post_id ) { + return false; + } + + $updated = wp_update_post( + array( + 'ID' => $this->post_id, + 'comment_status' => self::STATUS_UNREAD, + ) + ); + + if ( ! is_wp_error( $updated ) && $updated ) { + $this->is_unread = true; + return true; + } + + return false; + } + /** * Get the uploaded files from the feedback entry. * @@ -817,6 +906,7 @@ public function save() { 'post_content' => $this->serialize(), // In V3 we started to addslashes. 'post_mime_type' => 'v3', // a way to help us identify what version of the data this is. 'post_parent' => $this->source->get_id(), + 'comment_status' => self::STATUS_UNREAD, // New feedback is unread by default. ) ); diff --git a/projects/packages/forms/tests/php/contact-form/Feedback_Test.php b/projects/packages/forms/tests/php/contact-form/Feedback_Test.php index a3bc52b097389..68a3e4dfb365a 100644 --- a/projects/packages/forms/tests/php/contact-form/Feedback_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Feedback_Test.php @@ -2512,4 +2512,85 @@ public function test_special_characters_handling() { $this->assertEquals( 'こんにちは世界', $saved_response->get_field_value_by_label( 'Special' ), 'Special field value should match saved value' ); $this->assertEquals( '🙈', $saved_response->get_field_value_by_label( 'Message' ), 'Message field value should match saved value' ); } + + public function test_mark_as_read() { + $post_id = Utility::create_legacy_feedback( + array( + 'name' => 'Test User', + 'email' => 'test@example.com', + ), + 'Test message', + 'Test User', + 'test@example.com', + '', + '', + 'Test Subject', + 'spam', + null, + true // is_unread + ); + + $feedback = Feedback::get( $post_id ); + $this->assertTrue( $feedback->is_unread(), 'Feedback should start as unread' ); + + $result = $feedback->mark_as_read(); + $this->assertTrue( $result, 'mark_as_read should return true on success' ); + $this->assertFalse( $feedback->is_unread(), 'Feedback should be marked as read' ); + + // Then mark as unread + $result = $feedback->mark_as_unread(); + $this->assertTrue( $result, 'mark_as_unread should return true on success' ); + $this->assertTrue( $feedback->is_unread(), 'Feedback should be marked as unread' ); + } + + public function test_mark_as_read_without_post_id() { + $form = new Contact_Form( array() ); + $response = Feedback::from_submission( array(), $form ); + $response->save(); + + // Should return false if not saved yet (no post_id) + $result = $response->mark_as_read(); + $this->assertFalse( $result, 'mark_as_read should return false when post_id is not set' ); + } + + public function test_mark_as_unread_without_post_id() { + $form = new Contact_Form( array() ); + $response = Feedback::from_submission( array(), $form ); + + // Should return false if not saved yet (no post_id) + $result = $response->mark_as_unread(); + $this->assertFalse( $result, 'mark_as_unread should return false when post_id is not set' ); + } + + public function test_unread_status_uses_constants() { + $post_id = Utility::create_legacy_feedback( + array( + 'name' => 'Test User', + 'email' => 'test@example.com', + ), + 'Test message', + 'Test User', + 'test@example.com', + '', + '', + 'Test Subject', + 'spam', + null, + true // unread + ); + + $feedback = Feedback::get( $post_id ); + + // Check the comment_status field directly + $post = get_post( $post_id ); + $this->assertEquals( 'open', $post->comment_status, 'Unread feedback should have comment_status = open' ); + + $feedback->mark_as_read(); + $post = get_post( $post_id ); + $this->assertEquals( 'closed', $post->comment_status, 'Read feedback should have comment_status = closed' ); + + $feedback->mark_as_unread(); + $post = get_post( $post_id ); + $this->assertEquals( 'open', $post->comment_status, 'Unread feedback should have comment_status = open' ); + } } diff --git a/projects/packages/forms/tests/php/contact-form/class-utility.php b/projects/packages/forms/tests/php/contact-form/class-utility.php index 3045424b46921..848f839a00cb0 100644 --- a/projects/packages/forms/tests/php/contact-form/class-utility.php +++ b/projects/packages/forms/tests/php/contact-form/class-utility.php @@ -32,7 +32,8 @@ public static function create_legacy_feedback( $comment_ip_text = 'https://127.0.0.1', $subject = 'Test Subject', $status = 'publish', - $strip_new_lines = false + $strip_new_lines = false, + $is_unread = false ) { global $post; $feedback_time = current_time( 'mysql' ); @@ -73,13 +74,14 @@ public static function create_legacy_feedback( // Create a mock post with JSON_DATA format return wp_insert_post( array( - 'post_date' => addslashes( $feedback_time ), - 'post_type' => 'feedback', - 'post_status' => addslashes( $status ), - 'post_parent' => $post ? $post->ID : 0, - 'post_title' => addslashes( wp_kses( $feedback_title, array() ) ), - 'post_content' => $content, // so that search will pick up this data - 'post_name' => $feedback_id, + 'post_date' => addslashes( $feedback_time ), + 'post_type' => 'feedback', + 'post_status' => addslashes( $status ), + 'post_parent' => $post ? $post->ID : 0, + 'post_title' => addslashes( wp_kses( $feedback_title, array() ) ), + 'post_content' => $content, // so that search will pick up this data + 'post_name' => $feedback_id, + 'comment_status' => $is_unread ? 'open' : 'closed', ) ); } From 0adfba1f09b6f38f01a6fbe976d30573d60e00dc Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 2 Oct 2025 07:23:32 +0300 Subject: [PATCH 02/21] Add new endpoint that lets us marks things as read and unread --- .../class-contact-form-endpoint.php | 74 +++++++++ .../Contact_Form_Endpoint_Test.php | 141 ++++++++++++++++++ 2 files changed, 215 insertions(+) diff --git a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php index 949d4305961ab..9ee2215874f2a 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php @@ -254,6 +254,29 @@ public function register_routes() { 'callback' => array( $this, 'get_forms_config' ), ) ); + + // Mark feedback as read/unread endpoint. + register_rest_route( + $this->namespace, + $this->rest_base . '/(?P\d+)/read', + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'update_read_status' ), + 'permission_callback' => array( $this, 'update_item_permissions_check' ), + 'args' => array( + 'id' => array( + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + ), + 'is_unread' => array( + 'type' => 'boolean', + 'required' => true, + 'sanitize_callback' => 'rest_sanitize_boolean', + ), + ), + ) + ); } /** @@ -488,6 +511,16 @@ public function get_item_schema() { 'readonly' => true, ); + $schema['properties']['is_unread'] = array( + 'description' => __( 'Whether the form response is unread.', 'jetpack-forms' ), + 'type' => 'boolean', + 'context' => array( 'view', 'edit', 'embed' ), + 'arg_options' => array( + 'sanitize_callback' => 'rest_sanitize_boolean', + ), + 'readonly' => true, + ); + $this->schema = $schema; return $this->add_additional_fields_schema( $this->schema ); @@ -631,6 +664,10 @@ public function prepare_item_for_response( $item, $request ) { $data['has_file'] = $feedback_response->has_file(); } + if ( rest_is_field_included( 'is_unread', $fields ) ) { + $data['is_unread'] = $feedback_response->is_unread(); + } + $response->set_data( $data ); return rest_ensure_response( $response ); @@ -1033,6 +1070,43 @@ public function disable_integration( $request ) { return rest_ensure_response( array( 'deleted' => $is_deleted ) ); } + /** + * Updates the read/unread status of a feedback item. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function update_read_status( $request ) { + $post_id = $request->get_param( 'id' ); + $is_unread = $request->get_param( 'is_unread' ); + + $feedback_response = Feedback::get( $post_id ); + if ( ! $feedback_response ) { + return new WP_Error( + 'rest_post_invalid_id', + __( 'Invalid feedback ID.', 'jetpack-forms' ), + array( 'status' => 404 ) + ); + } + + $success = $is_unread ? $feedback_response->mark_as_unread() : $feedback_response->mark_as_read(); + + if ( ! $success ) { + return new WP_Error( + 'rest_cannot_update', + __( 'Failed to update feedback read status.', 'jetpack-forms' ), + array( 'status' => 500 ) + ); + } + + return rest_ensure_response( + array( + 'id' => $post_id, + 'is_unread' => $feedback_response->is_unread(), + ) + ); + } + /** * Return consolidated Forms config payload. * diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php index a3b9567149cb0..d510e047a5704 100644 --- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php @@ -140,6 +140,7 @@ public function test_item_schema() { $this->assertArrayHasKey( 'entry_permalink', $schema_properties ); $this->assertArrayHasKey( 'subject', $schema_properties ); $this->assertArrayHasKey( 'fields', $schema_properties ); + $this->assertArrayHasKey( 'is_unread', $schema_properties ); } /** @@ -674,4 +675,144 @@ public function test_prepare_item_for_response_without_consent() { $this->assertArrayHasKey( 'has_file', $data ); $this->assertFalse( $data['has_file'] ); } + + /** + * Test default unread state on new feedback + */ + public function test_feedback_is_unread_by_default() { + $post_id = Utility::create_legacy_feedback( + array( + 'name' => 'Test User', + 'email' => 'test@example.com', + ), + 'Test message', + 'Test User', + 'test@example.com', + '', + '', + 'Test Subject', + 'spam', + null, + true // is_unread + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/feedback/' . $post_id ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayHasKey( 'is_unread', $data ); + $this->assertTrue( $data['is_unread'] ); + + // Verify Feedback class method + $feedback = \Automattic\Jetpack\Forms\ContactForm\Feedback::get( $post_id ); + $this->assertTrue( $feedback->is_unread() ); + } + + /** + * Test marking feedback as read + */ + public function test_mark_feedback_as_read() { + $post_id = Utility::create_legacy_feedback( + array( + 'name' => 'Test User', + 'email' => 'test@example.com', + ), + 'Test message', + 'Test User', + 'test@example.com' + ); + + // Mark as read + $request = new WP_REST_Request( 'POST', '/wp/v2/feedback/' . $post_id . '/read' ); + $request->set_param( 'is_unread', false ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( $post_id, $data['id'] ); + $this->assertFalse( $data['is_unread'] ); + + // Verify the state persists + $request = new WP_REST_Request( 'GET', '/wp/v2/feedback/' . $post_id ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertFalse( $data['is_unread'] ); + + // Verify Feedback class method + $feedback = \Automattic\Jetpack\Forms\ContactForm\Feedback::get( $post_id ); + $this->assertFalse( $feedback->is_unread() ); + } + + /** + * Test marking feedback as unread + */ + public function test_mark_feedback_as_unread() { + $post_id = Utility::create_legacy_feedback( + array( + 'name' => 'Test User', + 'email' => 'test@example.com', + ), + 'Test message', + 'Test User', + 'test@example.com' + ); + + // First mark as read + wp_update_post( + array( + 'ID' => $post_id, + 'comment_status' => 'closed', + ) + ); + + // Then mark as unread + $request = new WP_REST_Request( 'POST', '/wp/v2/feedback/' . $post_id . '/read' ); + $request->set_param( 'is_unread', true ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( $post_id, $data['id'] ); + $this->assertTrue( $data['is_unread'] ); + + // Verify Feedback class method + $feedback = \Automattic\Jetpack\Forms\ContactForm\Feedback::get( $post_id ); + $this->assertTrue( $feedback->is_unread() ); + } + + /** + * Test marking feedback with invalid ID + */ + public function test_mark_feedback_with_invalid_id() { + $request = new WP_REST_Request( 'POST', '/wp/v2/feedback/999999/read' ); + $request->set_param( 'is_unread', false ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_post_invalid_id', $data['code'] ); + } + + /** + * Test unauthorized access to mark feedback + */ + public function test_mark_feedback_unauthorized() { + $post_id = Utility::create_legacy_feedback( + array( + 'name' => 'Test User', + 'email' => 'test@example.com', + ), + 'Test message', + 'Test User', + 'test@example.com' + ); + + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'POST', '/wp/v2/feedback/' . $post_id . '/read' ); + $request->set_param( 'is_unread', false ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 401, $response->get_status() ); + } } From 4ab077c086cdfd524f4188f406d4cb8275649c01 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 2 Oct 2025 07:25:28 +0300 Subject: [PATCH 03/21] Update the UI that lets us shows us things that are unread and mark them as read once they are viewed --- .../src/dashboard/hooks/use-inbox-data.ts | 18 +++++++++++-- .../src/dashboard/inbox/dataviews/index.js | 18 +++++++++++++ .../forms/src/dashboard/inbox/response.js | 26 ++++++++++++++++++- .../forms/src/dashboard/inbox/style.scss | 7 +++++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts b/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts index 2e9e19128f37f..e66e8460a7745 100644 --- a/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts +++ b/projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import { useEntityRecords } from '@wordpress/core-data'; +import { useEntityRecords, store as coreDataStore } from '@wordpress/core-data'; import { useDispatch, useSelect } from '@wordpress/data'; import { useSearchParams } from 'react-router'; /** @@ -75,7 +75,21 @@ export default function useInboxData(): UseInboxDataReturn { totalPages, } = useEntityRecords( 'postType', 'feedback', currentQuery ); - const records = ( rawRecords || [] ) as FormResponse[]; + // Merge raw records with any local edits from editEntityRecord + const records = useSelect( + select => { + return ( rawRecords || [] ).map( record => { + // Get the edited version of this record if it exists + const editedRecord = select( coreDataStore ).getEditedEntityRecord( + 'postType', + 'feedback', + record.id + ); + return editedRecord || record; + } ) as FormResponse[]; + }, + [ rawRecords ] + ); const { isResolving: isLoadingInboxData, totalItems: totalItemsInbox = 0 } = useEntityRecords( 'postType', diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js index 67d63a8fd9c24..7369ca7632d0c 100644 --- a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js +++ b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js @@ -245,6 +245,24 @@ export default function InboxView() { { id: 'from', label: __( 'From', 'jetpack-forms' ), + render: ( { item } ) => { + const authorInfo = decodeEntities( + item.author_name || item.author_email || item.author_url || item.ip + ); + return ( + <> + { item.is_unread && ( + + ● + + ) } + { authorInfo } + + ); + }, getValue: ( { item } ) => { return decodeEntities( item.author_name || item.author_email || item.author_url || item.ip diff --git a/projects/packages/forms/src/dashboard/inbox/response.js b/projects/packages/forms/src/dashboard/inbox/response.js index 83cf0b9e053da..808ac3e5af9c2 100644 --- a/projects/packages/forms/src/dashboard/inbox/response.js +++ b/projects/packages/forms/src/dashboard/inbox/response.js @@ -1,6 +1,7 @@ /** * External dependencies */ +import apiFetch from '@wordpress/api-fetch'; import { Button, ExternalLink, @@ -13,7 +14,7 @@ import { __experimentalHStack as HStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis __experimentalVStack as VStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis } from '@wordpress/components'; -import { useRegistry } from '@wordpress/data'; +import { useRegistry, useDispatch } from '@wordpress/data'; import { dateI18n, getSettings as getDateSettings } from '@wordpress/date'; import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; import { decodeEntities } from '@wordpress/html-entities'; @@ -199,6 +200,7 @@ const InboxResponse = ( { const [ isMovingToTrash, setIsMovingToTrash ] = useState( false ); const [ isRestoring, setIsRestoring ] = useState( false ); const [ isDeleting, setIsDeleting ] = useState( false ); + const { editEntityRecord } = useDispatch( 'core' ); // When opening a "Mark as spam" link from the email, the InboxResponse component is rendered, so we use a hook here to handle it. const { isConfirmDialogOpen, onConfirmMarkAsSpam, onCancelMarkAsSpam } = @@ -485,6 +487,28 @@ const InboxResponse = ( { ref.current.scrollTop = 0; }, [ response ] ); + // Mark feedback as read when viewing + useEffect( () => { + if ( ! response || ! response.id || ! response.is_unread ) { + return; + } + + // Immediately update entity in store + editEntityRecord( 'postType', 'feedback', response.id, { + is_unread: false, + } ); + + // Then update on server + apiFetch( { + path: `/wp/v2/feedback/${ response.id }/read`, + method: 'POST', + data: { is_unread: false }, + } ).catch( error => { + // eslint-disable-next-line no-console + console.error( 'Failed to mark feedback as read:', error ); + } ); + }, [ response, editEntityRecord ] ); + const handelImageLoaded = useCallback( () => { return setIsImageLoading( false ); }, [ setIsImageLoading ] ); diff --git a/projects/packages/forms/src/dashboard/inbox/style.scss b/projects/packages/forms/src/dashboard/inbox/style.scss index a00ac05c2c4fe..4b3d4b49abc3d 100644 --- a/projects/packages/forms/src/dashboard/inbox/style.scss +++ b/projects/packages/forms/src/dashboard/inbox/style.scss @@ -515,3 +515,10 @@ body.jetpack_page_jetpack-forms-admin { gap: 16px; align-items: center; } + +.jp-forms__inbox__unread-indicator { + color: #2271b1; + font-size: 12px; + margin-right: 6px; + vertical-align: middle; +} From a65d79a5e7c510916d7a7e57103e003b44ffd000 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 2 Oct 2025 07:36:38 +0300 Subject: [PATCH 04/21] Add DataView actions --- .../src/dashboard/inbox/dataviews/actions.js | 115 +++++++++++++++++- .../src/dashboard/inbox/dataviews/index.js | 4 + .../forms/src/dashboard/inbox/style.scss | 2 +- 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js b/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js index 56ea8675cc452..ffd819ac87423 100644 --- a/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js +++ b/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js @@ -1,7 +1,8 @@ +import apiFetch from '@wordpress/api-fetch'; import { Icon } from '@wordpress/components'; import { store as coreStore } from '@wordpress/core-data'; import { __, _n, sprintf } from '@wordpress/i18n'; -import { seen, trash, backup } from '@wordpress/icons'; +import { seen, trash, backup, check, cancelCircleFilled } from '@wordpress/icons'; import { store as noticesStore } from '@wordpress/notices'; import { notSpam, spam } from '../../icons'; import { store as dashboardStore } from '../../store'; @@ -300,3 +301,115 @@ export const deleteAction = { createErrorNotice( errorMessage, { type: 'snackbar' } ); }, }; + +export const markAsReadAction = { + id: 'mark-as-read', + label: __( 'Mark as read', 'jetpack-forms' ), + isEligible: item => item.is_unread, + supportsBulk: true, + icon: , + async callback( items, { registry } ) { + const { editEntityRecord } = registry.dispatch( coreStore ); + const { createSuccessNotice, createErrorNotice } = registry.dispatch( noticesStore ); + const promises = await Promise.allSettled( + items.map( async ( { id } ) => { + // Update entity in store + editEntityRecord( 'postType', 'feedback', id, { is_unread: false } ); + // Update on server + return apiFetch( { + path: `/wp/v2/feedback/${ id }/read`, + method: 'POST', + data: { is_unread: false }, + } ); + } ) + ); + if ( promises.every( ( { status } ) => status === 'fulfilled' ) ) { + const successMessage = + items.length === 1 + ? __( 'Response marked as read.', 'jetpack-forms' ) + : sprintf( + /* translators: %d: the number of responses. */ + _n( + '%d response marked as read.', + '%d responses marked as read.', + items.length, + 'jetpack-forms' + ), + items.length + ); + createSuccessNotice( successMessage, { + type: 'snackbar', + id: 'mark-as-read-action', + actions: [ + { + label: __( 'Undo', 'jetpack-forms' ), + onClick: () => { + markAsUnreadAction.callback( items, { registry } ); + }, + }, + ], + } ); + return; + } + // There is at least one failure. + const numberOfErrors = promises.filter( ( { status } ) => status === 'rejected' ).length; + const errorMessage = getGenericErrorMessage( numberOfErrors ); + createErrorNotice( errorMessage, { type: 'snackbar' } ); + }, +}; + +export const markAsUnreadAction = { + id: 'mark-as-unread', + label: __( 'Mark as unread', 'jetpack-forms' ), + isEligible: item => ! item.is_unread, + supportsBulk: true, + icon: , + async callback( items, { registry } ) { + const { editEntityRecord } = registry.dispatch( coreStore ); + const { createSuccessNotice, createErrorNotice } = registry.dispatch( noticesStore ); + const promises = await Promise.allSettled( + items.map( async ( { id } ) => { + // Update entity in store + editEntityRecord( 'postType', 'feedback', id, { is_unread: true } ); + // Update on server + return apiFetch( { + path: `/wp/v2/feedback/${ id }/read`, + method: 'POST', + data: { is_unread: true }, + } ); + } ) + ); + if ( promises.every( ( { status } ) => status === 'fulfilled' ) ) { + const successMessage = + items.length === 1 + ? __( 'Response marked as unread.', 'jetpack-forms' ) + : sprintf( + /* translators: %d: the number of responses. */ + _n( + '%d response marked as unread.', + '%d responses marked as unread.', + items.length, + 'jetpack-forms' + ), + items.length + ); + createSuccessNotice( successMessage, { + type: 'snackbar', + id: 'mark-as-unread-action', + actions: [ + { + label: __( 'Undo', 'jetpack-forms' ), + onClick: () => { + markAsReadAction.callback( items, { registry } ); + }, + }, + ], + } ); + return; + } + // There is at least one failure. + const numberOfErrors = promises.filter( ( { status } ) => status === 'rejected' ).length; + const errorMessage = getGenericErrorMessage( numberOfErrors ); + createErrorNotice( errorMessage, { type: 'snackbar' } ); + }, +}; diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js index 7369ca7632d0c..bdf44dff3bad9 100644 --- a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js +++ b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js @@ -32,6 +32,8 @@ import { moveToTrashAction, deleteAction, restoreAction, + markAsReadAction, + markAsUnreadAction, } from './actions'; import { useView, defaultLayouts } from './views'; @@ -316,6 +318,8 @@ export default function InboxView() { const actions = useMemo( () => { const _actions = [ + markAsReadAction, + markAsUnreadAction, markAsSpamAction, markAsNotSpamAction, moveToTrashAction, diff --git a/projects/packages/forms/src/dashboard/inbox/style.scss b/projects/packages/forms/src/dashboard/inbox/style.scss index 4b3d4b49abc3d..052883a4d11a7 100644 --- a/projects/packages/forms/src/dashboard/inbox/style.scss +++ b/projects/packages/forms/src/dashboard/inbox/style.scss @@ -517,7 +517,7 @@ body.jetpack_page_jetpack-forms-admin { } .jp-forms__inbox__unread-indicator { - color: #2271b1; + color: var(--wp-admin-theme-color, #2271b1); font-size: 12px; margin-right: 6px; vertical-align: middle; From af57b786db1bcb80414a857d31d31475e06aedd5 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 2 Oct 2025 07:37:55 +0300 Subject: [PATCH 05/21] changelog --- projects/packages/forms/changelog/add-forms-unread-state | 4 ++++ projects/plugins/jetpack/changelog/add-forms-unread-state | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 projects/packages/forms/changelog/add-forms-unread-state create mode 100644 projects/plugins/jetpack/changelog/add-forms-unread-state diff --git a/projects/packages/forms/changelog/add-forms-unread-state b/projects/packages/forms/changelog/add-forms-unread-state new file mode 100644 index 0000000000000..fa85c42c80052 --- /dev/null +++ b/projects/packages/forms/changelog/add-forms-unread-state @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Forms: add read and unread state diff --git a/projects/plugins/jetpack/changelog/add-forms-unread-state b/projects/plugins/jetpack/changelog/add-forms-unread-state new file mode 100644 index 0000000000000..c51f32513c453 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-forms-unread-state @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Forms: add read and unread state for new form responses From 1ef26d2f0054ef27dac60a1d77784d416f3132ca Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 2 Oct 2025 14:28:42 +0300 Subject: [PATCH 06/21] Update the style --- projects/packages/forms/src/dashboard/inbox/style.scss | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/projects/packages/forms/src/dashboard/inbox/style.scss b/projects/packages/forms/src/dashboard/inbox/style.scss index 052883a4d11a7..6aefaec50ecd6 100644 --- a/projects/packages/forms/src/dashboard/inbox/style.scss +++ b/projects/packages/forms/src/dashboard/inbox/style.scss @@ -517,8 +517,9 @@ body.jetpack_page_jetpack-forms-admin { } .jp-forms__inbox__unread-indicator { - color: var(--wp-admin-theme-color, #2271b1); - font-size: 12px; - margin-right: 6px; + color: #d63638; + font-size: 8px; + margin-right: 4.5px; + margin-left: -12px; vertical-align: middle; } From 4d1adbc82ef6db023c5049cf51a93d41e453d7bc Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Fri, 3 Oct 2025 00:19:50 +0300 Subject: [PATCH 07/21] Make things bold --- .../src/dashboard/inbox/dataviews/index.js | 37 +++++++++++-------- .../forms/src/dashboard/inbox/style.scss | 11 +++--- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js index bdf44dff3bad9..e901c040db3a1 100644 --- a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js +++ b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js @@ -242,6 +242,20 @@ export default function InboxView() { () => ( { totalItems, totalPages } ), [ totalItems, totalPages ] ); + + const wrapperUnread = ( isUnread, itemValue ) => { + if ( isUnread ) { + return ( + + { itemValue } + + ); + } + return itemValue; + }; const fields = useMemo( () => [ { @@ -251,19 +265,7 @@ export default function InboxView() { const authorInfo = decodeEntities( item.author_name || item.author_email || item.author_url || item.ip ); - return ( - <> - { item.is_unread && ( - - ● - - ) } - { authorInfo } - - ); + return <>{ wrapperUnread( item.is_unread, authorInfo ) }; }, getValue: ( { item } ) => { return decodeEntities( @@ -276,7 +278,9 @@ export default function InboxView() { { id: 'date', label: __( 'Date', 'jetpack-forms' ), - render: ( { item } ) => dateI18n( dateSettings.formats.date, item.date ), + render: ( { item } ) => { + return wrapperUnread( item.is_unread, dateI18n( dateSettings.formats.date, item.date ) ); + }, elements: ( filterOptions?.date || [] ).map( _filter => { const date = new Date(); date.setDate( 1 ); @@ -300,7 +304,10 @@ export default function InboxView() { render: ( { item } ) => { return ( - { decodeEntities( item.entry_title ) || getPath( item ) } + { wrapperUnread( + item.is_unread, + decodeEntities( item.entry_title ) || getPath( item ) + ) } ); }, diff --git a/projects/packages/forms/src/dashboard/inbox/style.scss b/projects/packages/forms/src/dashboard/inbox/style.scss index 6aefaec50ecd6..757e1363565d7 100644 --- a/projects/packages/forms/src/dashboard/inbox/style.scss +++ b/projects/packages/forms/src/dashboard/inbox/style.scss @@ -517,9 +517,10 @@ body.jetpack_page_jetpack-forms-admin { } .jp-forms__inbox__unread-indicator { - color: #d63638; - font-size: 8px; - margin-right: 4.5px; - margin-left: -12px; - vertical-align: middle; + font-weight: 600; + color: #222; +} + +a .jp-forms__inbox__unread-indicator { + color: #135e96; } From ac769254e0e50311c377ca5624a8a29e8cd2c5d1 Mon Sep 17 00:00:00 2001 From: Mikael Korpela Date: Sat, 4 Oct 2025 15:58:25 +0300 Subject: [PATCH 08/21] Swap icons and add back indicator dot --- .../src/dashboard/inbox/dataviews/actions.js | 8 +++---- .../src/dashboard/inbox/dataviews/index.js | 23 +++++++++++-------- .../forms/src/dashboard/inbox/style.scss | 11 +++++---- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js b/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js index ffd819ac87423..ba370d93174e7 100644 --- a/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js +++ b/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js @@ -2,7 +2,7 @@ import apiFetch from '@wordpress/api-fetch'; import { Icon } from '@wordpress/components'; import { store as coreStore } from '@wordpress/core-data'; import { __, _n, sprintf } from '@wordpress/i18n'; -import { seen, trash, backup, check, cancelCircleFilled } from '@wordpress/icons'; +import { seen, unseen, trash, backup, commentContent } from '@wordpress/icons'; import { store as noticesStore } from '@wordpress/notices'; import { notSpam, spam } from '../../icons'; import { store as dashboardStore } from '../../store'; @@ -15,7 +15,7 @@ export const BULK_ACTIONS = { export const viewAction = { id: 'view-response', - icon: , + icon: , isPrimary: true, label: __( 'View response', 'jetpack-forms' ), }; @@ -307,7 +307,7 @@ export const markAsReadAction = { label: __( 'Mark as read', 'jetpack-forms' ), isEligible: item => item.is_unread, supportsBulk: true, - icon: , + icon: , async callback( items, { registry } ) { const { editEntityRecord } = registry.dispatch( coreStore ); const { createSuccessNotice, createErrorNotice } = registry.dispatch( noticesStore ); @@ -363,7 +363,7 @@ export const markAsUnreadAction = { label: __( 'Mark as unread', 'jetpack-forms' ), isEligible: item => ! item.is_unread, supportsBulk: true, - icon: , + icon: , async callback( items, { registry } ) { const { editEntityRecord } = registry.dispatch( coreStore ); const { createSuccessNotice, createErrorNotice } = registry.dispatch( noticesStore ); diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js index e901c040db3a1..7fd48a1f57043 100644 --- a/projects/packages/forms/src/dashboard/inbox/dataviews/index.js +++ b/projects/packages/forms/src/dashboard/inbox/dataviews/index.js @@ -245,14 +245,7 @@ export default function InboxView() { const wrapperUnread = ( isUnread, itemValue ) => { if ( isUnread ) { - return ( - - { itemValue } - - ); + return { itemValue }; } return itemValue; }; @@ -265,7 +258,19 @@ export default function InboxView() { const authorInfo = decodeEntities( item.author_name || item.author_email || item.author_url || item.ip ); - return <>{ wrapperUnread( item.is_unread, authorInfo ) }; + return ( + <> + { item.is_unread && ( + + ● + + ) } + { wrapperUnread( item.is_unread, authorInfo ) } + + ); }, getValue: ( { item } ) => { return decodeEntities( diff --git a/projects/packages/forms/src/dashboard/inbox/style.scss b/projects/packages/forms/src/dashboard/inbox/style.scss index 757e1363565d7..23dad73193fdf 100644 --- a/projects/packages/forms/src/dashboard/inbox/style.scss +++ b/projects/packages/forms/src/dashboard/inbox/style.scss @@ -516,11 +516,14 @@ body.jetpack_page_jetpack-forms-admin { align-items: center; } -.jp-forms__inbox__unread-indicator { +.jp-forms__inbox__unread { font-weight: 600; - color: #222; } -a .jp-forms__inbox__unread-indicator { - color: #135e96; +.jp-forms__inbox__unread-indicator { + color: var(--jp-blue-50, #135e96); + font-size: 8px; + margin-right: 4.5px; + margin-left: -12px; + vertical-align: middle; } From 34218af9475e6e9845be5c1299f637fc9e8906d8 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 6 Oct 2025 07:09:48 +0300 Subject: [PATCH 09/21] Update the sidebar counter to match Replaces legacy unread feedback count logic with new static methods in Contact_Form_Plugin and Feedback classes, storing the count in a new option. Updates backend to recalculate and return the unread count after marking feedback as read/unread. Frontend now updates the admin menu counter dynamically using the new count from the server, improving accuracy and consistency. --- .../class-contact-form-endpoint.php | 2 + .../class-contact-form-plugin.php | 117 +++++++++++------- .../src/contact-form/class-contact-form.php | 4 +- .../forms/src/contact-form/class-feedback.php | 18 +++ .../src/dashboard/inbox/dataviews/actions.js | 40 +++++- .../forms/src/dashboard/inbox/response.js | 14 ++- .../Contact_Form_Endpoint_Test.php | 2 + 7 files changed, 140 insertions(+), 57 deletions(-) diff --git a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php index 9ee2215874f2a..f1df3e65f0811 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php @@ -1091,6 +1091,7 @@ public function update_read_status( $request ) { $success = $is_unread ? $feedback_response->mark_as_unread() : $feedback_response->mark_as_read(); + Contact_Form_Plugin::recalculate_unread_count(); if ( ! $success ) { return new WP_Error( 'rest_cannot_update', @@ -1103,6 +1104,7 @@ public function update_read_status( $request ) { array( 'id' => $post_id, 'is_unread' => $feedback_response->is_unread(), + 'count' => Contact_Form_Plugin::get_unread_count(), ) ); } diff --git a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php index 7fc8bb5229949..127547fa5ac2e 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php @@ -1406,70 +1406,91 @@ public function allow_feedback_rest_api_type( $post_types ) { * Display the count of new feedback entries received. It's reset when user visits the Feedback screen. * * @since 4.1.0 - * - * @param object $screen Information about the current screen. */ - public function unread_count( $screen ) { - if ( isset( $screen->post_type ) && 'feedback' === $screen->post_type || $screen->id === 'jetpack_page_jetpack-forms-admin' ) { - update_option( 'feedback_unread_count', 0 ); - } else { - global $submenu, $menu; - if ( apply_filters( 'jetpack_forms_use_new_menu_parent', true ) && current_user_can( 'edit_pages' ) ) { - // show the count on Jetpack and Jetpack → Forms - $unread = get_option( 'feedback_unread_count', 0 ); - - if ( $unread > 0 && isset( $submenu['jetpack'] ) && is_array( $submenu['jetpack'] ) && ! empty( $submenu['jetpack'] ) ) { - $forms_unread_count_tag = " " . number_format_i18n( $unread ) . ''; - $jetpack_badge_count = $unread; - - // Main menu entries - foreach ( $menu as $index => $main_menu_item ) { - if ( isset( $main_menu_item[1] ) && 'jetpack_admin_page' === $main_menu_item[1] ) { - // Parse the menu item - $jetpack_menu_item = $this->parse_menu_item( $menu[ $index ][0] ); - - if ( isset( $jetpack_menu_item['badge'] ) && is_numeric( $jetpack_menu_item['badge'] ) && intval( $jetpack_menu_item['badge'] ) ) { - $jetpack_badge_count += intval( $jetpack_menu_item['badge'] ); - } + public function unread_count() { - if ( isset( $jetpack_menu_item['count'] ) && is_numeric( $jetpack_menu_item['count'] ) && intval( $jetpack_menu_item['count'] ) ) { - $jetpack_badge_count += intval( $jetpack_menu_item['count'] ); - } + global $submenu, $menu; + if ( apply_filters( 'jetpack_forms_use_new_menu_parent', true ) && current_user_can( 'edit_pages' ) ) { + // show the count on Jetpack and Jetpack → Forms + $unread = self::get_unread_count(); - $jetpack_unread_tag = " " . number_format_i18n( $jetpack_badge_count ) . ''; + if ( $unread > 0 && isset( $submenu['jetpack'] ) && is_array( $submenu['jetpack'] ) && ! empty( $submenu['jetpack'] ) ) { + $forms_unread_count_tag = " '; + $jetpack_badge_count = $unread; - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - $menu[ $index ][0] = $jetpack_menu_item['title'] . ' ' . $jetpack_unread_tag; + // Main menu entries + foreach ( $menu as $index => $main_menu_item ) { + if ( isset( $main_menu_item[1] ) && 'jetpack_admin_page' === $main_menu_item[1] ) { + // Parse the menu item + $jetpack_menu_item = $this->parse_menu_item( $menu[ $index ][0] ); + + if ( isset( $jetpack_menu_item['badge'] ) && is_numeric( $jetpack_menu_item['badge'] ) && intval( $jetpack_menu_item['badge'] ) ) { + $jetpack_badge_count += intval( $jetpack_menu_item['badge'] ); } - } - // Jetpack submenu entries - foreach ( $submenu['jetpack'] as $index => $menu_item ) { - if ( 'jetpack-forms-admin' === $menu_item[2] ) { - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - $submenu['jetpack'][ $index ][0] .= $forms_unread_count_tag; + if ( isset( $jetpack_menu_item['count'] ) && is_numeric( $jetpack_menu_item['count'] ) && intval( $jetpack_menu_item['count'] ) ) { + $jetpack_badge_count += intval( $jetpack_menu_item['count'] ); } + + $jetpack_unread_tag = " '; + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $menu[ $index ][0] = $jetpack_menu_item['title'] . ' ' . $jetpack_unread_tag; + } + } + + // Jetpack submenu entries + foreach ( $submenu['jetpack'] as $index => $menu_item ) { + if ( 'jetpack-forms-admin' === $menu_item[2] ) { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $submenu['jetpack'][ $index ][0] .= $forms_unread_count_tag; } } - return; } - if ( isset( $submenu['feedback'] ) && is_array( $submenu['feedback'] ) && ! empty( $submenu['feedback'] ) ) { - foreach ( $submenu['feedback'] as $index => $menu_item ) { - if ( 'edit.php?post_type=feedback' === $menu_item[2] ) { - $unread = get_option( 'feedback_unread_count', 0 ); - if ( $unread > 0 ) { - $unread_count = current_user_can( 'publish_pages' ) ? " ' : ''; - - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - $submenu['feedback'][ $index ][0] .= $unread_count; - } - break; + return; + } + + if ( isset( $submenu['feedback'] ) && is_array( $submenu['feedback'] ) && ! empty( $submenu['feedback'] ) ) { + foreach ( $submenu['feedback'] as $index => $menu_item ) { + if ( 'edit.php?post_type=feedback' === $menu_item[2] ) { + $unread = self::get_unread_count(); + + if ( $unread > 0 ) { + $unread_count = current_user_can( 'publish_pages' ) ? " ' : ''; + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $submenu['feedback'][ $index ][0] .= $unread_count; } + break; } } } } + /** + * Get the count of unread feedback entries. + * + * @since $$next-version$$ + * + * @return int The count of unread feedback entries. + */ + public static function get_unread_count() { + return (int) get_option( 'feedback_unread_count_v2', 0 ); + } + + /** + * Recalculate the count of unread feedback entries. + * + * @since $$next-version$$ + * + * @return int The count of unread feedback entries. + */ + public static function recalculate_unread_count() { + $count = Feedback::get_unread_count(); + update_option( 'feedback_unread_count_v2', $count ); + return $count; + } + /** * Handles all contact-form POST submissions * diff --git a/projects/packages/forms/src/contact-form/class-contact-form.php b/projects/packages/forms/src/contact-form/class-contact-form.php index 34abadc7664bb..64e017d9b784c 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form.php +++ b/projects/packages/forms/src/contact-form/class-contact-form.php @@ -1929,9 +1929,7 @@ public function process_submission() { update_post_meta( $post_id, '_feedback_extra_fields', $this->addslashes_deep( $extra_values ) ); if ( 'publish' === $feedback_status ) { - // Increase count of unread feedback. - $unread = (int) get_option( 'feedback_unread_count', 0 ) + 1; - update_option( 'feedback_unread_count', $unread ); + Contact_Form_Plugin::recalculate_unread_count(); } if ( defined( 'AKISMET_VERSION' ) ) { diff --git a/projects/packages/forms/src/contact-form/class-feedback.php b/projects/packages/forms/src/contact-form/class-feedback.php index 32502261a402c..39ddc40575d70 100644 --- a/projects/packages/forms/src/contact-form/class-feedback.php +++ b/projects/packages/forms/src/contact-form/class-feedback.php @@ -795,6 +795,24 @@ public function mark_as_unread() { return false; } + /** + * Get the count of unread feedback entries. + * + * @return int + */ + public static function get_unread_count() { + $query = new \WP_Query( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'comment_status' => self::STATUS_UNREAD, + 'posts_per_page' => -1, + 'fields' => 'ids', + ) + ); + return (int) $query->found_posts; + } + /** * Get the uploaded files from the feedback entry. * diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js b/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js index ba370d93174e7..ad2991420018f 100644 --- a/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js +++ b/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js @@ -7,6 +7,24 @@ import { store as noticesStore } from '@wordpress/notices'; import { notSpam, spam } from '../../icons'; import { store as dashboardStore } from '../../store'; import InboxResponse from '../response'; +/** + * Update the unread count in the admin menu. + * + * @param {number} count - The new unread count. + */ +export const updateMenuCounter = count => { + // iterate over all elements with the class 'feedback-unread-counter' and update their text content + document.querySelectorAll( '.jp-feedback-unread-counter' ).forEach( item => { + if ( item.dataset.unreadDiff ) { + const newCount = parseInt( item.dataset.unreadDiff, 10 ) + count; + item.textContent = newCount > 0 ? newCount : ''; + item.style.display = newCount > 0 ? '' : 'none'; + } else { + item.textContent = count > 0 ? count : ''; + item.style.display = count > 0 ? '' : 'none'; + } + } ); +}; export const BULK_ACTIONS = { markAsSpam: 'mark_as_spam', @@ -320,7 +338,16 @@ export const markAsReadAction = { path: `/wp/v2/feedback/${ id }/read`, method: 'POST', data: { is_unread: false }, - } ); + } ) + .then( ( { count } ) => { + // Update the unread count in the store. + updateMenuCounter( count ); + } ) + .catch( () => { + // Revert the change in the store if the server update fails. + editEntityRecord( 'postType', 'feedback', id, { is_unread: true } ); + throw new Error( 'Failed to mark as read' ); + } ); } ) ); if ( promises.every( ( { status } ) => status === 'fulfilled' ) ) { @@ -376,7 +403,16 @@ export const markAsUnreadAction = { path: `/wp/v2/feedback/${ id }/read`, method: 'POST', data: { is_unread: true }, - } ); + } ) + .then( ( { count } ) => { + // Update the unread count in the store. + updateMenuCounter( count ); + } ) + .catch( () => { + // Revert the change in the store if the server update fails. + editEntityRecord( 'postType', 'feedback', id, { is_unread: false } ); + throw new Error( 'Failed to mark as unread' ); + } ); } ) ); if ( promises.every( ( { status } ) => status === 'fulfilled' ) ) { diff --git a/projects/packages/forms/src/dashboard/inbox/response.js b/projects/packages/forms/src/dashboard/inbox/response.js index 808ac3e5af9c2..f8ddb7bafe078 100644 --- a/projects/packages/forms/src/dashboard/inbox/response.js +++ b/projects/packages/forms/src/dashboard/inbox/response.js @@ -33,6 +33,7 @@ import { moveToTrashAction, restoreAction, deleteAction, + updateMenuCounter, } from './dataviews/actions'; import { getPath } from './utils'; @@ -503,10 +504,15 @@ const InboxResponse = ( { path: `/wp/v2/feedback/${ response.id }/read`, method: 'POST', data: { is_unread: false }, - } ).catch( error => { - // eslint-disable-next-line no-console - console.error( 'Failed to mark feedback as read:', error ); - } ); + } ) + .then( ( { count } ) => { + updateMenuCounter( count ); + } ) + .catch( () => { + editEntityRecord( 'postType', 'feedback', response.id, { + is_unread: true, + } ); + } ); }, [ response, editEntityRecord ] ); const handelImageLoaded = useCallback( () => { diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php index d510e047a5704..ce9f1e8b6b7c7 100644 --- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php @@ -738,10 +738,12 @@ public function test_mark_feedback_as_read() { $response = $this->server->dispatch( $request ); $data = $response->get_data(); $this->assertFalse( $data['is_unread'] ); + $this->assertIsInt( 0, Contact_Form_Plugin::get_unread_count() ); // Verify Feedback class method $feedback = \Automattic\Jetpack\Forms\ContactForm\Feedback::get( $post_id ); $this->assertFalse( $feedback->is_unread() ); + $this->assertIsInt( 1, Contact_Form_Plugin::get_unread_count() ); } /** From a13b8a2a29c67fa48ea8abd25f863dd4ff135767 Mon Sep 17 00:00:00 2001 From: Mikael Korpela Date: Tue, 7 Oct 2025 10:12:53 +0300 Subject: [PATCH 10/21] Clarify JS comments --- .../packages/forms/src/dashboard/inbox/dataviews/actions.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js b/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js index ad2991420018f..83922f17f4627 100644 --- a/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js +++ b/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js @@ -13,7 +13,7 @@ import InboxResponse from '../response'; * @param {number} count - The new unread count. */ export const updateMenuCounter = count => { - // iterate over all elements with the class 'feedback-unread-counter' and update their text content + // iterate over all elements with the class 'jp-feedback-unread-counter' and update their text content document.querySelectorAll( '.jp-feedback-unread-counter' ).forEach( item => { if ( item.dataset.unreadDiff ) { const newCount = parseInt( item.dataset.unreadDiff, 10 ) + count; @@ -340,7 +340,7 @@ export const markAsReadAction = { data: { is_unread: false }, } ) .then( ( { count } ) => { - // Update the unread count in the store. + // Update the unread count in the menu. updateMenuCounter( count ); } ) .catch( () => { @@ -405,7 +405,7 @@ export const markAsUnreadAction = { data: { is_unread: true }, } ) .then( ( { count } ) => { - // Update the unread count in the store. + // Update the unread count in the menu. updateMenuCounter( count ); } ) .catch( () => { From 9573b8e8e3d207b6b12ba296ced9797054fe0932 Mon Sep 17 00:00:00 2001 From: Mikael Korpela Date: Tue, 7 Oct 2025 14:27:14 +0300 Subject: [PATCH 11/21] Move menu updated to utils --- .../src/dashboard/inbox/dataviews/actions.js | 19 +------------------ .../forms/src/dashboard/inbox/response.js | 3 +-- .../forms/src/dashboard/inbox/utils.js | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js b/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js index 83922f17f4627..df8550da6ceb6 100644 --- a/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js +++ b/projects/packages/forms/src/dashboard/inbox/dataviews/actions.js @@ -7,24 +7,7 @@ import { store as noticesStore } from '@wordpress/notices'; import { notSpam, spam } from '../../icons'; import { store as dashboardStore } from '../../store'; import InboxResponse from '../response'; -/** - * Update the unread count in the admin menu. - * - * @param {number} count - The new unread count. - */ -export const updateMenuCounter = count => { - // iterate over all elements with the class 'jp-feedback-unread-counter' and update their text content - document.querySelectorAll( '.jp-feedback-unread-counter' ).forEach( item => { - if ( item.dataset.unreadDiff ) { - const newCount = parseInt( item.dataset.unreadDiff, 10 ) + count; - item.textContent = newCount > 0 ? newCount : ''; - item.style.display = newCount > 0 ? '' : 'none'; - } else { - item.textContent = count > 0 ? count : ''; - item.style.display = count > 0 ? '' : 'none'; - } - } ); -}; +import { updateMenuCounter } from '../utils'; export const BULK_ACTIONS = { markAsSpam: 'mark_as_spam', diff --git a/projects/packages/forms/src/dashboard/inbox/response.js b/projects/packages/forms/src/dashboard/inbox/response.js index f8ddb7bafe078..329e263a2853b 100644 --- a/projects/packages/forms/src/dashboard/inbox/response.js +++ b/projects/packages/forms/src/dashboard/inbox/response.js @@ -33,9 +33,8 @@ import { moveToTrashAction, restoreAction, deleteAction, - updateMenuCounter, } from './dataviews/actions'; -import { getPath } from './utils'; +import { getPath, updateMenuCounter } from './utils'; const getDisplayName = response => { const { author_name, author_email, author_url, ip } = response; diff --git a/projects/packages/forms/src/dashboard/inbox/utils.js b/projects/packages/forms/src/dashboard/inbox/utils.js index 74a20206fdd81..add9f7c8a2e03 100644 --- a/projects/packages/forms/src/dashboard/inbox/utils.js +++ b/projects/packages/forms/src/dashboard/inbox/utils.js @@ -7,3 +7,22 @@ export const getPath = item => { return ''; } }; + +/** + * Update the unread count in the admin menu. + * + * @param {number} count - The new unread count. + */ +export const updateMenuCounter = count => { + // iterate over all elements with the class 'jp-feedback-unread-counter' and update their text content + document.querySelectorAll( '.jp-feedback-unread-counter' ).forEach( item => { + if ( item.dataset.unreadDiff ) { + const newCount = parseInt( item.dataset.unreadDiff, 10 ) + count; + item.textContent = newCount > 0 ? newCount : ''; + item.style.display = newCount > 0 ? '' : 'none'; + } else { + item.textContent = count > 0 ? count : ''; + item.style.display = count > 0 ? '' : 'none'; + } + } ); +}; From 6f48d7a0896bfcce0c436541ed9ca5e8e89628ba Mon Sep 17 00:00:00 2001 From: Mikael Korpela Date: Tue, 7 Oct 2025 14:53:38 +0300 Subject: [PATCH 12/21] Always output counter badge --- .../forms/src/contact-form/class-contact-form-plugin.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php index 127547fa5ac2e..91d1ba716a92a 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php @@ -1414,8 +1414,10 @@ public function unread_count() { // show the count on Jetpack and Jetpack → Forms $unread = self::get_unread_count(); - if ( $unread > 0 && isset( $submenu['jetpack'] ) && is_array( $submenu['jetpack'] ) && ! empty( $submenu['jetpack'] ) ) { - $forms_unread_count_tag = " '; + if ( isset( $submenu['jetpack'] ) && is_array( $submenu['jetpack'] ) && ! empty( $submenu['jetpack'] ) ) { + $inline_style = ( $unread > 0 ) ? '' : 'style="display: none;"'; + + $forms_unread_count_tag = " '; $jetpack_badge_count = $unread; // Main menu entries From 58464dfd945d14656da0b61ef4f4443bf5e929b0 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Wed, 8 Oct 2025 06:18:11 -0700 Subject: [PATCH 13/21] Make the dot red. --- projects/packages/forms/src/dashboard/inbox/style.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/packages/forms/src/dashboard/inbox/style.scss b/projects/packages/forms/src/dashboard/inbox/style.scss index 23dad73193fdf..587388fb3f6da 100644 --- a/projects/packages/forms/src/dashboard/inbox/style.scss +++ b/projects/packages/forms/src/dashboard/inbox/style.scss @@ -521,7 +521,7 @@ body.jetpack_page_jetpack-forms-admin { } .jp-forms__inbox__unread-indicator { - color: var(--jp-blue-50, #135e96); + color: #d63638; font-size: 8px; margin-right: 4.5px; margin-left: -12px; From ea73e41261de16dcdbcc3b7ff5dec2a6d6ab220a Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Wed, 8 Oct 2025 06:36:16 -0700 Subject: [PATCH 14/21] Add mark as read buttons --- .../forms/src/dashboard/inbox/response.js | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/projects/packages/forms/src/dashboard/inbox/response.js b/projects/packages/forms/src/dashboard/inbox/response.js index 329e263a2853b..69c2127db3cff 100644 --- a/projects/packages/forms/src/dashboard/inbox/response.js +++ b/projects/packages/forms/src/dashboard/inbox/response.js @@ -33,6 +33,8 @@ import { moveToTrashAction, restoreAction, deleteAction, + markAsReadAction, + markAsUnreadAction, } from './dataviews/actions'; import { getPath, updateMenuCounter } from './utils'; @@ -200,6 +202,8 @@ const InboxResponse = ( { const [ isMovingToTrash, setIsMovingToTrash ] = useState( false ); const [ isRestoring, setIsRestoring ] = useState( false ); const [ isDeleting, setIsDeleting ] = useState( false ); + const [ hasMarkedSelfAsRead, setHasMarkedSelfAsRead ] = useState( false ); + const { editEntityRecord } = useDispatch( 'core' ); // When opening a "Mark as spam" link from the email, the InboxResponse component is rendered, so we use a hook here to handle it. @@ -271,6 +275,15 @@ const InboxResponse = ( { onActionComplete?.( response.id.toString() ); }, [ response, registry, onActionComplete ] ); + const handleMarkAsRead = useCallback( () => { + markAsReadAction.callback( [ response ], { registry } ); + }, [ response, registry ] ); + + const handleMarkAsUnread = useCallback( () => { + setHasMarkedSelfAsRead( response.id ); + markAsUnreadAction.callback( [ response ], { registry } ); + }, [ response, registry ] ); + const renderActionButtons = () => { switch ( response.status ) { case 'spam': @@ -328,6 +341,28 @@ const InboxResponse = ( { default: // 'publish' (inbox) or any other status return ( <> + { response.is_unread && ( + + ) } + { ! response.is_unread && ( + + ) } + ) } + { ! response.is_unread && ( + + ) } + + ); const renderActionButtons = () => { switch ( response.status ) { case 'spam': return ( <> + { readUnreadButtons } - ) } - { ! response.is_unread && ( - - ) } + { readUnreadButtons }