/**
* 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 && (
{ decodeEntities( file.name ) }
) }
{ ! 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 (
) : (
image
)
}
iconSize={ 60 }
>
{ label }
);
} ) }
) }
);
}
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 (
);
}
// Phone numbers
if ( isLikelyPhoneNumber( value ) ) {
return (
);
}
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 }
) }
{ __( '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 }
{ response.browser && (
{ __( '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;