src/applications/mhv-secure-messaging/components/ComposeForm/ReplyDraftItem.jsx
import React, {
useEffect,
useMemo,
useState,
useCallback,
useRef,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import PropTypes from 'prop-types';
import { VaModal } from '@department-of-veterans-affairs/component-library/dist/react-bindings';
import { focusElement } from '@department-of-veterans-affairs/platform-utilities/ui';
import HorizontalRule from '../shared/HorizontalRule';
import {
messageSignatureFormatter,
navigateToFolderByFolderId,
resetUserSession,
setCaretToPos,
} from '../../util/helpers';
import AttachmentsList from '../AttachmentsList';
import FileInput from './FileInput';
import DraftSavedInfo from './DraftSavedInfo';
import ComposeFormActionButtons from './ComposeFormActionButtons';
import MessageThreadBody from '../MessageThread/MessageThreadBody';
import {
ErrorMessages,
draftAutoSaveTimeout,
Alerts,
} from '../../util/constants';
import useDebounce from '../../hooks/use-debounce';
import { saveReplyDraft } from '../../actions/draftDetails';
import RouteLeavingGuard from '../shared/RouteLeavingGuard';
import { retrieveMessageThread, sendReply } from '../../actions/messages';
import { focusOnErrorField } from '../../util/formHelpers';
const ReplyDraftItem = props => {
const {
draft,
drafts,
cannotReply,
editMode,
isSaving,
signature,
draftsCount,
draftSequence,
replyMessage,
replyToName,
setLastFocusableElement,
showBlockedTriageGroupAlert,
setHideDraft,
setIsEditing,
setIsSending,
} = props;
const dispatch = useDispatch();
const history = useHistory();
const textareaRef = useRef(null);
const composeFormActionButtonsRef = useRef(null);
const folderId = useSelector(state => state.sm.folders.folder?.folderId);
const [category, setCategory] = useState(null);
const [subject, setSubject] = useState('');
const [selectedRecipient, setSelectedRecipient] = useState(null);
const [formPopulated, setFormPopulated] = useState(false);
const [sendMessageFlag, setSendMessageFlag] = useState(false);
const [fieldsString, setFieldsString] = useState('');
const [messageBody, setMessageBody] = useState('');
const [attachments, setAttachments] = useState([]);
const debouncedMessageBody = useDebounce(messageBody, draftAutoSaveTimeout);
const [navigationError, setNavigationError] = useState(null);
const [isAutosave, setIsAutosave] = useState(true); // to halt autosave debounce on message send and resume if message send failed
const [attachFileSuccess, setAttachFileSuccess] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const [bodyError, setBodyError] = useState('');
const [messageInvalid, setMessageInvalid] = useState(false);
const [saveError, setSaveError] = useState(null);
const [focusToTextarea, setFocusToTextarea] = useState(false);
const [draftId, setDraftId] = useState(null);
const [savedDraft, setSavedDraft] = useState(false);
const alertsList = useSelector(state => state.sm.alerts.alertList);
const attachmentScanError = useMemo(
() =>
alertsList?.filter(
alert =>
alert.content === Alerts.Message.ATTACHMENT_SCAN_FAIL &&
alert.isActive,
).length > 0,
[alertsList],
);
const localStorageValues = useMemo(() => {
return {
atExpires: localStorage.atExpires,
hasSession: localStorage.hasSession,
sessionExpiration: localStorage.sessionExpiration,
userFirstName: localStorage.userFirstName,
};
}, []);
const { signOutMessage, timeoutId } = resetUserSession(localStorageValues);
const replyToMessageId = draft?.messageId || replyMessage.messageId;
const noTimeout = () => {
clearTimeout(timeoutId);
};
const formattededSignature = useMemo(
() => {
return messageSignatureFormatter(signature);
},
[signature],
);
const refreshThreadHandler = useCallback(
() => {
dispatch(retrieveMessageThread(replyMessage.messageId));
},
[replyMessage, dispatch],
);
const beforeUnloadHandler = useCallback(
e => {
if (messageBody !== (draft ? draft.body : '') || attachments.length) {
e.preventDefault();
window.onbeforeunload = () => signOutMessage;
e.returnValue = true;
} else {
window.removeEventListener('beforeunload', beforeUnloadHandler);
window.onbeforeunload = null;
e.returnValue = false;
noTimeout();
}
},
[draft, messageBody, attachments],
);
useEffect(
() => {
window.addEventListener('beforeunload', beforeUnloadHandler);
return () => {
window.removeEventListener('beforeunload', beforeUnloadHandler);
window.onbeforeunload = null;
noTimeout();
};
},
[beforeUnloadHandler],
);
const checkMessageValidity = useCallback(
() => {
let messageValid = true;
if (messageBody === '' || messageBody.match(/^[\s]+$/)) {
setBodyError(ErrorMessages.ComposeForm.BODY_REQUIRED);
messageValid = false;
}
setMessageInvalid(!messageValid);
return { messageValid };
},
[messageBody],
);
const messageBodyHandler = e => {
setMessageBody(e.target.value);
if (e.target.value) setBodyError('');
};
useEffect(
() => {
if (draft) {
setDraftId(draft.messageId);
}
},
[draft],
);
// OnSave Reply Draft
const saveDraftHandler = useCallback(
async (type, e) => {
// Prevents 'auto' from running if isModalVisible is open
if (type === 'auto' && isModalVisible) {
return;
}
const { messageValid } = checkMessageValidity();
if (type === 'manual') {
if (messageValid) {
setSavedDraft(true);
setLastFocusableElement(e?.target);
}
if (attachments.length) {
setSaveError(
ErrorMessages.ComposeForm.UNABLE_TO_SAVE_DRAFT_ATTACHMENT,
);
} else focusOnErrorField();
setNavigationError(
attachments.length
? {
...ErrorMessages.ComposeForm.UNABLE_TO_SAVE,
confirmButtonText: 'Continue editing',
cancelButtonText: 'Delete draft',
}
: null,
);
}
const newFieldsString = JSON.stringify({
rec: selectedRecipient,
cat: category,
sub: subject,
bod: debouncedMessageBody || messageBody,
});
if (type === 'auto' && newFieldsString === fieldsString) {
return;
}
const formData = {
recipientId: selectedRecipient,
category,
subject,
body: messageBody,
};
if (messageValid) {
if (!draftId) {
setFieldsString(newFieldsString);
dispatch(saveReplyDraft(replyMessage.messageId, formData, type));
} else if (typeof draftId === 'number') {
setFieldsString(newFieldsString);
dispatch(
saveReplyDraft(replyMessage.messageId, formData, type, draftId),
);
}
setSavedDraft(true);
}
if (!attachments.length) setNavigationError(null);
},
[
attachments.length,
category,
checkMessageValidity,
debouncedMessageBody,
dispatch,
draft,
fieldsString,
messageBody,
replyMessage.messageId,
selectedRecipient,
subject,
isModalVisible,
],
);
const sendMessageHandler = useCallback(
async e => {
const { messageValid } = checkMessageValidity();
await setMessageInvalid(false);
if (messageValid) {
setSendMessageFlag(true);
setNavigationError(null);
setLastFocusableElement(e.target);
} else focusOnErrorField();
},
[checkMessageValidity, setLastFocusableElement],
);
// Navigation error effect
useEffect(
() => {
const draftBody = draft && draft.body;
const blankDraft = messageBody === '' && draftBody === undefined;
const savedEdits = messageBody === draftBody;
if (savedEdits || blankDraft) {
setNavigationError(null);
}
if (!savedEdits && blankDraft && attachments.length > 0) {
setNavigationError({
...ErrorMessages.ComposeForm.UNABLE_TO_SAVE,
});
}
if (
(!savedEdits && !blankDraft && attachments.length > 0) ||
(savedEdits && attachments.length > 0)
) {
setNavigationError({
...ErrorMessages.ComposeForm.UNABLE_TO_SAVE_DRAFT_ATTACHMENT,
p1: '',
});
}
if (!draft && !savedEdits && !blankDraft && attachments.length === 0) {
setNavigationError({
...ErrorMessages.ComposeForm.CONT_SAVING_DRAFT,
});
}
if (draft && draftBody !== messageBody && attachments.length === 0) {
setNavigationError({
...ErrorMessages.ComposeForm.CONT_SAVING_DRAFT_CHANGES,
});
}
},
[attachments.length, draft, messageBody],
);
useEffect(
() => {
if (replyMessage && !draft) {
setSelectedRecipient(replyMessage.senderId);
setSubject(replyMessage.subject);
setMessageBody('');
setCategory(replyMessage.category);
}
},
[replyMessage, draft],
);
useEffect(
() => {
if (
editMode &&
debouncedMessageBody &&
isAutosave &&
!cannotReply &&
!isModalVisible
) {
saveDraftHandler('auto');
}
},
[
cannotReply,
debouncedMessageBody,
isAutosave,
isModalVisible,
saveDraftHandler,
],
);
// sending a reply message
useEffect(
() => {
if (sendMessageFlag && isSaving !== true) {
const messageData = {
category,
body: messageBody,
subject,
};
if (draft && replyToMessageId) {
messageData[`${'draft_id'}`] = replyToMessageId; // if replying to a thread that has a saved draft, set a draft_id field in a request body
}
messageData[`${'recipient_id'}`] = selectedRecipient;
setIsAutosave(false);
let sendData;
if (attachments.length > 0) {
sendData = new FormData();
sendData.append('message', JSON.stringify(messageData));
attachments.map(upload => sendData.append('uploads[]', upload));
} else {
sendData = JSON.stringify(messageData);
}
setIsSending(true);
dispatch(sendReply(replyToMessageId, sendData, attachments.length > 0))
.then(() => {
setTimeout(() => {
if (draftsCount > 1) {
// send a call to get updated thread
dispatch(retrieveMessageThread(replyMessage.messageId)).then(
setIsSending(false),
);
} else {
setIsSending(false);
navigateToFolderByFolderId(
draft?.threadFolderId ? draft?.threadFolderId : folderId,
history,
);
}
}, 1000);
})
.catch(() => {
setIsSending(false);
setSendMessageFlag(false);
setIsAutosave(true);
});
}
},
[sendMessageFlag, isSaving],
);
const populateForm = () => {
setSelectedRecipient(draft?.recipientId);
setCategory(draft.category);
setSubject(draft.subject);
setMessageBody(draft.body);
setFormPopulated(true);
setFieldsString(
JSON.stringify({
rec: draft.recipientId,
cat: draft.category,
sub: draft.subject,
bod: draft.body,
}),
);
};
useEffect(
() => {
if (messageInvalid) {
focusOnErrorField();
}
},
[messageInvalid],
);
useEffect(
() => {
if (draft && !formPopulated) {
populateForm();
}
},
[draft, formPopulated],
);
useEffect(
() => {
if (editMode && focusToTextarea) {
setTimeout(() => {
focusElement(
cannotReply
? composeFormActionButtonsRef.current?.querySelector(
'#delete-draft-button',
)
: textareaRef.current,
);
setFocusToTextarea(false);
}, 300);
}
},
[cannotReply, editMode, focusToTextarea],
);
return (
<>
{saveError && (
<VaModal
modalTitle={saveError.title}
onPrimaryButtonClick={() => setSaveError(null)}
onCloseEvent={() => setSaveError(null)}
primaryButtonText="Continue editing"
status="warning"
visible
>
<p>{saveError.p1}</p>
{saveError.p2 && <p>{saveError.p2}</p>}
</VaModal>
)}
<RouteLeavingGuard
when={!!navigationError}
modalVisible={isModalVisible}
setIsModalVisible={setIsModalVisible}
setSetErrorModal={setSavedDraft}
navigate={path => {
history.push(path);
}}
shouldBlockNavigation={() => {
return !!navigationError;
}}
title={navigationError?.title}
p1={navigationError?.p1}
p2={navigationError?.p2}
confirmButtonText={navigationError?.confirmButtonText}
cancelButtonText={navigationError?.cancelButtonText}
saveDraftHandler={saveDraftHandler}
savedDraft={savedDraft}
/>
<h3 className="vads-u-margin-bottom--0p5" slot="headline">
[Draft
{draftSequence ? ` ${draftSequence}]` : ']'}
</h3>
<>
<span
className="vads-u-display--flex vads-u-margin-top--3 vads-u-color--gray-dark vads-u-font-size--h4 vads-u-font-weight--bold"
data-testid="draft-reply-to"
style={{ whiteSpace: 'break-spaces', overflowWrap: 'anywhere' }}
data-dd-privacy="mask"
data-dd-action-name="Reply Draft Accordion Header"
>
<div className="vads-u-margin-right--0p5 vads-u-margin-top--0p25">
<va-icon icon="undo" aria-hidden="true" />
</div>
<span className="thread-list-draft reply-draft-label vads-u-padding-right--2">
{`Draft ${draftSequence ? `${draftSequence} ` : ''}`}
</span>
{`To: ${replyToName}\n(Team: ${draft?.triageGroupName ||
replyMessage.triageGroupName})`}
<br />
</span>
<HorizontalRule />
{cannotReply ? (
<section
aria-label="Message body."
className="vads-u-margin-top--1 old-reply-message-body"
>
<h3 className="sr-only">Message body.</h3>
<MessageThreadBody text={draft?.body} />
</section>
) : (
<va-textarea
ref={textareaRef}
data-dd-privacy="mask"
label="Message"
required
id={`reply-message-body${draftSequence ? `-${draftSequence}` : ''}`}
name={`reply-message-body${
draftSequence ? `-${draftSequence}` : ''
}`}
className="message-body"
data-testid={`message-body-field${
draftSequence ? `-${draftSequence}` : ''
}`}
onInput={messageBodyHandler}
value={draft?.body || formattededSignature} // populate with the signature, unless there is a saved draft
error={bodyError}
onFocus={e => {
setCaretToPos(e.target.shadowRoot.querySelector('textarea'), 0);
}}
/>
)}
{!cannotReply &&
!showBlockedTriageGroupAlert && (
<section className="attachments-section vads-u-margin-top--2">
<AttachmentsList
attachments={attachments}
reply
setAttachments={setAttachments}
setNavigationError={setNavigationError}
editingEnabled
attachFileSuccess={attachFileSuccess}
setAttachFileSuccess={setAttachFileSuccess}
draftSequence={draftSequence}
attachmentScanError={attachmentScanError}
/>
<FileInput
attachments={attachments}
setAttachments={setAttachments}
setAttachFileSuccess={setAttachFileSuccess}
draftSequence={draftSequence}
attachmentScanError={attachmentScanError}
/>
</section>
)}
<DraftSavedInfo messageId={draftId} drafts={drafts} />
<div ref={composeFormActionButtonsRef}>
<ComposeFormActionButtons
cannotReply={showBlockedTriageGroupAlert || cannotReply}
draftId={draft?.messageId}
draftsCount={draftsCount}
draftBody={draft?.body}
messageBody={messageBody}
navigationError={navigationError}
onSaveDraft={(type, e) => saveDraftHandler(type, e)}
onSend={sendMessageHandler}
refreshThreadCallback={refreshThreadHandler}
setNavigationError={setNavigationError}
draftSequence={draftSequence}
setHideDraft={setHideDraft}
setIsEditing={setIsEditing}
setIsModalVisible={setIsModalVisible}
isModalVisible={isModalVisible}
/>
</div>
</>
</>
);
};
ReplyDraftItem.propTypes = {
cannotReply: PropTypes.bool,
draft: PropTypes.object,
draftSequence: PropTypes.number,
drafts: PropTypes.array,
draftsCount: PropTypes.number,
editMode: PropTypes.bool,
isSaving: PropTypes.bool,
replyMessage: PropTypes.object,
replyToName: PropTypes.string,
setHideDraft: PropTypes.func,
setIsEditing: PropTypes.func,
setLastFocusableElement: PropTypes.func,
showBlockedTriageGroupAlert: PropTypes.bool,
signature: PropTypes.object,
setIsSending: PropTypes.func,
};
export default ReplyDraftItem;