diff --git a/src/CONST.ts b/src/CONST.ts index 4da05d85cfd2..19b729da7a45 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -295,6 +295,9 @@ type OnboardingMessage = { type?: string; }; +const EMAIL_WITH_OPTIONAL_DOMAIN = + /(?=((?=[\w'#%+-]+(?:\.[\w'#%+-]+)*@?)[\w.'#%+-]{1,64}(?:@(?:(?=[a-z\d]+(?:-+[a-z\d]+)*\.)(?:[a-z\d-]{1,63}\.)+[a-z]{2,63}))?(?= |_|\b))(?.*))\S{3,254}(?=\k$)/; + const CONST = { HEIC_SIGNATURES: [ '6674797068656963', // 'ftypheic' - Indicates standard HEIC file @@ -1315,7 +1318,7 @@ const CONST = { TEST_TOOLS_MODAL_THROTTLE_TIME: 800, TOOLTIP_SENSE: 1000, TRIE_INITIALIZATION: 'trie_initialization', - COMMENT_LENGTH_DEBOUNCE_TIME: 500, + COMMENT_LENGTH_DEBOUNCE_TIME: 1500, SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300, RESIZE_DEBOUNCE_TIME: 100, UNREAD_UPDATE_DEBOUNCE_TIME: 300, @@ -3045,6 +3048,14 @@ const CONST = { get EXPENSIFY_POLICY_DOMAIN_NAME() { return new RegExp(`${EXPENSIFY_POLICY_DOMAIN}([a-zA-Z0-9]+)\\${EXPENSIFY_POLICY_DOMAIN_EXTENSION}`); }, + + /** + * Matching task rule by group + * Group 1: Start task rule with [] + * Group 2: Optional email group between \s+....\s* start rule with @+valid email or short mention + * Group 3: Title is remaining characters + */ + TASK_TITLE_WITH_OPTONAL_SHORT_MENTION: `^\\[\\]\\s+(?:@(?:${EMAIL_WITH_OPTIONAL_DOMAIN}))?\\s*([\\s\\S]*)`, }, PRONOUNS: { diff --git a/src/components/ExceededCommentLength.tsx b/src/components/ExceededCommentLength.tsx index 2f0887afc8f1..5c3d529fd871 100644 --- a/src/components/ExceededCommentLength.tsx +++ b/src/components/ExceededCommentLength.tsx @@ -4,20 +4,26 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import Text from './Text'; -function ExceededCommentLength() { +type ExceededCommentLengthProps = { + maxCommentLength?: number; + isTaskTitle?: boolean; +}; + +function ExceededCommentLength({maxCommentLength = CONST.MAX_COMMENT_LENGTH, isTaskTitle}: ExceededCommentLengthProps) { const styles = useThemeStyles(); const {numberFormat, translate} = useLocalize(); + const translationKey = isTaskTitle ? 'composer.taskTitleExceededMaxLength' : 'composer.commentExceededMaxLength'; + return ( - {translate('composer.commentExceededMaxLength', {formattedMaxLength: numberFormat(CONST.MAX_COMMENT_LENGTH)})} + {translate(translationKey, {formattedMaxLength: numberFormat(maxCommentLength)})} ); } - ExceededCommentLength.displayName = 'ExceededCommentLength'; export default memo(ExceededCommentLength); diff --git a/src/hooks/useHandleExceedMaxCommentLength.ts b/src/hooks/useHandleExceedMaxCommentLength.ts index 8afa88da38b5..2c4fa1fd1886 100644 --- a/src/hooks/useHandleExceedMaxCommentLength.ts +++ b/src/hooks/useHandleExceedMaxCommentLength.ts @@ -1,5 +1,4 @@ -import debounce from 'lodash/debounce'; -import {useCallback, useMemo, useState} from 'react'; +import {useCallback, useState} from 'react'; import * as ReportUtils from '@libs/ReportUtils'; import type {ParsingDetails} from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -7,7 +6,7 @@ import CONST from '@src/CONST'; const useHandleExceedMaxCommentLength = () => { const [hasExceededMaxCommentLength, setHasExceededMaxCommentLength] = useState(false); - const handleValueChange = useCallback( + const validateCommentMaxLength = useCallback( (value: string, parsingDetails?: ParsingDetails) => { if (ReportUtils.getCommentLength(value, parsingDetails) <= CONST.MAX_COMMENT_LENGTH) { if (hasExceededMaxCommentLength) { @@ -20,9 +19,7 @@ const useHandleExceedMaxCommentLength = () => { [hasExceededMaxCommentLength], ); - const validateCommentMaxLength = useMemo(() => debounce(handleValueChange, 1500, {leading: true}), [handleValueChange]); - - return {hasExceededMaxCommentLength, validateCommentMaxLength}; + return {hasExceededMaxCommentLength, validateCommentMaxLength, setHasExceededMaxCommentLength}; }; export default useHandleExceedMaxCommentLength; diff --git a/src/hooks/useHandleExceedMaxTaskTitleLength.ts b/src/hooks/useHandleExceedMaxTaskTitleLength.ts new file mode 100644 index 000000000000..bb792a5da8b4 --- /dev/null +++ b/src/hooks/useHandleExceedMaxTaskTitleLength.ts @@ -0,0 +1,15 @@ +import {useCallback, useState} from 'react'; +import CONST from '@src/CONST'; + +const useHandleExceedMaxTaskTitleLength = () => { + const [hasExceededMaxTaskTitleLength, setHasExceededMaxTitleLength] = useState(false); + + const validateTaskTitleMaxLength = useCallback((title: string) => { + const exceeded = title ? title.length > CONST.TITLE_CHARACTER_LIMIT : false; + setHasExceededMaxTitleLength(exceeded); + }, []); + + return {hasExceededMaxTaskTitleLength, validateTaskTitleMaxLength, setHasExceededMaxTitleLength}; +}; + +export default useHandleExceedMaxTaskTitleLength; diff --git a/src/languages/en.ts b/src/languages/en.ts index 5a06ca3a826c..fe991b9715fc 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -529,6 +529,7 @@ const translations = { noExtensionFoundForMimeType: 'No extension found for mime type', problemGettingImageYouPasted: 'There was a problem getting the image you pasted', commentExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `The maximum comment length is ${formattedMaxLength} characters.`, + taskTitleExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `The maximum task title length is ${formattedMaxLength} characters.`, }, baseUpdateAppModal: { updateApp: 'Update app', diff --git a/src/languages/es.ts b/src/languages/es.ts index 38d2afb9f47d..b548653fe4db 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -520,6 +520,7 @@ const translations = { noExtensionFoundForMimeType: 'No se encontró una extension para este tipo de contenido', problemGettingImageYouPasted: 'Ha ocurrido un problema al obtener la imagen que has pegado', commentExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `El comentario debe tener máximo ${formattedMaxLength} caracteres.`, + taskTitleExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `La longitud máxima del título de una tarea es de ${formattedMaxLength} caracteres.`, }, baseUpdateAppModal: { updateApp: 'Actualizar app', diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 59d1b4c00683..32371232ad56 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,4 +1,5 @@ import {useNavigation} from '@react-navigation/native'; +import lodashDebounce from 'lodash/debounce'; import noop from 'lodash/noop'; import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputFocusEventData, TextInputSelectionChangeEventData} from 'react-native'; @@ -23,6 +24,7 @@ import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebounce from '@hooks/useDebounce'; import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength'; +import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitleLength'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -171,7 +173,9 @@ function ReportActionCompose({ * Updates the composer when the comment length is exceeded * Shows red borders and prevents the comment from being sent */ - const {hasExceededMaxCommentLength, validateCommentMaxLength} = useHandleExceedMaxCommentLength(); + const {hasExceededMaxCommentLength, validateCommentMaxLength, setHasExceededMaxCommentLength} = useHandleExceedMaxCommentLength(); + const {hasExceededMaxTaskTitleLength, validateTaskTitleMaxLength, setHasExceededMaxTitleLength} = useHandleExceedMaxTaskTitleLength(); + const [exceededMaxLength, setExceededMaxLength] = useState(null); const suggestionsRef = useRef(null); const composerRef = useRef(); @@ -306,6 +310,16 @@ function ReportActionCompose({ onComposerFocus?.(); }, [onComposerFocus]); + useEffect(() => { + if (hasExceededMaxTaskTitleLength) { + setExceededMaxLength(CONST.TITLE_CHARACTER_LIMIT); + } else if (hasExceededMaxCommentLength) { + setExceededMaxLength(CONST.MAX_COMMENT_LENGTH); + } else { + setExceededMaxLength(null); + } + }, [hasExceededMaxTaskTitleLength, hasExceededMaxCommentLength]); + // We are returning a callback here as we want to incoke the method on unmount only useEffect( () => () => { @@ -333,7 +347,7 @@ function ReportActionCompose({ const hasReportRecipient = !isEmptyObject(reportRecipient); - const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || !!disabled || hasExceededMaxCommentLength; + const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || !!disabled || !!exceededMaxLength; // Note: using JS refs is not well supported in reanimated, thus we need to store the function in a shared value // useSharedValue on web doesn't support functions, so we need to wrap it in an object. @@ -394,14 +408,31 @@ function ReportActionCompose({ ], ); + const validateMaxLength = useCallback( + (value: string) => { + const taskCommentMatch = value?.match(CONST.REGEX.TASK_TITLE_WITH_OPTONAL_SHORT_MENTION); + if (taskCommentMatch) { + const title = taskCommentMatch?.[3] ? taskCommentMatch[3].trim().replace(/\n/g, ' ') : ''; + setHasExceededMaxCommentLength(false); + validateTaskTitleMaxLength(title); + } else { + setHasExceededMaxTitleLength(false); + validateCommentMaxLength(value, {reportID}); + } + }, + [setHasExceededMaxCommentLength, setHasExceededMaxTitleLength, validateTaskTitleMaxLength, validateCommentMaxLength, reportID], + ); + + const debouncedValidate = useMemo(() => lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true}), [validateMaxLength]); + const onValueChange = useCallback( (value: string) => { if (value.length === 0 && isComposerFullSize) { Report.setIsComposerFullSize(reportID, false); } - validateCommentMaxLength(value, {reportID}); + debouncedValidate(value); }, - [isComposerFullSize, reportID, validateCommentMaxLength], + [isComposerFullSize, reportID, debouncedValidate], ); return ( @@ -436,7 +467,7 @@ function ReportActionCompose({ styles.flexRow, styles.chatItemComposeBox, isComposerFullSize && styles.chatItemFullComposeBox, - hasExceededMaxCommentLength && styles.borderColorDanger, + !!exceededMaxLength && styles.borderColorDanger, ]} > setIsAttachmentPreviewActive(true)} onModalHide={onAttachmentPreviewClose} - shouldDisableSendButton={hasExceededMaxCommentLength} + shouldDisableSendButton={!!exceededMaxLength} > {({displayFileInModal}) => ( <> @@ -463,7 +494,7 @@ function ReportActionCompose({ onAddActionPressed={onAddActionPressed} onItemSelected={onItemSelected} actionButtonRef={actionButtonRef} - shouldDisableAttachmentItem={hasExceededMaxCommentLength} + shouldDisableAttachmentItem={!!exceededMaxLength} /> { @@ -548,7 +579,12 @@ function ReportActionCompose({ > {!shouldUseNarrowLayout && } - {hasExceededMaxCommentLength && } + {!!exceededMaxLength && ( + + )} {!isSmallScreenWidth && ( diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 9f8d968bbc59..6dff93bd1af3 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -109,6 +109,7 @@ function ReportActionItemMessageEdit( const [selection, setSelection] = useState({start: draft.length, end: draft.length, positionX: 0, positionY: 0}); const [isFocused, setIsFocused] = useState(false); const {hasExceededMaxCommentLength, validateCommentMaxLength} = useHandleExceedMaxCommentLength(); + const debouncedValidateCommentMaxLength = useMemo(() => lodashDebounce(validateCommentMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME), [validateCommentMaxLength]); const [modal, setModal] = useState({ willAlertModalBecomeVisible: false, isVisible: false, @@ -453,8 +454,8 @@ function ReportActionItemMessageEdit( ); useEffect(() => { - validateCommentMaxLength(draft, {reportID}); - }, [draft, reportID, validateCommentMaxLength]); + debouncedValidateCommentMaxLength(draft, {reportID}); + }, [draft, reportID, debouncedValidateCommentMaxLength]); useEffect(() => { // required for keeping last state of isFocused variable diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index c087510374be..ec03393161f3 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -124,18 +124,7 @@ function ReportFooter({ const handleCreateTask = useCallback( (text: string): boolean => { - /** - * Matching task rule by group - * Group 1: Start task rule with [] - * Group 2: Optional email group between \s+....\s* start rule with @+valid email or short mention - * Group 3: Title is remaining characters - */ - // The regex is copied from the expensify-common CONST file, but the domain is optional to accept short mention - const emailWithOptionalDomainRegex = - /(?=((?=[\w'#%+-]+(?:\.[\w'#%+-]+)*@?)[\w.'#%+-]{1,64}(?:@(?:(?=[a-z\d]+(?:-+[a-z\d]+)*\.)(?:[a-z\d-]{1,63}\.)+[a-z]{2,63}))?(?= |_|\b))(?.*))\S{3,254}(?=\k$)/; - const taskRegex = `^\\[\\]\\s+(?:@(?:${emailWithOptionalDomainRegex.source}))?\\s*([\\s\\S]*)`; - - const match = text.match(taskRegex); + const match = text.match(CONST.REGEX.TASK_TITLE_WITH_OPTONAL_SHORT_MENTION); if (!match) { return false; }