/** * External dependencies */ import { getRedirectUrl } from '@automattic/jetpack-components'; import apiFetch from '@wordpress/api-fetch'; import { Button, ExternalLink, Modal, Tooltip, Spinner, Icon, Tip, __experimentalConfirmDialog as ConfirmDialog, // eslint-disable-line @wordpress/no-unsafe-wp-apis __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 { 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'; import { __, _n, sprintf } from '@wordpress/i18n'; import { download, image } from '@wordpress/icons'; import clsx from 'clsx'; import photon from 'photon'; /** * Internal dependencies */ import useConfigValue from '../../../hooks/use-config-value'; import CopyClipboardButton from '../../components/copy-clipboard-button'; import Gravatar from '../../components/gravatar'; import useInboxData from '../../hooks/use-inbox-data'; import { useMarkAsSpam } from '../../hooks/use-mark-as-spam'; import { getPath, updateMenuCounter, updateMenuCounterOptimistically, getCountryFlagEmoji, } from '../../inbox/utils'; import { store as dashboardStore } from '../../store'; import type { FormResponse } from '../../../types'; const getDisplayName = response => { const { author_name, author_email, author_url, ip } = response; return decodeEntities( author_name || author_email || author_url || ip ); }; const isFileUploadField = value => { return value && typeof value === 'object' && 'files' in value; }; const isImageSelectField = value => { return value?.type === 'image-select'; }; const isLikelyPhoneNumber = value => { // Only operate on strings to avoid coercing numbers (e.g., 2024) into strings that could match if ( typeof value !== 'string' ) { return false; } const normalizedValue = value.trim(); // Allow only digits, spaces, parentheses, hyphens, dots, plus if ( ! /^[\d+\-\s().]+$/.test( normalizedValue ) ) { return false; } // Exclude common date formats to avoid false positives // - ISO-like: 2025-11-01 or 2025/11/01 if ( /^\d{4}[-/]\d{1,2}[-/]\d{1,2}$/.test( normalizedValue ) ) { return false; } // - Locale-like: 01/11/2025, 1/11/25, 11-01-2025 if ( /^\d{1,2}[-/]\d{1,2}[-/]\d{2,4}$/.test( normalizedValue ) ) { return false; } // Strip non-digits and validate digit count within a typical global range const digits = normalizedValue.replace( /\D/g, '' ); if ( digits.length < 7 || digits.length > 15 ) { return false; } return true; }; const PreviewFile = ( { file, isLoading, onImageLoaded } ) => { const imageClass = clsx( 'jp-forms__inbox-file-preview-container', { 'is-loading': isLoading, } ); return (
{ isLoading && (
{ __( 'Loading preview…', 'jetpack-forms' ) }
) }
{
); }; const FileField = ( { file, onClick } ) => { const fileExtension = file.name.split( '.' ).pop().toLowerCase(); const fileType = file.type.split( '/' )[ 0 ]; const iconMap = { image: 'png', video: 'mp4', audio: 'mp3', document: 'pdf', application: 'txt', }; const extensionMap = { pdf: 'pdf', png: 'png', jpg: 'png', jpeg: 'png', gif: 'png', mp4: 'mp4', mp3: 'mp3', webm: 'webm', doc: 'doc', docx: 'doc', txt: 'txt', ppt: 'ppt', pptx: 'ppt', xls: 'xls', xlsx: 'xls', csv: 'xls', zip: 'zip', sql: 'sql', cal: 'cal', }; const iconType = extensionMap[ fileExtension ] || iconMap[ fileType ] || 'txt'; const iconClass = clsx( 'file-field__icon', 'icon-' + iconType ); return (
{ file.is_previewable && ( ) } { ! file.is_previewable && ( { decodeEntities( file.name ) } ) }
{ sprintf( /* translators: %1$s size of the file and %2$s is the file extension */ __( '%1$s, %2$s', 'jetpack-forms' ), file.size, fileExtension.toUpperCase() ) }
); }; export type ResponseViewBodyProps = { response: FormResponse; isLoading: boolean; onModalStateChange?: ( toggleOpen: boolean ) => void; isMobile?: boolean; }; /** * Renders the dashboard response view. * * @param {object} props - The props object. * @param {object} props.response - The response item. * @param {boolean} props.isLoading - Whether the response is loading. * @param {Function} props.onModalStateChange - Function to update the modal state. * @return {import('react').JSX.Element} The dashboard response view. */ const ResponseViewBody = ( { response, isLoading, onModalStateChange, }: ResponseViewBodyProps ): import('react').JSX.Element => { const { currentQuery } = useInboxData(); const [ isPreviewModalOpen, setIsPreviewModalOpen ] = useState( false ); const [ previewFile, setPreviewFile ] = useState< null | object >( null ); const [ isImageLoading, setIsImageLoading ] = useState( true ); const [ hasMarkedSelfAsRead, setHasMarkedSelfAsRead ] = useState( 0 ); const { editEntityRecord } = useDispatch( 'core' ); const emptyTrashDays = useConfigValue( 'emptyTrashDays' ) ?? 0; // When opening a "Mark as spam" link from the email, the ResponseViewBody component is rendered, so we use a hook here to handle it. const { isConfirmDialogOpen, onConfirmMarkAsSpam, onCancelMarkAsSpam } = useMarkAsSpam( response as FormResponse ); const { invalidateCounts, markRecordsAsInvalid } = useDispatch( dashboardStore ); const ref = useRef( undefined ); const openFilePreview = useCallback( file => { setIsImageLoading( true ); setPreviewFile( file ); setIsPreviewModalOpen( true ); if ( onModalStateChange ) { onModalStateChange( true ); } }, [ onModalStateChange, setPreviewFile, setIsPreviewModalOpen ] ); const handleFilePreview = useCallback( file => openFilePreview.bind( null, file ), [ openFilePreview ] ); const closePreviewModal = useCallback( () => { setIsPreviewModalOpen( false ); setIsImageLoading( true ); // Notify parent component that this modal is closed if ( onModalStateChange ) { onModalStateChange( false ); } }, [ onModalStateChange, setIsPreviewModalOpen, setIsImageLoading ] ); const renderFieldValue = value => { if ( isImageSelectField( value ) ) { return (
{ ( value.choices?.length ?? 0 ) === 0 && '-' } { ( value.choices?.length ?? 0 ) > 0 && ( { value.choices.map( choice => { const label = choice.label ? `${ choice.selected }: ${ choice.label }` : choice.selected; const hasImage = choice.image?.src; return ( ); } ) } ) }
); } if ( isFileUploadField( value ) ) { return (
{ value.files?.length ? value.files.map( file => { if ( ! file || ! file.name ) { return '-'; } return ( ); } ) : '-' }
); } // Emails const emailRegEx = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i; if ( emailRegEx.test( value ) ) { return (
{ value }
); } // Phone numbers if ( isLikelyPhoneNumber( value ) ) { return (
{ value }
); } return value; }; useEffect( () => { if ( ! ref.current ) { return; } ref.current.scrollTop = 0; }, [ response ] ); // Mark feedback as read when viewing useEffect( () => { if ( ! response || ! response.id || ! response.is_unread ) { setHasMarkedSelfAsRead( response.id ); return; } if ( hasMarkedSelfAsRead === response.id ) { return; } setHasMarkedSelfAsRead( response.id ); // Immediately update entity in store editEntityRecord( 'postType', 'feedback', response.id, { is_unread: false, } ); // Immediately update menu counters optimistically to avoid delays if ( response.status === 'publish' ) { updateMenuCounterOptimistically( -1 ); } // Then update on server apiFetch( { path: `/wp/v2/feedback/${ response.id }/read`, method: 'POST', data: { is_unread: false }, } ) .then( ( { count }: { count: number } ) => { // Update menu counter with accurate count from server updateMenuCounter( count ); // Mark record as invalid instead of removing from view markRecordsAsInvalid( [ response.id ] ); // invalidate counts to refresh the counts across all status tabs invalidateCounts(); } ) .catch( () => { // Revert the change in the store editEntityRecord( 'postType', 'feedback', response.id, { is_unread: true, } ); // Revert the change in the sidebar if ( response.status === 'publish' ) { updateMenuCounterOptimistically( 1 ); } } ); }, [ response, editEntityRecord, hasMarkedSelfAsRead, invalidateCounts, markRecordsAsInvalid, currentQuery, ] ); const handelImageLoaded = useCallback( () => { return setIsImageLoading( false ); }, [ setIsImageLoading ] ); if ( ! isLoading && ! response ) { return null; } if ( isPreviewModalOpen && ! onModalStateChange ) { return ( ); } const displayName = getDisplayName( response ); return ( <>
{ response.author_email && ( ) }

{ displayName }

{ response.author_email && displayName !== response.author_email && (

{ response.author_email }

) }
{ response.browser && ( ) }
{ __( 'Date:', 'jetpack-forms' ) } { sprintf( /* Translators: %1$s is the date, %2$s is the time. */ __( '%1$s at %2$s', 'jetpack-forms' ), dateI18n( getDateSettings().formats.date, response.date ), dateI18n( getDateSettings().formats.time, response.date ) ) }
{ __( 'Source:', 'jetpack-forms' ) } { decodeEntities( response.entry_title ) || getPath( response ) }
{ __( 'IP address:', 'jetpack-forms' ) }  { response.country_code && ( { getCountryFlagEmoji( response.country_code ) } ) } { response.ip }
{ __( 'Browser:', 'jetpack-forms' ) }  { response.browser }
{ Object.entries( response.fields ).map( ( [ key, value ] ) => (
{ key.endsWith( '?' ) ? key : `${ key }:` }
{ renderFieldValue( value ) }
) ) }
{ isPreviewModalOpen && previewFile && onModalStateChange && ( ) } { __( 'Are you sure you want to mark this response as spam?', 'jetpack-forms' ) }
{ response.status === 'spam' && ( { __( 'Spam responses are moved to trash after 15 days.', 'jetpack-forms' ) } ) } { response.status === 'trash' && ( { sprintf( /* translators: %d number of days. */ _n( 'Items in trash are permanently deleted after %d day.', 'Items in trash are permanently deleted after %d days.', emptyTrashDays, 'jetpack-forms' ), emptyTrashDays ) } ) } ); }; export default ResponseViewBody;