diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index ab39e5379230..7f0178863fc9 100755 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -89,7 +89,7 @@ type AttachmentModalProps = AttachmentModalOnyxProps & { source?: AvatarSource; /** Optional callback to fire when we want to preview an image and approve it for use. */ - onConfirm?: ((file: Partial) => void) | null; + onConfirm?: ((file: FileObject) => void) | null; /** Whether the modal should be open by default */ defaultOpen?: boolean; @@ -264,7 +264,7 @@ function AttachmentModal({ } if (onConfirm) { - onConfirm(Object.assign(file ?? {}, {source: sourceState})); + onConfirm(Object.assign(file ?? {}, {source: sourceState} as FileObject)); } setIsModalOpen(false); @@ -318,7 +318,7 @@ function AttachmentModal({ const validateAndDisplayFileToUpload = useCallback( (data: FileObject) => { - if (!isDirectoryCheck(data)) { + if (!data || !isDirectoryCheck(data)) { return; } let fileObject = data; @@ -617,4 +617,4 @@ export default withOnyx({ }, })(memo(AttachmentModal)); -export type {Attachment}; +export type {Attachment, FileObject}; diff --git a/src/components/AvatarWithImagePicker.tsx b/src/components/AvatarWithImagePicker.tsx index fa8a6d71516f..4388ebb8f815 100644 --- a/src/components/AvatarWithImagePicker.tsx +++ b/src/components/AvatarWithImagePicker.tsx @@ -220,7 +220,7 @@ function AvatarWithImagePicker({ setError(null, {}); setIsMenuVisible(false); setImageData({ - uri: image.uri, + uri: image.uri ?? '', name: image.name, type: image.type, }); diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 516de55c73ba..b6443f3ca385 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -75,7 +75,7 @@ function Composer( shouldContainScroll = false, ...props }: ComposerProps, - ref: ForwardedRef, + ref: ForwardedRef, ) { const theme = useTheme(); const styles = useThemeStyles(); @@ -278,6 +278,7 @@ function Composer( if (!onKeyPress || isEnterWhileComposition(e as unknown as KeyboardEvent)) { return; } + onKeyPress(e); }, [onKeyPress], diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index d8d88970ea78..6bc44aba69cd 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -1,11 +1,11 @@ -import type {NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInputKeyPressEventData, TextInputSelectionChangeEventData, TextStyle} from 'react-native'; +import type {NativeSyntheticEvent, StyleProp, TextInputProps, TextInputSelectionChangeEventData, TextStyle} from 'react-native'; type TextSelection = { start: number; end?: number; }; -type ComposerProps = { +type ComposerProps = TextInputProps & { /** identify id in the text input */ id?: string; @@ -31,7 +31,7 @@ type ComposerProps = { onNumberOfLinesChange?: (numberOfLines: number) => void; /** Callback method to handle pasting a file */ - onPasteFile?: (file?: File) => void; + onPasteFile?: (file: File) => void; /** General styles to apply to the text input */ // eslint-disable-next-line react/forbid-prop-types @@ -74,12 +74,6 @@ type ComposerProps = { /** Whether the sull composer is open */ isComposerFullSize?: boolean; - onKeyPress?: (event: NativeSyntheticEvent) => void; - - onFocus?: (event: NativeSyntheticEvent) => void; - - onBlur?: (event: NativeSyntheticEvent) => void; - /** Should make the input only scroll inside the element avoid scroll out to parent */ shouldContainScroll?: boolean; }; diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx index 7313bb4aa7bb..25b468181b87 100644 --- a/src/components/LocaleContextProvider.tsx +++ b/src/components/LocaleContextProvider.tsx @@ -132,4 +132,4 @@ Provider.displayName = 'withOnyx(LocaleContextProvider)'; export {Provider as LocaleContextProvider, LocaleContext}; -export type {LocaleContextProps}; +export type {LocaleContextProps, Locale}; diff --git a/src/components/MentionSuggestions.tsx b/src/components/MentionSuggestions.tsx index 459131ecc434..23040a242807 100644 --- a/src/components/MentionSuggestions.tsx +++ b/src/components/MentionSuggestions.tsx @@ -1,4 +1,5 @@ import React, {useCallback} from 'react'; +import type {MeasureInWindowOnSuccessCallback} from 'react-native'; import {View} from 'react-native'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -18,7 +19,7 @@ type Mention = { alternateText: string; /** Email/phone number of the user */ - login: string; + login?: string; /** Array of icons of the user. We use the first element of this array */ icons: Icon[]; @@ -32,7 +33,7 @@ type MentionSuggestionsProps = { mentions: Mention[]; /** Fired when the user selects a mention */ - onSelect: () => void; + onSelect: (highlightedMentionIndex: number) => void; /** Mention prefix that follows the @ sign */ prefix: string; @@ -43,7 +44,7 @@ type MentionSuggestionsProps = { isMentionPickerLarge: boolean; /** Measures the parent container's position and dimensions. */ - measureParentContainer: () => void; + measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; }; /** @@ -142,3 +143,5 @@ function MentionSuggestions({prefix, mentions, highlightedMentionIndex = 0, onSe MentionSuggestions.displayName = 'MentionSuggestions'; export default MentionSuggestions; + +export type {Mention}; diff --git a/src/libs/API/parameters/AddCommentOrAttachementParams.ts b/src/libs/API/parameters/AddCommentOrAttachementParams.ts index 58faf9fdfc9c..a705c92f7f27 100644 --- a/src/libs/API/parameters/AddCommentOrAttachementParams.ts +++ b/src/libs/API/parameters/AddCommentOrAttachementParams.ts @@ -1,9 +1,11 @@ +import type {FileObject} from '@components/AttachmentModal'; + type AddCommentOrAttachementParams = { reportID: string; reportActionID?: string; commentReportActionID?: string | null; reportComment?: string; - file?: File; + file?: FileObject; timezone?: string; shouldAllowActionableMentionWhispers?: boolean; clientCreatedTime?: string; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index d3514a110314..6da5c8af1ff2 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -265,7 +265,7 @@ function formatToLongDateWithWeekday(datetime: string | Date): string { * * @returns Sunday */ -function formatToDayOfWeek(datetime: string): string { +function formatToDayOfWeek(datetime: Date): string { return format(new Date(datetime), CONST.DATE.WEEKDAY_TIME_FORMAT); } diff --git a/src/libs/E2E/tests/chatOpeningTest.e2e.ts b/src/libs/E2E/tests/chatOpeningTest.e2e.ts index ef380f847c3f..17d9dfa1cb4d 100644 --- a/src/libs/E2E/tests/chatOpeningTest.e2e.ts +++ b/src/libs/E2E/tests/chatOpeningTest.e2e.ts @@ -1,14 +1,14 @@ +import type {NativeConfig} from 'react-native-config'; import E2ELogin from '@libs/E2E/actions/e2eLogin'; import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded'; import E2EClient from '@libs/E2E/client'; -import type {TestConfig} from '@libs/E2E/types'; import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow'; import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -const test = (config: TestConfig) => { +const test = (config: NativeConfig) => { // check for login (if already logged in the action will simply resolve) console.debug('[E2E] Logging in for chat opening'); diff --git a/src/libs/E2E/tests/reportTypingTest.e2e.ts b/src/libs/E2E/tests/reportTypingTest.e2e.ts index 4e0678aeb020..817bda941611 100644 --- a/src/libs/E2E/tests/reportTypingTest.e2e.ts +++ b/src/libs/E2E/tests/reportTypingTest.e2e.ts @@ -1,9 +1,9 @@ +import type {NativeConfig} from 'react-native-config'; import Config from 'react-native-config'; import E2ELogin from '@libs/E2E/actions/e2eLogin'; import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded'; import waitForKeyboard from '@libs/E2E/actions/waitForKeyboard'; import E2EClient from '@libs/E2E/client'; -import type {TestConfig} from '@libs/E2E/types'; import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow'; import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import * as NativeCommands from '../../../../tests/e2e/nativeCommands/NativeCommandsAction'; -const test = (config: TestConfig) => { +const test = (config: NativeConfig) => { // check for login (if already logged in the action will simply resolve) console.debug('[E2E] Logging in for typing'); diff --git a/src/libs/E2E/types.ts b/src/libs/E2E/types.ts index 2d48813fa115..93640fbb4ce8 100644 --- a/src/libs/E2E/types.ts +++ b/src/libs/E2E/types.ts @@ -20,7 +20,7 @@ type NetworkCacheMap = Record< type TestConfig = { name: string; - [key: string]: string; + [key: string]: string | {autoFocus: boolean}; }; export type {SigninParams, IsE2ETestSession, NetworkCacheMap, NetworkCacheEntry, TestConfig}; diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts index 2b9e4c6fcd8a..cab0f48d75fd 100644 --- a/src/libs/EmojiUtils.ts +++ b/src/libs/EmojiUtils.ts @@ -10,7 +10,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {FrequentlyUsedEmoji, Locale} from '@src/types/onyx'; import type {ReportActionReaction, UsersReactions} from '@src/types/onyx/ReportActionReactions'; import type IconAsset from '@src/types/utils/IconAsset'; -import type {SupportedLanguage} from './EmojiTrie'; type HeaderIndice = {code: string; index: number; icon: IconAsset}; type EmojiSpacer = {code: string; spacer: boolean}; @@ -384,7 +383,7 @@ function replaceAndExtractEmojis(text: string, preferredSkinTone: number = CONST * Suggest emojis when typing emojis prefix after colon * @param [limit] - matching emojis limit */ -function suggestEmojis(text: string, lang: SupportedLanguage, limit: number = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS): Emoji[] | undefined { +function suggestEmojis(text: string, lang: Locale, limit: number = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS): Emoji[] | undefined { // emojisTrie is importing the emoji JSON file on the app starting and we want to avoid it const emojisTrie = require('./EmojiTrie').default; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 0f8656adfa51..29d8e7ae9af2 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -8,6 +8,7 @@ import lodashIsEqual from 'lodash/isEqual'; import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import type {FileObject} from '@components/AttachmentModal'; import * as Expensicons from '@components/Icon/Expensicons'; import * as defaultWorkspaceAvatars from '@components/Icon/WorkspaceDefaultAvatars'; import CONST from '@src/CONST'; @@ -1323,7 +1324,7 @@ function getRoomWelcomeMessage(report: OnyxEntry, isUserPolicyAdmin: boo /** * Returns true if Concierge is one of the chat participants (1:1 as well as group chats) */ -function chatIncludesConcierge(report: OnyxEntry): boolean { +function chatIncludesConcierge(report: Partial>): boolean { return Boolean(report?.participantAccountIDs?.length && report?.participantAccountIDs?.includes(CONST.ACCOUNT_ID.CONCIERGE)); } @@ -2698,7 +2699,7 @@ function getPolicyDescriptionText(policy: Policy): string { return parser.htmlToText(policy.description); } -function buildOptimisticAddCommentReportAction(text?: string, file?: File, actorAccountID?: number): OptimisticReportAction { +function buildOptimisticAddCommentReportAction(text?: string, file?: FileObject, actorAccountID?: number): OptimisticReportAction { const parser = new ExpensiMark(); const commentText = getParsedComment(text ?? ''); const isAttachment = !text && file !== undefined; diff --git a/src/libs/actions/EmojiPickerAction.ts b/src/libs/actions/EmojiPickerAction.ts index 0fc21713c608..c63e1c13d29a 100644 --- a/src/libs/actions/EmojiPickerAction.ts +++ b/src/libs/actions/EmojiPickerAction.ts @@ -68,7 +68,7 @@ function showEmojiPicker( /** * Hide the Emoji Picker modal. */ -function hideEmojiPicker(isNavigating: boolean) { +function hideEmojiPicker(isNavigating?: boolean) { if (!emojiPickerRef.current) { return; } diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index f29f8a4fbaab..b0834ee6dec6 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -7,6 +7,7 @@ import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-nat import Onyx from 'react-native-onyx'; import type {PartialDeep, ValueOf} from 'type-fest'; import type {Emoji} from '@assets/emojis/types'; +import type {FileObject} from '@components/AttachmentModal'; import * as ActiveClientManager from '@libs/ActiveClientManager'; import * as API from '@libs/API'; import type { @@ -355,7 +356,7 @@ function notifyNewAction(reportID: string, accountID?: number, reportActionID?: * - Adding one attachment * - Add both a comment and attachment simultaneously */ -function addActions(reportID: string, text = '', file?: File) { +function addActions(reportID: string, text = '', file?: FileObject) { let reportCommentText = ''; let reportCommentAction: OptimisticAddCommentReportAction | undefined; let attachmentAction: OptimisticAddCommentReportAction | undefined; @@ -514,7 +515,7 @@ function addActions(reportID: string, text = '', file?: File) { } /** Add an attachment and optional comment. */ -function addAttachment(reportID: string, file: File, text = '') { +function addAttachment(reportID: string, file: FileObject, text = '') { addActions(reportID, text, file); } diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index d7cef2aca546..5d089ed6e393 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -33,7 +33,7 @@ import playSoundExcludingMobile from '@libs/Sound/playSoundExcludingMobile'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {FrequentlyUsedEmoji} from '@src/types/onyx'; +import type {BlockedFromConcierge, FrequentlyUsedEmoji} from '@src/types/onyx'; import type Login from '@src/types/onyx/Login'; import type {OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer'; import type OnyxPersonalDetails from '@src/types/onyx/PersonalDetails'; @@ -47,8 +47,6 @@ import * as OnyxUpdates from './OnyxUpdates'; import * as Report from './Report'; import * as Session from './Session'; -type BlockedFromConciergeNVP = {expiresAt: number}; - let currentUserAccountID = -1; let currentEmail = ''; Onyx.connect({ @@ -447,7 +445,7 @@ function validateSecondaryLogin(contactMethod: string, validateCode: string) { * and if so whether the expiresAt date of a user's ban is before right now * */ -function isBlockedFromConcierge(blockedFromConciergeNVP: OnyxEntry): boolean { +function isBlockedFromConcierge(blockedFromConciergeNVP: OnyxEntry): boolean { if (isEmptyObject(blockedFromConciergeNVP)) { return false; } diff --git a/src/pages/home/report/ParticipantLocalTime.js b/src/pages/home/report/ParticipantLocalTime.tsx similarity index 63% rename from src/pages/home/report/ParticipantLocalTime.js rename to src/pages/home/report/ParticipantLocalTime.tsx index 1992953c959e..f51032690a33 100644 --- a/src/pages/home/report/ParticipantLocalTime.js +++ b/src/pages/home/report/ParticipantLocalTime.tsx @@ -1,24 +1,23 @@ -import lodashGet from 'lodash/get'; import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; -import participantPropTypes from '@components/participantPropTypes'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; import Timers from '@libs/Timers'; +import type {Locale} from '@src/components/LocaleContextProvider'; import CONST from '@src/CONST'; +import type {PersonalDetails} from '@src/types/onyx'; -const propTypes = { +type ParticipantLocalTimeProps = { /** Personal details of the participant */ - participant: participantPropTypes.isRequired, - - ...withLocalizePropTypes, + participant: PersonalDetails; }; -function getParticipantLocalTime(participant, preferredLocale) { - const reportRecipientTimezone = lodashGet(participant, 'timezone', CONST.DEFAULT_TIME_ZONE); - const reportTimezone = DateUtils.getLocalDateFromDatetime(preferredLocale, null, reportRecipientTimezone.selected); +function getParticipantLocalTime(participant: PersonalDetails, preferredLocale: Locale) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null + const reportRecipientTimezone = participant.timezone || CONST.DEFAULT_TIME_ZONE; + const reportTimezone = DateUtils.getLocalDateFromDatetime(preferredLocale, undefined, reportRecipientTimezone.selected); const currentTimezone = DateUtils.getLocalDateFromDatetime(preferredLocale); const reportRecipientDay = DateUtils.formatToDayOfWeek(reportTimezone); const currentUserDay = DateUtils.formatToDayOfWeek(currentTimezone); @@ -28,9 +27,9 @@ function getParticipantLocalTime(participant, preferredLocale) { return `${DateUtils.formatToLocalTime(reportTimezone)}`; } -function ParticipantLocalTime(props) { +function ParticipantLocalTime({participant}: ParticipantLocalTimeProps) { + const {translate, preferredLocale} = useLocalize(); const styles = useThemeStyles(); - const {participant, preferredLocale, translate} = props; const [localTime, setLocalTime] = useState(() => getParticipantLocalTime(participant, preferredLocale)); useEffect(() => { @@ -44,7 +43,8 @@ function ParticipantLocalTime(props) { }; }, [participant, preferredLocale]); - const reportRecipientDisplayName = lodashGet(props, 'participant.firstName') || lodashGet(props, 'participant.displayName'); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null + const reportRecipientDisplayName = participant.firstName || participant.displayName; if (!reportRecipientDisplayName) { return null; @@ -65,7 +65,6 @@ function ParticipantLocalTime(props) { ); } -ParticipantLocalTime.propTypes = propTypes; ParticipantLocalTime.displayName = 'ParticipantLocalTime'; -export default withLocalize(ParticipantLocalTime); +export default ParticipantLocalTime; diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx similarity index 81% rename from src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js rename to src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 72727168cad6..68c7f0883683 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -1,112 +1,94 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; +import {useIsFocused} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo} from 'react'; import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {ValueOf} from 'type-fest'; +import type {FileObject} from '@components/AttachmentModal'; import AttachmentPicker from '@components/AttachmentPicker'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; import PopoverMenu from '@components/PopoverMenu'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; -import withNavigationFocus from '@components/withNavigationFocus'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; -import compose from '@libs/compose'; import * as ReportUtils from '@libs/ReportUtils'; import * as IOU from '@userActions/IOU'; import * as Report from '@userActions/Report'; import * as Task from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; -const propTypes = { - /** The report currently being looked at */ - report: PropTypes.shape({ - /** ID of the report */ - reportID: PropTypes.string, - - /** Whether or not the report is in the process of being created */ - loading: PropTypes.bool, - }).isRequired, +type MoneyRequestOptions = Record, PopoverMenuItem>; +type AttachmentPickerWithMenuItemsOnyxProps = { /** The policy tied to the report */ - policy: PropTypes.shape({ - /** Type of the policy */ - type: PropTypes.string, - }), + policy: OnyxEntry; +}; - /** The personal details of everyone in the report */ - reportParticipantIDs: PropTypes.arrayOf(PropTypes.number), +type AttachmentPickerWithMenuItemsProps = AttachmentPickerWithMenuItemsOnyxProps & { + /** The report currently being looked at */ + report: OnyxEntry; /** Callback to open the file in the modal */ - displayFileInModal: PropTypes.func.isRequired, + displayFileInModal: (url: FileObject) => void; /** Whether or not the full size composer is available */ - isFullComposerAvailable: PropTypes.bool.isRequired, + isFullComposerAvailable: boolean; /** Whether or not the composer is full size */ - isComposerFullSize: PropTypes.bool.isRequired, + isComposerFullSize: boolean; /** Whether or not the user is blocked from concierge */ - isBlockedFromConcierge: PropTypes.bool.isRequired, + isBlockedFromConcierge: boolean; /** Whether or not the attachment picker is disabled */ - disabled: PropTypes.bool, + disabled?: boolean; /** Sets the menu visibility */ - setMenuVisibility: PropTypes.func.isRequired, + setMenuVisibility: (isVisible: boolean) => void; /** Whether or not the menu is visible */ - isMenuVisible: PropTypes.bool.isRequired, + isMenuVisible: boolean; /** Report ID */ - reportID: PropTypes.string.isRequired, + reportID: string; /** Called when opening the attachment picker */ - onTriggerAttachmentPicker: PropTypes.func.isRequired, + onTriggerAttachmentPicker: () => void; /** Called when cancelling the attachment picker */ - onCanceledAttachmentPicker: PropTypes.func.isRequired, + onCanceledAttachmentPicker: () => void; /** Called when the menu with the items is closed after it was open */ - onMenuClosed: PropTypes.func.isRequired, + onMenuClosed: () => void; /** Called when the add action button is pressed */ - onAddActionPressed: PropTypes.func.isRequired, + onAddActionPressed: () => void; /** Called when the menu item is selected */ - onItemSelected: PropTypes.func.isRequired, + onItemSelected: () => void; /** A ref for the add action button */ - actionButtonRef: PropTypes.shape({ - // eslint-disable-next-line react/forbid-prop-types - current: PropTypes.object, - }).isRequired, - - /** Whether or not the screen is focused */ - isFocused: PropTypes.bool.isRequired, + actionButtonRef: React.RefObject; /** A function that toggles isScrollLikelyLayoutTriggered flag for a certain period of time */ - raiseIsScrollLikelyLayoutTriggered: PropTypes.func.isRequired, -}; + raiseIsScrollLikelyLayoutTriggered: () => void; -const defaultProps = { - reportParticipantIDs: [], - disabled: false, - policy: {}, + /** The personal details of everyone in the report */ + reportParticipantIDs?: number[]; }; /** * This includes the popover of options you see when pressing the + button in the composer. * It also contains the attachment picker, as the menu items need to be able to open it. - * - * @returns {React.Component} */ function AttachmentPickerWithMenuItems({ report, @@ -126,9 +108,9 @@ function AttachmentPickerWithMenuItems({ onAddActionPressed, onItemSelected, actionButtonRef, - isFocused, raiseIsScrollLikelyLayoutTriggered, -}) { +}: AttachmentPickerWithMenuItemsProps) { + const isFocused = useIsFocused(); const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -136,37 +118,35 @@ function AttachmentPickerWithMenuItems({ /** * Returns the list of IOU Options - * @returns {Array} */ const moneyRequestOptions = useMemo(() => { - const options = { + const options: MoneyRequestOptions = { [CONST.IOU.TYPE.SPLIT]: { icon: Expensicons.Receipt, text: translate('iou.splitBill'), - onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.SPLIT, report.reportID), + onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.SPLIT, report?.reportID ?? ''), }, [CONST.IOU.TYPE.REQUEST]: { icon: Expensicons.MoneyCircle, text: translate('iou.requestMoney'), - onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.REQUEST, report.reportID), + onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.REQUEST, report?.reportID ?? ''), }, [CONST.IOU.TYPE.SEND]: { icon: Expensicons.Send, text: translate('iou.sendMoney'), - onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.SEND, report.reportID), + onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.SEND, report?.reportID), }, }; - return _.map(ReportUtils.getMoneyRequestOptions(report, policy, reportParticipantIDs), (option) => ({ + return ReportUtils.getMoneyRequestOptions(report, policy, reportParticipantIDs ?? []).map((option) => ({ ...options[option], })); }, [report, policy, reportParticipantIDs, translate]); /** * Determines if we can show the task option - * @returns {Boolean} */ - const taskOption = useMemo(() => { + const taskOption: PopoverMenuItem[] = useMemo(() => { if (!ReportUtils.canCreateTaskInReport(report)) { return []; } @@ -205,6 +185,7 @@ function AttachmentPickerWithMenuItems({ return ( + {/* @ts-expect-error TODO: Remove this once AttachmentPicker (https://github.com/Expensify/App/issues/25134) is migrated to TypeScript. */} {({openPicker}) => { const triggerAttachmentPicker = () => { onTriggerAttachmentPicker(); @@ -234,7 +215,7 @@ function AttachmentPickerWithMenuItems({ { - e.preventDefault(); + e?.preventDefault(); raiseIsScrollLikelyLayoutTriggered(); Report.setIsComposerFullSize(reportID, false); }} @@ -256,7 +237,7 @@ function AttachmentPickerWithMenuItems({ { - e.preventDefault(); + e?.preventDefault(); raiseIsScrollLikelyLayoutTriggered(); Report.setIsComposerFullSize(reportID, true); }} @@ -278,14 +259,14 @@ function AttachmentPickerWithMenuItems({ { - e.preventDefault(); + e?.preventDefault(); if (!isFocused) { return; } onAddActionPressed(); // Drop focus to avoid blue focus ring. - actionButtonRef.current.blur(); + actionButtonRef.current?.blur(); setMenuVisibility(!isMenuVisible); }} style={styles.composerSizeButton} @@ -328,15 +309,10 @@ function AttachmentPickerWithMenuItems({ ); } -AttachmentPickerWithMenuItems.propTypes = propTypes; -AttachmentPickerWithMenuItems.defaultProps = defaultProps; AttachmentPickerWithMenuItems.displayName = 'AttachmentPickerWithMenuItems'; -export default compose( - withNavigationFocus, - withOnyx({ - policy: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${lodashGet(report, 'policyID')}`, - }, - }), -)(AttachmentPickerWithMenuItems); +export default withOnyx({ + policy: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, + }, +})(AttachmentPickerWithMenuItems); diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx similarity index 65% rename from src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js rename to src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 026df340040e..af2d0b9eab56 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -1,11 +1,24 @@ import {useIsFocused, useNavigation} from '@react-navigation/native'; -import lodashGet from 'lodash/get'; -import React, {memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import lodashDebounce from 'lodash/debounce'; +import type {ForwardedRef, MutableRefObject, RefAttributes, RefObject} from 'react'; +import React, {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import type { + LayoutChangeEvent, + MeasureInWindowOnSuccessCallback, + NativeSyntheticEvent, + TextInput, + TextInputFocusEventData, + TextInputKeyPressEventData, + TextInputSelectionChangeEventData, +} from 'react-native'; import {findNodeHandle, InteractionManager, NativeModules, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {useAnimatedRef} from 'react-native-reanimated'; +import type {Emoji} from '@assets/emojis/types'; +import type {FileObject} from '@components/AttachmentModal'; import Composer from '@components/Composer'; -import withKeyboardState from '@components/withKeyboardState'; +import useKeyboardState from '@hooks/useKeyboardState'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -14,7 +27,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; -import compose from '@libs/compose'; import * as ComposerUtils from '@libs/ComposerUtils'; import getDraftComment from '@libs/ComposerUtils/getDraftComment'; import convertToLTRForComposer from '@libs/convertToLTRForComposer'; @@ -28,6 +40,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import * as SuggestionUtils from '@libs/SuggestionUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; +import type {ComposerRef, SuggestionsRef} from '@pages/home/report/ReportActionCompose/ReportActionCompose'; import SilentCommentUpdater from '@pages/home/report/ReportActionCompose/SilentCommentUpdater'; import Suggestions from '@pages/home/report/ReportActionCompose/Suggestions'; import * as EmojiPickerActions from '@userActions/EmojiPickerAction'; @@ -36,7 +49,128 @@ import * as Report from '@userActions/Report'; import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {defaultProps, propTypes} from './composerWithSuggestionsProps'; +import type * as OnyxTypes from '@src/types/onyx'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; + +type SyncSelection = { + position: number; + value: string; +}; + +type AnimatedRef = ReturnType; + +type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string}; + +type ComposerWithSuggestionsOnyxProps = { + /** The number of lines the comment should take up */ + numberOfLines: OnyxEntry; + + /** The parent report actions for the report */ + parentReportActions: OnyxEntry; + + /** The modal state */ + modal: OnyxEntry; + + /** The preferred skin tone of the user */ + preferredSkinTone: number; + + /** Whether the input is focused */ + editFocused: OnyxEntry; +}; + +type ComposerWithSuggestionsProps = ComposerWithSuggestionsOnyxProps & + Partial & { + /** Report ID */ + reportID: string; + + /** Callback to focus composer */ + onFocus: () => void; + + /** Callback to blur composer */ + onBlur: (event: NativeSyntheticEvent) => void; + + /** Callback to update the value of the composer */ + onValueChange: (value: string) => void; + + /** Whether the composer is full size */ + isComposerFullSize: boolean; + + /** Whether the menu is visible */ + isMenuVisible: boolean; + + /** The placeholder for the input */ + inputPlaceholder: string; + + /** Function to display a file in a modal */ + displayFileInModal: (file: FileObject) => void; + + /** Whether the text input should clear */ + textInputShouldClear: boolean; + + /** Function to set the text input should clear */ + setTextInputShouldClear: (shouldClear: boolean) => void; + + /** Whether the user is blocked from concierge */ + isBlockedFromConcierge: boolean; + + /** Whether the input is disabled */ + disabled: boolean; + + /** Whether the full composer is available */ + isFullComposerAvailable: boolean; + + /** Function to set whether the full composer is available */ + setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void; + + /** Function to set whether the comment is empty */ + setIsCommentEmpty: (isCommentEmpty: boolean) => void; + + /** Function to handle sending a message */ + handleSendMessage: () => void; + + /** Whether the compose input should show */ + shouldShowComposeInput: OnyxEntry; + + /** Function to measure the parent container */ + measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; + + /** The height of the list */ + listHeight: number; + + /** Whether the scroll is likely to trigger a layout */ + isScrollLikelyLayoutTriggered: RefObject; + + /** Function to raise the scroll is likely layout triggered */ + raiseIsScrollLikelyLayoutTriggered: () => void; + + /** The ref to the suggestions */ + suggestionsRef: React.RefObject; + + /** The ref to the animated input */ + animatedRef: AnimatedRef; + + /** The ref to the next modal will open */ + isNextModalWillOpenRef: MutableRefObject; + + /** Whether the edit is focused */ + editFocused: boolean; + + /** Wheater chat is empty */ + isEmptyChat?: boolean; + + /** The last report action */ + lastReportAction?: OnyxTypes.ReportAction; + + /** Whether to include chronos */ + includeChronos?: boolean; + + /** The parent report action ID */ + parentReportActionID?: string; + + /** The parent report ID */ + // eslint-disable-next-line react/no-unused-prop-types -- its used in the withOnyx HOC + parentReportID: string | undefined; + }; const {RNTextInputReset} = NativeModules; @@ -44,9 +178,8 @@ const isIOSNative = getPlatform() === CONST.PLATFORM.IOS; /** * Broadcast that the user is typing. Debounced to limit how often we publish client events. - * @param {String} reportID */ -const debouncedBroadcastUserIsTyping = _.debounce((reportID) => { +const debouncedBroadcastUserIsTyping = lodashDebounce((reportID: string) => { Report.broadcastUserIsTyping(reportID); }, 100); @@ -61,63 +194,66 @@ const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); * If a component really needs access to these state values it should be put here. * However, double check if the component really needs access, as it will re-render * on every key press. - * @param {Object} props - * @returns {React.Component} */ -function ComposerWithSuggestions({ - // Onyx - modal, - preferredSkinTone, - parentReportActions, - numberOfLines, - // HOCs - isKeyboardShown, - // Props: Report - reportID, - includeChronos, - isEmptyChat, - lastReportAction, - parentReportActionID, - // Focus - onFocus, - onBlur, - onValueChange, - // Composer - isComposerFullSize, - isMenuVisible, - inputPlaceholder, - displayFileInModal, - textInputShouldClear, - setTextInputShouldClear, - isBlockedFromConcierge, - disabled, - isFullComposerAvailable, - setIsFullComposerAvailable, - setIsCommentEmpty, - handleSendMessage, - shouldShowComposeInput, - measureParentContainer, - listHeight, - isScrollLikelyLayoutTriggered, - raiseIsScrollLikelyLayoutTriggered, - // Refs - suggestionsRef, - animatedRef, - forwardedRef, - isNextModalWillOpenRef, - editFocused, - // For testing - children, -}) { +function ComposerWithSuggestions( + { + // Onyx + modal, + preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, + parentReportActions, + numberOfLines, + + // Props: Report + reportID, + includeChronos, + isEmptyChat, + lastReportAction, + parentReportActionID, + + // Focus + onFocus, + onBlur, + onValueChange, + + // Composer + isComposerFullSize, + isMenuVisible, + inputPlaceholder, + displayFileInModal, + textInputShouldClear, + setTextInputShouldClear, + isBlockedFromConcierge, + disabled, + isFullComposerAvailable, + setIsFullComposerAvailable, + setIsCommentEmpty, + handleSendMessage, + shouldShowComposeInput, + measureParentContainer = () => {}, + listHeight, + isScrollLikelyLayoutTriggered, + raiseIsScrollLikelyLayoutTriggered, + + // Refs + suggestionsRef, + animatedRef, + isNextModalWillOpenRef, + editFocused, + + // For testing + children, + }: ComposerWithSuggestionsProps, + ref: ForwardedRef, +) { + const {isKeyboardShown} = useKeyboardState(); const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {preferredLocale} = useLocalize(); const isFocused = useIsFocused(); const navigation = useNavigation(); - const emojisPresentBefore = useRef([]); - - const draftComment = getDraftComment(reportID) || ''; + const emojisPresentBefore = useRef([]); + const draftComment = getDraftComment(reportID) ?? ''; const [value, setValue] = useState(() => { if (draftComment) { emojisPresentBefore.current = EmojiUtils.extractEmojis(draftComment); @@ -130,9 +266,9 @@ function ComposerWithSuggestions({ const {isSmallScreenWidth} = useWindowDimensions(); const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - const parentReportAction = lodashGet(parentReportActions, [parentReportActionID]); + const parentReportAction = parentReportActions?.[parentReportActionID ?? ''] ?? null; const shouldAutoFocus = - !modal.isVisible && isFocused && (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentReportAction))) && shouldShowComposeInput; + !modal?.isVisible && isFocused && (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentReportAction))) && shouldShowComposeInput; const valueRef = useRef(value); valueRef.current = value; @@ -141,14 +277,14 @@ function ComposerWithSuggestions({ const [composerHeight, setComposerHeight] = useState(0); - const textInputRef = useRef(null); - const insertedEmojisRef = useRef([]); + const textInputRef = useRef(null); + const insertedEmojisRef = useRef([]); - const syncSelectionWithOnChangeTextRef = useRef(null); + const syncSelectionWithOnChangeTextRef = useRef(null); - const suggestions = lodashGet(suggestionsRef, 'current.getSuggestions', () => [])(); + const suggestions = suggestionsRef.current?.getSuggestions() ?? []; - const hasEnoughSpaceForLargeSuggestion = SuggestionUtils.hasEnoughSpaceForLargeSuggestionMenu(listHeight, composerHeight, suggestions.length); + const hasEnoughSpaceForLargeSuggestion = SuggestionUtils.hasEnoughSpaceForLargeSuggestionMenu(listHeight, composerHeight, suggestions?.length ?? 0); const isAutoSuggestionPickerLarge = !isSmallScreenWidth || (isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion); @@ -163,15 +299,13 @@ function ComposerWithSuggestions({ /** * Set the TextInput Ref - * - * @param {Element} el - * @memberof ReportActionCompose */ const setTextInputRef = useCallback( - (el) => { + (el: TextInput) => { + // @ts-expect-error need to reassign this ref ReportActionComposeFocusManager.composerRef.current = el; textInputRef.current = el; - if (_.isFunction(animatedRef)) { + if (typeof animatedRef === 'function') { animatedRef(el); } }, @@ -187,7 +321,7 @@ function ComposerWithSuggestions({ const debouncedSaveReportComment = useMemo( () => - _.debounce((selectedReportID, newComment) => { + lodashDebounce((selectedReportID, newComment) => { Report.saveReportComment(selectedReportID, newComment || ''); }, 1000), [], @@ -196,15 +330,15 @@ function ComposerWithSuggestions({ /** * Find the newly added characters between the previous text and the new text based on the selection. * - * @param {string} prevText - The previous text. - * @param {string} newText - The new text. - * @returns {object} An object containing information about the newly added characters. - * @property {number} startIndex - The start index of the newly added characters in the new text. - * @property {number} endIndex - The end index of the newly added characters in the new text. - * @property {string} diff - The newly added characters. + * @param prevText - The previous text. + * @param newText - The new text. + * @returns An object containing information about the newly added characters. + * @property startIndex - The start index of the newly added characters in the new text. + * @property endIndex - The end index of the newly added characters in the new text. + * @property diff - The newly added characters. */ const findNewlyAddedChars = useCallback( - (prevText, newText) => { + (prevText: string, newText: string): NewlyAddedChars => { let startIndex = -1; let endIndex = -1; let currentIndex = 0; @@ -224,7 +358,6 @@ function ComposerWithSuggestions({ endIndex = currentIndex + newText.length; } } - return { startIndex, endIndex, @@ -236,12 +369,9 @@ function ComposerWithSuggestions({ /** * Update the value of the comment in Onyx - * - * @param {String} comment - * @param {Boolean} shouldDebounceSaveComment */ const updateComment = useCallback( - (commentValue, shouldDebounceSaveComment) => { + (commentValue: string, shouldDebounceSaveComment?: boolean) => { raiseIsScrollLikelyLayoutTriggered(); const {startIndex, endIndex, diff} = findNewlyAddedChars(lastTextRef.current, commentValue); const isEmojiInserted = diff.length && endIndex > startIndex && diff.trim() === diff && EmojiUtils.containsOnlyEmojis(diff); @@ -250,9 +380,9 @@ function ComposerWithSuggestions({ emojis, cursorPosition, } = EmojiUtils.replaceAndExtractEmojis(isEmojiInserted ? ComposerUtils.insertWhiteSpaceAtIndex(commentValue, endIndex) : commentValue, preferredSkinTone, preferredLocale); - if (!_.isEmpty(emojis)) { + if (emojis.length) { const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current); - if (!_.isEmpty(newEmojis)) { + if (newEmojis.length) { // Ensure emoji suggestions are hidden after inserting emoji even when the selection is not changed if (suggestionsRef.current) { suggestionsRef.current.resetSuggestions(); @@ -272,7 +402,7 @@ function ComposerWithSuggestions({ emojisPresentBefore.current = emojis; setValue(newCommentConverted); if (commentValue !== newComment) { - const position = Math.max(selection.end + (newComment.length - commentRef.current.length), cursorPosition || 0); + const position = Math.max(selection.end + (newComment.length - commentRef.current.length), cursorPosition ?? 0); if (isIOSNative) { syncSelectionWithOnChangeTextRef.current = {position, value: newComment}; @@ -320,10 +450,9 @@ function ComposerWithSuggestions({ /** * Update the number of lines for a comment in Onyx - * @param {Number} numberOfLines */ const updateNumberOfLines = useCallback( - (newNumberOfLines) => { + (newNumberOfLines: number) => { if (newNumberOfLines === numberOfLines) { return; } @@ -332,10 +461,7 @@ function ComposerWithSuggestions({ [reportID, numberOfLines], ); - /** - * @returns {String} - */ - const prepareCommentAndResetComposer = useCallback(() => { + const prepareCommentAndResetComposer = useCallback((): string => { const trimmedComment = commentRef.current.trim(); const commentLength = ReportUtils.getCommentLength(trimmedComment); @@ -360,37 +486,45 @@ function ComposerWithSuggestions({ /** * Callback to add whatever text is chosen into the main input (used f.e as callback for the emoji picker) - * @param {String} text */ const replaceSelectionWithText = useCallback( - (text) => { + (text: string) => { updateComment(ComposerUtils.insertText(commentRef.current, selection, text)); }, [selection, updateComment], ); const triggerHotkeyActions = useCallback( - (e) => { - if (!e || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) { + (event: NativeSyntheticEvent) => { + const webEvent = event as unknown as KeyboardEvent; + if (!webEvent || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) { return; } - if (suggestionsRef.current.triggerHotkeyActions(e)) { + if (suggestionsRef.current?.triggerHotkeyActions(webEvent)) { return; } // Submit the form when Enter is pressed - if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) { - e.preventDefault(); + if (webEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !webEvent.shiftKey) { + webEvent.preventDefault(); handleSendMessage(); } // Trigger the edit box for last sent message if ArrowUp is pressed and the comment is empty and Chronos is not in the participants const valueLength = valueRef.current.length; - if (e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && textInputRef.current.selectionStart === 0 && valueLength === 0 && !includeChronos) { - e.preventDefault(); + if ( + 'key' in event && + event.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && + textInputRef.current && + 'selectionStart' in textInputRef.current && + textInputRef.current?.selectionStart === 0 && + valueLength === 0 && + !includeChronos + ) { + event.preventDefault(); if (lastReportAction) { - Report.saveReportActionDraft(reportID, lastReportAction, _.last(lastReportAction.message).html); + Report.saveReportActionDraft(reportID, lastReportAction, lastReportAction.message?.at(-1)?.html ?? ''); } } }, @@ -398,7 +532,7 @@ function ComposerWithSuggestions({ ); const onChangeText = useCallback( - (commentValue) => { + (commentValue: string) => { updateComment(commentValue, true); if (isIOSNative && syncSelectionWithOnChangeTextRef.current) { @@ -409,7 +543,7 @@ function ComposerWithSuggestions({ InteractionManager.runAfterInteractions(() => { // note: this implementation is only available on non-web RN, thus the wrapping // 'if' block contains a redundant (since the ref is only used on iOS) platform check - textInputRef.current.setSelection(positionSnapshot, positionSnapshot); + textInputRef.current?.setSelection(positionSnapshot, positionSnapshot); }); } }, @@ -417,8 +551,8 @@ function ComposerWithSuggestions({ ); const onSelectionChange = useCallback( - (e) => { - if (textInputRef.current && textInputRef.current.isFocused() && suggestionsRef.current.onSelectionChange(e)) { + (e: NativeSyntheticEvent) => { + if (textInputRef.current?.isFocused() && suggestionsRef.current?.onSelectionChange?.(e)) { return; } @@ -444,8 +578,7 @@ function ComposerWithSuggestions({ /** * Focus the composer text input - * @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer - * @memberof ReportActionCompose + * @param [shouldDelay=false] Impose delay before focusing the composer */ const focus = useCallback((shouldDelay = false) => { focusComposerWithDelay(textInputRef.current)(shouldDelay); @@ -468,12 +601,12 @@ function ComposerWithSuggestions({ */ const checkComposerVisibility = useCallback(() => { // Checking whether the screen is focused or not, helps avoid `modal.isVisible` false when popups are closed, even if the modal is opened. - const isComposerCoveredUp = !isFocused || EmojiPickerActions.isEmojiPickerVisible() || isMenuVisible || modal.isVisible || modal.willAlertModalBecomeVisible; + const isComposerCoveredUp = !isFocused || EmojiPickerActions.isEmojiPickerVisible() || isMenuVisible || !!modal?.isVisible || modal?.willAlertModalBecomeVisible; return !isComposerCoveredUp; }, [isMenuVisible, modal, isFocused]); const focusComposerOnKeyPress = useCallback( - (e) => { + (e: KeyboardEvent) => { const isComposerVisible = checkComposerVisibility(); if (!isComposerVisible) { return; @@ -484,7 +617,7 @@ function ComposerWithSuggestions({ } // if we're typing on another input/text area, do not focus - if (['INPUT', 'TEXTAREA'].includes(e.target.nodeName)) { + if (['INPUT', 'TEXTAREA'].includes((e.target as Element | null)?.nodeName ?? '')) { return; } @@ -519,17 +652,17 @@ function ComposerWithSuggestions({ }; }, [focusComposerOnKeyPress, navigation, setUpComposeFocusManager]); - const prevIsModalVisible = usePrevious(modal.isVisible); + const prevIsModalVisible = usePrevious(modal?.isVisible); const prevIsFocused = usePrevious(isFocused); useEffect(() => { - if (modal.isVisible && !prevIsModalVisible) { + if (modal?.isVisible && !prevIsModalVisible) { // eslint-disable-next-line no-param-reassign isNextModalWillOpenRef.current = false; } // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused. // We avoid doing this on native platforms since the software keyboard popping // open creates a jarring and broken UX. - if (!((willBlurTextInputOnTapOutside || shouldAutoFocus) && !isNextModalWillOpenRef.current && !modal.isVisible && isFocused && (prevIsModalVisible || !prevIsFocused))) { + if (!((willBlurTextInputOnTapOutside || shouldAutoFocus) && !isNextModalWillOpenRef.current && !modal?.isVisible && isFocused && (!!prevIsModalVisible || !prevIsFocused))) { return; } @@ -538,11 +671,11 @@ function ComposerWithSuggestions({ return; } focus(true); - }, [focus, prevIsFocused, editFocused, prevIsModalVisible, isFocused, modal.isVisible, isNextModalWillOpenRef, shouldAutoFocus]); + }, [focus, prevIsFocused, editFocused, prevIsModalVisible, isFocused, modal?.isVisible, isNextModalWillOpenRef, shouldAutoFocus]); useEffect(() => { // Scrolls the composer to the bottom and sets the selection to the end, so that longer drafts are easier to edit - updateMultilineInputRange(textInputRef.current, shouldAutoFocus); + updateMultilineInputRange(textInputRef.current, !!shouldAutoFocus); if (value.length === 0) { return; @@ -552,15 +685,14 @@ function ComposerWithSuggestions({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useImperativeHandle( - forwardedRef, + ref, () => ({ blur, focus, replaceSelectionWithText, prepareCommentAndResetComposer, - isFocused: () => textInputRef.current.isFocused(), + isFocused: () => !!textInputRef.current?.isFocused(), }), [blur, focus, prepareCommentAndResetComposer, replaceSelectionWithText], ); @@ -574,7 +706,7 @@ function ComposerWithSuggestions({ }, [onValueChange, value]); const onLayout = useCallback( - (e) => { + (e: LayoutChangeEvent) => { const composerLayoutHeight = e.nativeEvent.layout.height; if (composerHeight === composerLayoutHeight) { return; @@ -594,7 +726,7 @@ function ComposerWithSuggestions({ ( - -)); - -ComposerWithSuggestionsWithRef.displayName = 'ComposerWithSuggestionsWithRef'; - -export default compose( - withKeyboardState, - withOnyx({ - numberOfLines: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES}${reportID}`, - // We might not have number of lines in onyx yet, for which the composer would be rendered as null - // during the first render, which we want to avoid: - initWithStoredValues: false, - }, - modal: { - key: ONYXKEYS.MODAL, - }, - preferredSkinTone: { - key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, - selector: EmojiUtils.getPreferredSkinToneIndex, - }, - editFocused: { - key: ONYXKEYS.INPUT_FOCUSED, - }, - parentReportActions: { - key: ({parentReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, - canEvict: false, - initWithStoredValues: false, - }, - }), -)(memo(ComposerWithSuggestionsWithRef)); +const ComposerWithSuggestionsWithRef = forwardRef(ComposerWithSuggestions); + +export default withOnyx, ComposerWithSuggestionsOnyxProps>({ + numberOfLines: { + key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES}${reportID}`, + // We might not have number of lines in onyx yet, for which the composer would be rendered as null + // during the first render, which we want to avoid: + initWithStoredValues: false, + }, + modal: { + key: ONYXKEYS.MODAL, + }, + preferredSkinTone: { + key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + selector: EmojiUtils.getPreferredSkinToneIndex, + }, + editFocused: { + key: ONYXKEYS.INPUT_FOCUSED, + }, + parentReportActions: { + key: ({parentReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, + canEvict: false, + initWithStoredValues: false, + }, +})(memo(ComposerWithSuggestionsWithRef)); + +export type {ComposerWithSuggestionsProps}; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js deleted file mode 100644 index 9d05db572949..000000000000 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js +++ /dev/null @@ -1,115 +0,0 @@ -import PropTypes from 'prop-types'; -import CONST from '@src/CONST'; - -const propTypes = { - /** Details about any modals being used */ - modal: PropTypes.shape({ - /** Indicates if there is a modal currently visible or not */ - isVisible: PropTypes.bool, - }), - - /** User's preferred skin tone color */ - preferredSkinTone: PropTypes.number, - - /** Number of lines for the composer */ - numberOfLines: PropTypes.number, - - /** Whether the keyboard is open or not */ - isKeyboardShown: PropTypes.bool.isRequired, - - /** The ID of the report */ - reportID: PropTypes.string.isRequired, - - /** Callback when the input is focused */ - onFocus: PropTypes.func.isRequired, - - /** Callback when the input is blurred */ - onBlur: PropTypes.func.isRequired, - - /** Whether the composer is full size or not */ - isComposerFullSize: PropTypes.bool.isRequired, - - /** Whether the menu is visible or not */ - isMenuVisible: PropTypes.bool.isRequired, - - /** Placeholder text for the input */ - inputPlaceholder: PropTypes.string.isRequired, - - /** Function to display a file in the modal */ - displayFileInModal: PropTypes.func.isRequired, - - /** Whether the text input should be cleared or not */ - textInputShouldClear: PropTypes.bool.isRequired, - - /** Function to set whether the text input should be cleared or not */ - setTextInputShouldClear: PropTypes.func.isRequired, - - /** Whether the user is blocked from concierge or not */ - isBlockedFromConcierge: PropTypes.bool.isRequired, - - /** Whether the input is disabled or not */ - disabled: PropTypes.bool, - - /** Whether the full composer is available or not */ - isFullComposerAvailable: PropTypes.bool.isRequired, - - /** Function to set whether the full composer is available or not */ - setIsFullComposerAvailable: PropTypes.func.isRequired, - - /** Function to set whether the comment is empty or not */ - setIsCommentEmpty: PropTypes.func.isRequired, - - /** A method to call when the form is submitted */ - handleSendMessage: PropTypes.func.isRequired, - - /** Whether the compose input is shown or not */ - shouldShowComposeInput: PropTypes.bool.isRequired, - - /** Meaures the parent container's position and dimensions. */ - measureParentContainer: PropTypes.func, - - /** Ref for the suggestions component */ - suggestionsRef: PropTypes.shape({ - current: PropTypes.shape({ - /** Update the shouldShowSuggestionMenuToFalse prop */ - updateShouldShowSuggestionMenuToFalse: PropTypes.func.isRequired, - - /** Trigger hotkey actions */ - triggerHotkeyActions: PropTypes.func.isRequired, - - /** Check if suggestion calculation should be blocked */ - setShouldBlockSuggestionCalc: PropTypes.func.isRequired, - - /** Callback when the selection changes */ - onSelectionChange: PropTypes.func.isRequired, - }), - }).isRequired, - - /** Ref for the animated view (text input) */ - animatedRef: PropTypes.func.isRequired, - - /** Ref for the composer */ - forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), - - /** Ref for the isNextModalWillOpen */ - isNextModalWillOpenRef: PropTypes.shape({current: PropTypes.bool.isRequired}).isRequired, - - /** A flag to indicate whether the onScroll callback is likely triggered by a layout change (caused by text change) or not */ - isScrollLikelyLayoutTriggered: PropTypes.shape({current: PropTypes.bool.isRequired}).isRequired, - - /** A function that toggles isScrollLikelyLayoutTriggered flag for a certain period of time */ - raiseIsScrollLikelyLayoutTriggered: PropTypes.func.isRequired, -}; - -const defaultProps = { - modal: {}, - preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, - numberOfLines: undefined, - parentReportActions: {}, - reportActions: [], - forwardedRef: null, - measureParentContainer: () => {}, - disabled: false, -}; - -export {propTypes, defaultProps}; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx similarity index 61% rename from src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.js rename to src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx index cbbd1758c9cb..7f169ef15918 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx @@ -1,6 +1,8 @@ -import _ from 'lodash'; -import React, {useEffect} from 'react'; +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useEffect} from 'react'; import E2EClient from '@libs/E2E/client'; +import type {ComposerRef} from '@pages/home/report/ReportActionCompose/ReportActionCompose'; +import type {ComposerWithSuggestionsProps} from './ComposerWithSuggestions'; import ComposerWithSuggestions from './ComposerWithSuggestions'; let rerenderCount = 0; @@ -14,20 +16,21 @@ function IncrementRenderCount() { return null; } -const ComposerWithSuggestionsE2e = React.forwardRef((props, ref) => { +function ComposerWithSuggestionsE2e(props: ComposerWithSuggestionsProps, ref: ForwardedRef) { // Eventually Auto focus on e2e tests useEffect(() => { - if (_.get(E2EClient.getCurrentActiveTestConfig(), 'reportScreen.autoFocus', false) === false) { + const testConfig = E2EClient.getCurrentActiveTestConfig(); + if (testConfig?.reportScreen && typeof testConfig.reportScreen !== 'string' && !testConfig?.reportScreen.autoFocus) { return; } // We need to wait for the component to be mounted before focusing setTimeout(() => { - if (!ref || !ref.current) { + if (!(ref && 'current' in ref)) { return; } - ref.current.focus(true); + ref.current?.focus(true); }, 1); }, [ref]); @@ -44,9 +47,9 @@ const ComposerWithSuggestionsE2e = React.forwardRef((props, ref) => { ); -}); +} ComposerWithSuggestionsE2e.displayName = 'ComposerWithSuggestionsE2e'; -export default ComposerWithSuggestionsE2e; +export default forwardRef(ComposerWithSuggestionsE2e); export {getRerenderCount, resetRerenderCount}; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.tsx similarity index 100% rename from src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.js rename to src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.tsx diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx similarity index 77% rename from src/pages/home/report/ReportActionCompose/ReportActionCompose.js rename to src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 4bbf3d393213..1e0e322be258 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,25 +1,29 @@ import {PortalHost} from '@gorhom/portal'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; +import type {SyntheticEvent} from 'react'; import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputFocusEventData, TextInputSelectionChangeEventData} from 'react-native'; import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import {runOnJS, setNativeProps, useAnimatedRef} from 'react-native-reanimated'; -import _ from 'underscore'; +import type {Emoji} from '@assets/emojis/types'; +import type {FileObject} from '@components/AttachmentModal'; import AttachmentModal from '@components/AttachmentModal'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ExceededCommentLength from '@components/ExceededCommentLength'; +import type {Mention} from '@components/MentionSuggestions'; import OfflineIndicator from '@components/OfflineIndicator'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {usePersonalDetails, withNetwork} from '@components/OnyxProvider'; -import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails'; +import {usePersonalDetails} from '@components/OnyxProvider'; +import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import useDebounce from '@hooks/useDebounce'; import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; -import compose from '@libs/compose'; import getDraftComment from '@libs/ComposerUtils/getDraftComment'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import getModalState from '@libs/getModalState'; @@ -29,63 +33,59 @@ import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutsi import ParticipantLocalTime from '@pages/home/report/ParticipantLocalTime'; import ReportDropUI from '@pages/home/report/ReportDropUI'; import ReportTypingIndicator from '@pages/home/report/ReportTypingIndicator'; -import reportPropTypes from '@pages/reportPropTypes'; import * as EmojiPickerActions from '@userActions/EmojiPickerAction'; import * as Report from '@userActions/Report'; import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerWithSuggestions from './ComposerWithSuggestions'; +import type {ComposerWithSuggestionsProps} from './ComposerWithSuggestions/ComposerWithSuggestions'; import SendButton from './SendButton'; -const propTypes = { - /** A method to call when the form is submitted */ - onSubmit: PropTypes.func.isRequired, - - /** The ID of the report actions will be created for */ - reportID: PropTypes.string.isRequired, - - /** The report currently being looked at */ - report: reportPropTypes, - - /** Is composer full size */ - isComposerFullSize: PropTypes.bool, - - /** Whether user interactions should be disabled */ - disabled: PropTypes.bool, +type ComposerRef = { + blur: () => void; + focus: (shouldDelay?: boolean) => void; + replaceSelectionWithText: (text: string, shouldAddTrailSpace: boolean) => void; + prepareCommentAndResetComposer: () => string; + isFocused: () => boolean; +}; - /** Height of the list which the composer is part of */ - listHeight: PropTypes.number, +type SuggestionsRef = { + resetSuggestions: () => void; + onSelectionChange?: (event: NativeSyntheticEvent) => void; + triggerHotkeyActions: (event: KeyboardEvent) => boolean | undefined; + updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void; + setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void; + getSuggestions: () => Mention[] | Emoji[]; +}; - // The NVP describing a user's block status - blockedFromConcierge: PropTypes.shape({ - // The date that the user will be unblocked - expiresAt: PropTypes.string, - }), +type ReportActionComposeOnyxProps = { + /** The NVP describing a user's block status */ + blockedFromConcierge: OnyxEntry; /** Whether the composer input should be shown */ - shouldShowComposeInput: PropTypes.bool, + shouldShowComposeInput: OnyxEntry; +}; - /** The type of action that's pending */ - pendingAction: PropTypes.oneOf(['add', 'update', 'delete']), +type ReportActionComposeProps = ReportActionComposeOnyxProps & + WithCurrentUserPersonalDetailsProps & + Pick & { + /** A method to call when the form is submitted */ + onSubmit: (newComment: string | undefined) => void; - /** /** Whetjer the report is ready for display */ - isReportReadyForDisplay: PropTypes.bool, - ...withCurrentUserPersonalDetailsPropTypes, -}; + /** The report currently being looked at */ + report: OnyxEntry; -const defaultProps = { - report: {}, - blockedFromConcierge: {}, - preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, - isComposerFullSize: false, - pendingAction: null, - shouldShowComposeInput: true, - listHeight: 0, - isReportReadyForDisplay: true, - ...withCurrentUserPersonalDetailsDefaultProps, -}; + /** The type of action that's pending */ + pendingAction?: OnyxCommon.PendingAction; + + /** Whether the report is ready for display */ + isReportReadyForDisplay?: boolean; + }; // We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will // prevent auto focus on existing chat for mobile device @@ -95,25 +95,25 @@ const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); function ReportActionCompose({ blockedFromConcierge, - currentUserPersonalDetails, + currentUserPersonalDetails = {}, disabled, - isComposerFullSize, - network, + isComposerFullSize = false, onSubmit, pendingAction, report, reportID, + listHeight = 0, + shouldShowComposeInput = true, + isReportReadyForDisplay = true, isEmptyChat, lastReportAction, - listHeight, - shouldShowComposeInput, - isReportReadyForDisplay, -}) { +}: ReportActionComposeProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isMediumScreenWidth, isSmallScreenWidth} = useWindowDimensions(); + const {isOffline} = useNetwork(); const animatedRef = useAnimatedRef(); - const actionButtonRef = useRef(null); + const actionButtonRef = useRef(null); const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; /** @@ -121,7 +121,7 @@ function ReportActionCompose({ */ const [isFocused, setIsFocused] = useState(() => { const initialModalState = getModalState(); - return shouldFocusInputOnScreenFocus && shouldShowComposeInput && !initialModalState.isVisible && !initialModalState.willAlertModalBecomeVisible; + return shouldFocusInputOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; }); const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); @@ -166,11 +166,11 @@ function ReportActionCompose({ */ const {hasExceededMaxCommentLength, validateCommentMaxLength} = useHandleExceedMaxCommentLength(); - const suggestionsRef = useRef(null); - const composerRef = useRef(null); + const suggestionsRef = useRef(null); + const composerRef = useRef(null); const reportParticipantIDs = useMemo( - () => _.without(lodashGet(report, 'participantAccountIDs', []), currentUserPersonalDetails.accountID), + () => report?.participantAccountIDs?.filter((accountID) => accountID !== currentUserPersonalDetails.accountID), [currentUserPersonalDetails.accountID, report], ); @@ -179,13 +179,13 @@ function ReportActionCompose({ [personalDetails, report, currentUserPersonalDetails.accountID, isComposerFullSize], ); - const includesConcierge = useMemo(() => ReportUtils.chatIncludesConcierge({participantAccountIDs: report.participantAccountIDs}), [report.participantAccountIDs]); + const includesConcierge = useMemo(() => ReportUtils.chatIncludesConcierge({participantAccountIDs: report?.participantAccountIDs}), [report?.participantAccountIDs]); const userBlockedFromConcierge = useMemo(() => User.isBlockedFromConcierge(blockedFromConcierge), [blockedFromConcierge]); const isBlockedFromConcierge = useMemo(() => includesConcierge && userBlockedFromConcierge, [includesConcierge, userBlockedFromConcierge]); // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions const conciergePlaceholderRandomIndex = useMemo( - () => _.random(translate('reportActionCompose.conciergePlaceholderOptions').length - (isSmallScreenWidth ? 4 : 1)), + () => Math.floor(Math.random() * (translate('reportActionCompose.conciergePlaceholderOptions').length - (isSmallScreenWidth ? 4 : 1) + 1)), // eslint-disable-next-line react-hooks/exhaustive-deps [], ); @@ -204,7 +204,7 @@ function ReportActionCompose({ }, [includesConcierge, translate, userBlockedFromConcierge, conciergePlaceholderRandomIndex]); const focus = () => { - if (composerRef === null || composerRef.current === null) { + if (composerRef.current === null) { return; } composerRef.current.focus(true); @@ -220,9 +220,9 @@ function ReportActionCompose({ isKeyboardVisibleWhenShowingModalRef.current = false; }, []); - const containerRef = useRef(null); + const containerRef = useRef(null); const measureContainer = useCallback( - (callback) => { + (callback: MeasureInWindowOnSuccessCallback) => { if (!containerRef.current) { return; } @@ -235,9 +235,9 @@ function ReportActionCompose({ const onAddActionPressed = useCallback(() => { if (!willBlurTextInputOnTapOutside) { - isKeyboardVisibleWhenShowingModalRef.current = composerRef.current.isFocused(); + isKeyboardVisibleWhenShowingModalRef.current = !!composerRef.current?.isFocused(); } - composerRef.current.blur(); + composerRef.current?.blur(); }, []); const onItemSelected = useCallback(() => { @@ -251,13 +251,10 @@ function ReportActionCompose({ suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); }, []); - /** - * @param {Object} file - */ const addAttachment = useCallback( - (file) => { + (file: FileObject) => { playSound(SOUNDS.DONE); - const newComment = composerRef.current.prepareCommentAndResetComposer(); + const newComment = composerRef?.current?.prepareCommentAndResetComposer(); Report.addAttachment(reportID, file, newComment); setTextInputShouldClear(false); }, @@ -275,16 +272,12 @@ function ReportActionCompose({ /** * Add a new comment to this chat - * - * @param {SyntheticEvent} [e] */ const submitForm = useCallback( - (e) => { - if (e) { - e.preventDefault(); - } + (event?: SyntheticEvent) => { + event?.preventDefault(); - const newComment = composerRef.current.prepareCommentAndResetComposer(); + const newComment = composerRef.current?.prepareCommentAndResetComposer(); if (!newComment) { return; } @@ -300,12 +293,13 @@ function ReportActionCompose({ isKeyboardVisibleWhenShowingModalRef.current = true; }, []); - const onBlur = useCallback((e) => { + const onBlur = useCallback((event: NativeSyntheticEvent) => { + const webEvent = event as unknown as FocusEvent; setIsFocused(false); if (suggestionsRef.current) { suggestionsRef.current.resetSuggestions(); } - if (e.relatedTarget && e.relatedTarget === actionButtonRef.current) { + if (webEvent.relatedTarget && webEvent.relatedTarget === actionButtonRef.current) { isKeyboardVisibleWhenShowingModalRef.current = true; } }, []); @@ -326,7 +320,7 @@ function ReportActionCompose({ // We are returning a callback here as we want to incoke the method on unmount only useEffect( () => () => { - if (!EmojiPickerActions.isActive(report.reportID)) { + if (!EmojiPickerActions.isActive(report?.reportID ?? '')) { return; } EmojiPickerActions.hideEmojiPicker(); @@ -339,9 +333,9 @@ function ReportActionCompose({ const reportRecipient = personalDetails[reportRecipientAcountIDs[0]]; const shouldUseFocusedColor = !isBlockedFromConcierge && !disabled && isFocused; - const hasReportRecipient = _.isObject(reportRecipient) && !_.isEmpty(reportRecipient); + const hasReportRecipient = !isEmptyObject(reportRecipient); - const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength; + const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || !!disabled || hasExceededMaxCommentLength; const handleSendMessage = useCallback(() => { 'worklet'; @@ -365,7 +359,7 @@ function ReportActionCompose({ }, [styles]); return ( - + {shouldShowReportRecipientLocalTime && hasReportRecipient && } @@ -402,7 +396,7 @@ function ReportActionCompose({ isFullComposerAvailable={isFullComposerAvailable} isComposerFullSize={isComposerFullSize} isBlockedFromConcierge={isBlockedFromConcierge} - disabled={disabled} + disabled={!!disabled} setMenuVisibility={setMenuVisibility} isMenuVisible={isMenuVisible} onTriggerAttachmentPicker={onTriggerAttachmentPicker} @@ -424,9 +418,9 @@ function ReportActionCompose({ isScrollLikelyLayoutTriggered={isScrollLikelyLayoutTriggered} raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLikelyLayoutTriggered} reportID={reportID} - parentReportID={report.parentReportID} - parentReportActionID={report.parentReportActionID} - includesChronos={ReportUtils.chatIncludesChronos(report)} + parentReportID={report?.parentReportID} + parentReportActionID={report?.parentReportActionID} + includeChronos={ReportUtils.chatIncludesChronos(report)} isEmptyChat={isEmptyChat} lastReportAction={lastReportAction} isMenuVisible={isMenuVisible} @@ -436,7 +430,7 @@ function ReportActionCompose({ textInputShouldClear={textInputShouldClear} setTextInputShouldClear={setTextInputShouldClear} isBlockedFromConcierge={isBlockedFromConcierge} - disabled={disabled} + disabled={!!disabled} isFullComposerAvailable={isFullComposerAvailable} setIsFullComposerAvailable={setIsFullComposerAvailable} setIsCommentEmpty={setIsCommentEmpty} @@ -454,12 +448,12 @@ function ReportActionCompose({ }} /> { + onDrop={(event: DragEvent) => { if (isAttachmentPreviewActive) { return; } - const data = lodashGet(e, ['dataTransfer', 'items', 0]); - displayFileInModal(data); + const data = event.dataTransfer?.items[0]; + displayFileInModal(data as unknown as FileObject); }} /> @@ -469,8 +463,9 @@ function ReportActionCompose({ composerRef.current.replaceSelectionWithText(...args)} - emojiPickerID={report.reportID} + // @ts-expect-error TODO: Remove this once EmojiPickerButton (https://github.com/Expensify/App/issues/25155) is migrated to TypeScript. + onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)} + emojiPickerID={report?.reportID} shiftVertical={emojiShiftVertical} /> )} @@ -484,7 +479,7 @@ function ReportActionCompose({ styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter, - (!isSmallScreenWidth || (isSmallScreenWidth && !network.isOffline)) && styles.chatItemComposeSecondaryRow, + (!isSmallScreenWidth || (isSmallScreenWidth && !isOffline)) && styles.chatItemComposeSecondaryRow, ]} > {!isSmallScreenWidth && } @@ -497,19 +492,17 @@ function ReportActionCompose({ ); } -ReportActionCompose.propTypes = propTypes; -ReportActionCompose.defaultProps = defaultProps; ReportActionCompose.displayName = 'ReportActionCompose'; -export default compose( - withNetwork(), - withCurrentUserPersonalDetails, - withOnyx({ +export default withCurrentUserPersonalDetails( + withOnyx({ blockedFromConcierge: { key: ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE, }, shouldShowComposeInput: { key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, }, - }), -)(memo(ReportActionCompose)); + })(memo(ReportActionCompose)), +); + +export type {SuggestionsRef, ComposerRef}; diff --git a/src/pages/home/report/ReportActionCompose/SendButton.js b/src/pages/home/report/ReportActionCompose/SendButton.tsx similarity index 87% rename from src/pages/home/report/ReportActionCompose/SendButton.js rename to src/pages/home/report/ReportActionCompose/SendButton.tsx index e9e3ef244f9c..c505eb0e32e7 100644 --- a/src/pages/home/report/ReportActionCompose/SendButton.js +++ b/src/pages/home/report/ReportActionCompose/SendButton.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React, {memo} from 'react'; import {View} from 'react-native'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; @@ -11,24 +10,22 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -const propTypes = { +type SendButtonProps = { /** Whether the button is disabled */ - isDisabled: PropTypes.bool.isRequired, + isDisabled: boolean; /** Handle clicking on send button */ - handleSendMessage: PropTypes.func.isRequired, + handleSendMessage: () => void; }; -function SendButton({isDisabled: isDisabledProp, handleSendMessage}) { +function SendButton({isDisabled: isDisabledProp, handleSendMessage}: SendButtonProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const Tap = Gesture.Tap() - .enabled() - .onEnd(() => { - handleSendMessage(); - }); + const Tap = Gesture.Tap().onEnd(() => { + handleSendMessage(); + }); return ( { - updateComment(comment); + updateComment(comment ?? ''); // eslint-disable-next-line react-hooks/exhaustive-deps -- We need to run this on mount }, []); return null; } -SilentCommentUpdater.propTypes = propTypes; -SilentCommentUpdater.defaultProps = defaultProps; SilentCommentUpdater.displayName = 'SilentCommentUpdater'; -export default withOnyx({ +export default withOnyx({ comment: { key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, - initialValue: '', }, })(SilentCommentUpdater); diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.js b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx similarity index 72% rename from src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.js rename to src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx index 23d69ec7defc..1abc6567bc7b 100644 --- a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.js +++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx @@ -1,47 +1,23 @@ -import PropTypes from 'prop-types'; import {useEffect} from 'react'; import {withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import ONYXKEYS from '@src/ONYXKEYS'; - -const propTypes = { - /** The comment of the report */ - comment: PropTypes.string, - - /** The value of the comment */ - value: PropTypes.string.isRequired, - - /** The ref of the comment */ - commentRef: PropTypes.shape({ - /** The current value of the comment */ - current: PropTypes.string, - }).isRequired, - - /** Updates the comment */ - updateComment: PropTypes.func.isRequired, - - reportID: PropTypes.string.isRequired, -}; - -const defaultProps = { - comment: '', -}; +import type {SilentCommentUpdaterOnyxProps, SilentCommentUpdaterProps} from './types'; /** * This component doesn't render anything. It runs a side effect to update the comment of a report under certain conditions. * It is connected to the actual draft comment in onyx. The comment in onyx might updates multiple times, and we want to avoid * re-rendering a UI component for that. That's why the side effect was moved down to a separate component. - * @returns {null} */ -function SilentCommentUpdater({comment, commentRef, reportID, value, updateComment}) { +function SilentCommentUpdater({comment, commentRef, reportID, value, updateComment}: SilentCommentUpdaterProps) { const prevCommentProp = usePrevious(comment); const prevReportId = usePrevious(reportID); const {preferredLocale} = useLocalize(); const prevPreferredLocale = usePrevious(preferredLocale); useEffect(() => { - updateComment(comment); + updateComment(comment ?? ''); // eslint-disable-next-line react-hooks/exhaustive-deps -- We need to run this on mount }, []); @@ -56,17 +32,15 @@ function SilentCommentUpdater({comment, commentRef, reportID, value, updateComme return; } - updateComment(comment); + updateComment(comment ?? ''); }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, reportID, updateComment, value, commentRef]); return null; } -SilentCommentUpdater.propTypes = propTypes; -SilentCommentUpdater.defaultProps = defaultProps; SilentCommentUpdater.displayName = 'SilentCommentUpdater'; -export default withOnyx({ +export default withOnyx({ comment: { key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, initialValue: '', diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts new file mode 100644 index 000000000000..dbc23b0279c3 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts @@ -0,0 +1,22 @@ +import type {OnyxEntry} from 'react-native-onyx'; + +type SilentCommentUpdaterOnyxProps = { + /** The comment of the report */ + comment: OnyxEntry; +}; + +type SilentCommentUpdaterProps = SilentCommentUpdaterOnyxProps & { + /** Updates the comment */ + updateComment: (comment: string) => void; + + /** The ID of the report associated with the comment */ + reportID: string; + + /** The value of the comment */ + value: string; + + /** The ref of the comment */ + commentRef: React.RefObject; +}; + +export type {SilentCommentUpdaterProps, SilentCommentUpdaterOnyxProps}; diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx similarity index 72% rename from src/pages/home/report/ReportActionCompose/SuggestionEmoji.js rename to src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx index b075740a3f4f..0ae45d2d705d 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx @@ -1,7 +1,8 @@ -import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import type {ForwardedRef, RefAttributes} from 'react'; +import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {Emoji} from '@assets/emojis/types'; import EmojiSuggestions from '@components/EmojiSuggestions'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useLocalize from '@hooks/useLocalize'; @@ -9,61 +10,59 @@ import * as EmojiUtils from '@libs/EmojiUtils'; import * as SuggestionsUtils from '@libs/SuggestionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import * as SuggestionProps from './suggestionProps'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {SuggestionsRef} from './ReportActionCompose'; +import type {SuggestionProps} from './Suggestions'; + +type SuggestionsValue = { + suggestedEmojis: Emoji[]; + colonIndex: number; + shouldShowSuggestionMenu: boolean; +}; + +type SuggestionEmojiOnyxProps = { + /** Preferred skin tone */ + preferredSkinTone: number; +}; + +type SuggestionEmojiProps = SuggestionProps & + SuggestionEmojiOnyxProps & { + /** Function to clear the input */ + resetKeyboardInput?: () => void; + }; /** * Check if this piece of string looks like an emoji - * @param {String} str - * @param {Number} pos - * @returns {Boolean} */ -const isEmojiCode = (str, pos) => { +const isEmojiCode = (str: string, pos: number): boolean => { const leftWords = str.slice(0, pos).split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); - const leftWord = _.last(leftWords); + const leftWord = leftWords.at(-1) ?? ''; return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; }; -const defaultSuggestionsValues = { +const defaultSuggestionsValues: SuggestionsValue = { suggestedEmojis: [], - colonSignIndex: -1, + colonIndex: -1, shouldShowSuggestionMenu: false, }; -const propTypes = { - /** Preferred skin tone */ - preferredSkinTone: PropTypes.number, - - /** A ref to this component */ - forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), - - /** Function to clear the input */ - resetKeyboardInput: PropTypes.func.isRequired, - - ...SuggestionProps.baseProps, -}; - -const defaultProps = { - preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, - forwardedRef: null, -}; - -function SuggestionEmoji({ - preferredSkinTone, - value, - setValue, - selection, - setSelection, - updateComment, - isComposerFullSize, - isAutoSuggestionPickerLarge, - forwardedRef, - resetKeyboardInput, - measureParentContainer, - isComposerFocused, -}) { +function SuggestionEmoji( + { + preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, + value, + selection, + setSelection, + updateComment, + isAutoSuggestionPickerLarge, + resetKeyboardInput, + measureParentContainer, + isComposerFocused, + }: SuggestionEmojiProps, + ref: ForwardedRef, +) { const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); - const isEmojiSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedEmojis) && suggestionValues.shouldShowSuggestionMenu; + const isEmojiSuggestionsMenuVisible = suggestionValues.suggestedEmojis.length > 0 && suggestionValues.shouldShowSuggestionMenu; const [highlightedEmojiIndex, setHighlightedEmojiIndex] = useArrowKeyFocusManager({ isActive: isEmojiSuggestionsMenuVisible, @@ -81,10 +80,10 @@ function SuggestionEmoji({ * @param {Number} selectedEmoji */ const insertSelectedEmoji = useCallback( - (highlightedEmojiIndexInner) => { + (highlightedEmojiIndexInner: number) => { const commentBeforeColon = value.slice(0, suggestionValues.colonIndex); const emojiObject = suggestionValues.suggestedEmojis[highlightedEmojiIndexInner]; - const emojiCode = emojiObject.types && emojiObject.types[preferredSkinTone] ? emojiObject.types[preferredSkinTone] : emojiObject.code; + const emojiCode = emojiObject.types?.[preferredSkinTone] ? emojiObject.types[preferredSkinTone] : emojiObject.code; const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end); updateComment(`${commentBeforeColon}${emojiCode} ${SuggestionsUtils.trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); @@ -92,7 +91,7 @@ function SuggestionEmoji({ // In some Android phones keyboard, the text to search for the emoji is not cleared // will be added after the user starts typing again on the keyboard. This package is // a workaround to reset the keyboard natively. - resetKeyboardInput(); + resetKeyboardInput?.(); setSelection({ start: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, @@ -121,11 +120,9 @@ function SuggestionEmoji({ /** * Listens for keyboard shortcuts and applies the action - * - * @param {Object} e */ const triggerHotkeyActions = useCallback( - (e) => { + (e: KeyboardEvent) => { const suggestionsExist = suggestionValues.suggestedEmojis.length > 0; if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { @@ -153,7 +150,7 @@ function SuggestionEmoji({ * Calculates and cares about the content of an Emoji Suggester */ const calculateEmojiSuggestion = useCallback( - (selectionEnd) => { + (selectionEnd: number) => { if (shouldBlockCalc.current || !value) { shouldBlockCalc.current = false; resetSuggestions(); @@ -163,16 +160,16 @@ function SuggestionEmoji({ const colonIndex = leftString.lastIndexOf(':'); const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selectionEnd); - const nextState = { + const nextState: SuggestionsValue = { suggestedEmojis: [], colonIndex, shouldShowSuggestionMenu: false, }; const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString, preferredLocale); - if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { + if (newSuggestedEmojis?.length && isCurrentlyShowingEmojiSuggestion) { nextState.suggestedEmojis = newSuggestedEmojis; - nextState.shouldShowSuggestionMenu = !_.isEmpty(newSuggestedEmojis); + nextState.shouldShowSuggestionMenu = !isEmptyObject(newSuggestedEmojis); } setSuggestionValues((prevState) => ({...prevState, ...nextState})); @@ -189,7 +186,7 @@ function SuggestionEmoji({ }, [selection, calculateEmojiSuggestion, isComposerFocused]); const onSelectionChange = useCallback( - (e) => { + (e: NativeSyntheticEvent) => { /** * we pass here e.nativeEvent.selection.end directly to calculateEmojiSuggestion * because in other case calculateEmojiSuggestion will have an old calculation value @@ -201,7 +198,7 @@ function SuggestionEmoji({ ); const setShouldBlockSuggestionCalc = useCallback( - (shouldBlockSuggestionCalc) => { + (shouldBlockSuggestionCalc: boolean) => { shouldBlockCalc.current = shouldBlockSuggestionCalc; }, [shouldBlockCalc], @@ -209,12 +206,8 @@ function SuggestionEmoji({ const getSuggestions = useCallback(() => suggestionValues.suggestedEmojis, [suggestionValues]); - const resetEmojiSuggestions = useCallback(() => { - setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []})); - }, []); - useImperativeHandle( - forwardedRef, + ref, () => ({ resetSuggestions, onSelectionChange, @@ -232,39 +225,22 @@ function SuggestionEmoji({ return ( ); } -SuggestionEmoji.propTypes = propTypes; -SuggestionEmoji.defaultProps = defaultProps; SuggestionEmoji.displayName = 'SuggestionEmoji'; -const SuggestionEmojiWithRef = React.forwardRef((props, ref) => ( - -)); - -SuggestionEmojiWithRef.displayName = 'SuggestionEmojiWithRef'; - -export default withOnyx({ +export default withOnyx, SuggestionEmojiOnyxProps>({ preferredSkinTone: { key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, selector: EmojiUtils.getPreferredSkinToneIndex, }, -})(SuggestionEmojiWithRef); +})(forwardRef(SuggestionEmoji)); diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx similarity index 73% rename from src/pages/home/report/ReportActionCompose/SuggestionMention.js rename to src/pages/home/report/ReportActionCompose/SuggestionMention.tsx index 6345ebf89185..ac52c06ee084 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx @@ -1,7 +1,8 @@ -import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; -import _ from 'underscore'; +import lodashSortBy from 'lodash/sortBy'; +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import * as Expensicons from '@components/Icon/Expensicons'; +import type {Mention} from '@components/MentionSuggestions'; import MentionSuggestions from '@components/MentionSuggestions'; import {usePersonalDetails} from '@components/OnyxProvider'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; @@ -11,52 +12,39 @@ import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as SuggestionsUtils from '@libs/SuggestionUtils'; import * as UserUtils from '@libs/UserUtils'; import CONST from '@src/CONST'; -import * as SuggestionProps from './suggestionProps'; +import type {PersonalDetailsList} from '@src/types/onyx'; +import type {SuggestionsRef} from './ReportActionCompose'; +import type {SuggestionProps} from './Suggestions'; + +type SuggestionValues = { + suggestedMentions: Mention[]; + atSignIndex: number; + shouldShowSuggestionMenu: boolean; + mentionPrefix: string; +}; /** * Check if this piece of string looks like a mention - * @param {String} str - * @returns {Boolean} */ -const isMentionCode = (str) => CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str); +const isMentionCode = (str: string): boolean => CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str); -const defaultSuggestionsValues = { +const defaultSuggestionsValues: SuggestionValues = { suggestedMentions: [], atSignIndex: -1, shouldShowSuggestionMenu: false, mentionPrefix: '', }; -const propTypes = { - /** A ref to this component */ - forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), - - ...SuggestionProps.implementationBaseProps, -}; - -const defaultProps = { - forwardedRef: null, -}; - -function SuggestionMention({ - value, - setValue, - selection, - setSelection, - isComposerFullSize, - updateComment, - composerHeight, - forwardedRef, - isAutoSuggestionPickerLarge, - measureParentContainer, - isComposerFocused, -}) { - const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; +function SuggestionMention( + {value, selection, setSelection, updateComment, isAutoSuggestionPickerLarge, measureParentContainer, isComposerFocused}: SuggestionProps, + ref: ForwardedRef, +) { + const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT; const {translate, formatPhoneNumber} = useLocalize(); const previousValue = usePrevious(value); const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); - const isMentionSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedMentions) && suggestionValues.shouldShowSuggestionMenu; + const isMentionSuggestionsMenuVisible = !!suggestionValues.suggestedMentions.length && suggestionValues.shouldShowSuggestionMenu; const [highlightedMentionIndex, setHighlightedMentionIndex] = useArrowKeyFocusManager({ isActive: isMentionSuggestionsMenuVisible, @@ -69,10 +57,9 @@ function SuggestionMention({ /** * Replace the code of mention and update selection - * @param {Number} highlightedMentionIndex */ const insertSelectedMention = useCallback( - (highlightedMentionIndexInner) => { + (highlightedMentionIndexInner: number) => { const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex); const mentionObject = suggestionValues.suggestedMentions[highlightedMentionIndexInner]; const mentionCode = mentionObject.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${mentionObject.login}`; @@ -100,23 +87,21 @@ function SuggestionMention({ /** * Listens for keyboard shortcuts and applies the action - * - * @param {Object} e */ const triggerHotkeyActions = useCallback( - (e) => { + (event: KeyboardEvent) => { const suggestionsExist = suggestionValues.suggestedMentions.length > 0; - if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { - e.preventDefault(); + if (((!event.shiftKey && event.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || event.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { + event.preventDefault(); if (suggestionValues.suggestedMentions.length > 0) { insertSelectedMention(highlightedMentionIndex); return true; } } - if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { - e.preventDefault(); + if (event.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { + event.preventDefault(); if (suggestionsExist) { resetSuggestions(); @@ -129,7 +114,7 @@ function SuggestionMention({ ); const getMentionOptions = useCallback( - (personalDetailsParam, searchValue = '') => { + (personalDetailsParam: PersonalDetailsList, searchValue = ''): Mention[] => { const suggestions = []; if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue.toLowerCase())) { @@ -139,15 +124,15 @@ function SuggestionMention({ icons: [ { source: Expensicons.Megaphone, - type: 'avatar', + type: CONST.ICON_TYPE_AVATAR, }, ], }); } - const filteredPersonalDetails = _.filter(_.values(personalDetailsParam), (detail) => { + const filteredPersonalDetails = Object.values(personalDetailsParam ?? {}).filter((detail) => { // If we don't have user's primary login, that member is not known to the current user and hence we do not allow them to be mentioned - if (!detail.login || detail.isOptimisticPersonalDetail) { + if (!detail?.login || detail.isOptimisticPersonalDetail) { return false; } // We don't want to mention system emails like notifications@expensify.com @@ -162,18 +147,19 @@ function SuggestionMention({ return true; }); - const sortedPersonalDetails = _.sortBy(filteredPersonalDetails, (detail) => detail.displayName || detail.login); - _.each(_.first(sortedPersonalDetails, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length), (detail) => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing cannot be used if left side can be empty string + const sortedPersonalDetails = lodashSortBy(filteredPersonalDetails, (detail) => detail?.displayName || detail?.login); + sortedPersonalDetails.slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length).forEach((detail) => { suggestions.push({ text: PersonalDetailsUtils.getDisplayNameOrDefault(detail), - alternateText: formatPhoneNumber(detail.login), - login: detail.login, + alternateText: formatPhoneNumber(detail?.login ?? ''), + login: detail?.login, icons: [ { - name: detail.login, - source: UserUtils.getAvatar(detail.avatar, detail.accountID), - type: 'avatar', - fallbackIcon: detail.fallbackIcon, + name: detail?.login, + source: UserUtils.getAvatar(detail?.avatar, detail?.accountID), + type: CONST.ICON_TYPE_AVATAR, + fallbackIcon: detail?.fallbackIcon, }, ], }); @@ -185,7 +171,7 @@ function SuggestionMention({ ); const calculateMentionSuggestion = useCallback( - (selectionEnd) => { + (selectionEnd: number) => { if (shouldBlockCalc.current || selectionEnd < 1 || !isComposerFocused) { shouldBlockCalc.current = false; resetSuggestions(); @@ -206,12 +192,12 @@ function SuggestionMention({ const afterLastBreakLineIndex = value.lastIndexOf('\n', selectionEnd - 1) + 1; const leftString = value.substring(afterLastBreakLineIndex, suggestionEndIndex); const words = leftString.split(CONST.REGEX.SPACE_OR_EMOJI); - const lastWord = _.last(words); + const lastWord: string = words.at(-1) ?? ''; const secondToLastWord = words[words.length - 3]; - let atSignIndex; - let suggestionWord; - let prefix; + let atSignIndex: number | undefined; + let suggestionWord = ''; + let prefix: string; // Detect if the last two words contain a mention (two words are needed to detect a mention with a space in it) if (lastWord.startsWith('@')) { @@ -228,7 +214,7 @@ function SuggestionMention({ prefix = lastWord.substring(1); } - const nextState = { + const nextState: Partial = { suggestedMentions: [], atSignIndex, mentionPrefix: prefix, @@ -240,7 +226,7 @@ function SuggestionMention({ const suggestions = getMentionOptions(personalDetails, prefix); nextState.suggestedMentions = suggestions; - nextState.shouldShowSuggestionMenu = !_.isEmpty(suggestions); + nextState.shouldShowSuggestionMenu = !!suggestions.length; } setSuggestionValues((prevState) => ({ @@ -273,20 +259,16 @@ function SuggestionMention({ }, []); const setShouldBlockSuggestionCalc = useCallback( - (shouldBlockSuggestionCalc) => { + (shouldBlockSuggestionCalc: boolean) => { shouldBlockCalc.current = shouldBlockSuggestionCalc; }, [shouldBlockCalc], ); - const onClose = useCallback(() => { - setSuggestionValues((prevState) => ({...prevState, suggestedMentions: []})); - }, []); - const getSuggestions = useCallback(() => suggestionValues.suggestedMentions, [suggestionValues]); useImperativeHandle( - forwardedRef, + ref, () => ({ resetSuggestions, triggerHotkeyActions, @@ -303,34 +285,16 @@ function SuggestionMention({ return ( ); } -SuggestionMention.propTypes = propTypes; -SuggestionMention.defaultProps = defaultProps; SuggestionMention.displayName = 'SuggestionMention'; -const SuggestionMentionWithRef = React.forwardRef((props, ref) => ( - -)); - -SuggestionMentionWithRef.displayName = 'SuggestionMentionWithRef'; - -export default SuggestionMentionWithRef; +export default forwardRef(SuggestionMention); diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js deleted file mode 100644 index 5dc71fec6419..000000000000 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ /dev/null @@ -1,169 +0,0 @@ -import PropTypes from 'prop-types'; -import React, {useCallback, useContext, useEffect, useImperativeHandle, useRef} from 'react'; -import {View} from 'react-native'; -import {DragAndDropContext} from '@components/DragAndDrop/Provider'; -import usePrevious from '@hooks/usePrevious'; -import SuggestionEmoji from './SuggestionEmoji'; -import SuggestionMention from './SuggestionMention'; -import * as SuggestionProps from './suggestionProps'; - -const propTypes = { - /** A ref to this component */ - forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), - - /** Function to clear the input */ - resetKeyboardInput: PropTypes.func.isRequired, - - /** Is auto suggestion picker large */ - isAutoSuggestionPickerLarge: PropTypes.bool, - - ...SuggestionProps.baseProps, -}; - -const defaultProps = { - forwardedRef: null, - isAutoSuggestionPickerLarge: true, -}; - -/** - * This component contains the individual suggestion components. - * If you want to add a new suggestion type, add it here. - * - * @returns {React.Component} - */ -function Suggestions({ - isComposerFullSize, - value, - setValue, - selection, - setSelection, - updateComment, - composerHeight, - forwardedRef, - resetKeyboardInput, - measureParentContainer, - isAutoSuggestionPickerLarge, - isComposerFocused, -}) { - const suggestionEmojiRef = useRef(null); - const suggestionMentionRef = useRef(null); - const {isDraggingOver} = useContext(DragAndDropContext); - const prevIsDraggingOver = usePrevious(isDraggingOver); - - const getSuggestions = useCallback(() => { - if (suggestionEmojiRef.current && suggestionEmojiRef.current.getSuggestions) { - const emojiSuggestions = suggestionEmojiRef.current.getSuggestions(); - if (emojiSuggestions.length > 0) { - return emojiSuggestions; - } - } - - if (suggestionMentionRef.current && suggestionMentionRef.current.getSuggestions) { - const mentionSuggestions = suggestionMentionRef.current.getSuggestions(); - if (mentionSuggestions.length > 0) { - return mentionSuggestions; - } - } - - return []; - }, []); - - /** - * Clean data related to EmojiSuggestions - */ - const resetSuggestions = useCallback(() => { - suggestionEmojiRef.current.resetSuggestions(); - suggestionMentionRef.current.resetSuggestions(); - }, []); - - /** - * Listens for keyboard shortcuts and applies the action - * - * @param {Object} e - */ - const triggerHotkeyActions = useCallback((e) => { - const emojiHandler = suggestionEmojiRef.current.triggerHotkeyActions(e); - const mentionHandler = suggestionMentionRef.current.triggerHotkeyActions(e); - return emojiHandler || mentionHandler; - }, []); - - const onSelectionChange = useCallback((e) => { - const emojiHandler = suggestionEmojiRef.current.onSelectionChange(e); - return emojiHandler; - }, []); - - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { - suggestionEmojiRef.current.updateShouldShowSuggestionMenuToFalse(); - suggestionMentionRef.current.updateShouldShowSuggestionMenuToFalse(); - }, []); - - const setShouldBlockSuggestionCalc = useCallback((shouldBlock) => { - suggestionEmojiRef.current.setShouldBlockSuggestionCalc(shouldBlock); - suggestionMentionRef.current.setShouldBlockSuggestionCalc(shouldBlock); - }, []); - - useImperativeHandle( - forwardedRef, - () => ({ - resetSuggestions, - onSelectionChange, - triggerHotkeyActions, - updateShouldShowSuggestionMenuToFalse, - setShouldBlockSuggestionCalc, - getSuggestions, - }), - [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions], - ); - - useEffect(() => { - if (!(!prevIsDraggingOver && isDraggingOver)) { - return; - } - updateShouldShowSuggestionMenuToFalse(); - }, [isDraggingOver, prevIsDraggingOver, updateShouldShowSuggestionMenuToFalse]); - - const baseProps = { - value, - setValue, - setSelection, - selection, - isComposerFullSize, - updateComment, - composerHeight, - isAutoSuggestionPickerLarge, - measureParentContainer, - isComposerFocused, - }; - - return ( - - - - - ); -} - -Suggestions.propTypes = propTypes; -Suggestions.defaultProps = defaultProps; -Suggestions.displayName = 'Suggestions'; - -const SuggestionsWithRef = React.forwardRef((props, ref) => ( - -)); - -SuggestionsWithRef.displayName = 'SuggestionsWithRef'; - -export default SuggestionsWithRef; diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.tsx b/src/pages/home/report/ReportActionCompose/Suggestions.tsx new file mode 100644 index 000000000000..61026a792919 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/Suggestions.tsx @@ -0,0 +1,181 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef} from 'react'; +import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; +import {View} from 'react-native'; +import {DragAndDropContext} from '@components/DragAndDrop/Provider'; +import usePrevious from '@hooks/usePrevious'; +import type {SuggestionsRef} from './ReportActionCompose'; +import SuggestionEmoji from './SuggestionEmoji'; +import SuggestionMention from './SuggestionMention'; + +type Selection = { + start: number; + end: number; +}; + +type SuggestionProps = { + /** The current input value */ + value: string; + + /** Callback to update the current input value */ + setValue: (newValue: string) => void; + + /** The current selection value */ + selection: Selection; + + /** Callback to update the current selection */ + setSelection: (newSelection: Selection) => void; + + /** Callback to update the comment draft */ + updateComment: (newComment: string, shouldDebounceSaveComment?: boolean) => void; + + /** Meaures the parent container's position and dimensions. */ + measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; + + /** Whether the composer is expanded */ + isComposerFullSize: boolean; + + /** Report composer focus state */ + isComposerFocused?: boolean; + + /** Callback to reset the keyboard input */ + resetKeyboardInput?: () => void; + + /** Whether the auto suggestion picker is large */ + isAutoSuggestionPickerLarge?: boolean; + + /** The height of the composer */ + composerHeight?: number; +}; + +/** + * This component contains the individual suggestion components. + * If you want to add a new suggestion type, add it here. + * + */ +function Suggestions( + { + isComposerFullSize, + value, + setValue, + selection, + setSelection, + updateComment, + composerHeight, + resetKeyboardInput, + measureParentContainer, + isAutoSuggestionPickerLarge = true, + isComposerFocused, + }: SuggestionProps, + ref: ForwardedRef, +) { + const suggestionEmojiRef = useRef(null); + const suggestionMentionRef = useRef(null); + const {isDraggingOver} = useContext(DragAndDropContext); + const prevIsDraggingOver = usePrevious(isDraggingOver); + + const getSuggestions = useCallback(() => { + if (suggestionEmojiRef.current?.getSuggestions) { + const emojiSuggestions = suggestionEmojiRef.current.getSuggestions(); + if (emojiSuggestions.length > 0) { + return emojiSuggestions; + } + } + + if (suggestionMentionRef.current?.getSuggestions) { + const mentionSuggestions = suggestionMentionRef.current.getSuggestions(); + if (mentionSuggestions.length > 0) { + return mentionSuggestions; + } + } + + return []; + }, []); + + /** + * Clean data related to EmojiSuggestions + */ + const resetSuggestions = useCallback(() => { + suggestionEmojiRef.current?.resetSuggestions(); + suggestionMentionRef.current?.resetSuggestions(); + }, []); + + /** + * Listens for keyboard shortcuts and applies the action + */ + const triggerHotkeyActions = useCallback((e: KeyboardEvent) => { + const emojiHandler = suggestionEmojiRef.current?.triggerHotkeyActions(e); + const mentionHandler = suggestionMentionRef.current?.triggerHotkeyActions(e); + return emojiHandler ?? mentionHandler; + }, []); + + const onSelectionChange = useCallback((e: NativeSyntheticEvent) => { + const emojiHandler = suggestionEmojiRef.current?.onSelectionChange?.(e); + return emojiHandler; + }, []); + + const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + suggestionEmojiRef.current?.updateShouldShowSuggestionMenuToFalse(); + suggestionMentionRef.current?.updateShouldShowSuggestionMenuToFalse(); + }, []); + + const setShouldBlockSuggestionCalc = useCallback((shouldBlock: boolean) => { + suggestionEmojiRef.current?.setShouldBlockSuggestionCalc(shouldBlock); + suggestionMentionRef.current?.setShouldBlockSuggestionCalc(shouldBlock); + }, []); + + useImperativeHandle( + ref, + () => ({ + resetSuggestions, + onSelectionChange, + triggerHotkeyActions, + updateShouldShowSuggestionMenuToFalse, + setShouldBlockSuggestionCalc, + getSuggestions, + }), + [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions], + ); + + useEffect(() => { + if (!(!prevIsDraggingOver && isDraggingOver)) { + return; + } + updateShouldShowSuggestionMenuToFalse(); + }, [isDraggingOver, prevIsDraggingOver, updateShouldShowSuggestionMenuToFalse]); + + const baseProps = { + value, + setValue, + setSelection, + selection, + isComposerFullSize, + updateComment, + composerHeight, + isAutoSuggestionPickerLarge, + measureParentContainer, + isComposerFocused, + }; + + return ( + + + + + ); +} + +Suggestions.displayName = 'Suggestions'; + +export default forwardRef(Suggestions); + +export type {SuggestionProps}; diff --git a/src/pages/home/report/ReportActionCompose/suggestionProps.js b/src/pages/home/report/ReportActionCompose/suggestionProps.js deleted file mode 100644 index 62c29f3d418e..000000000000 --- a/src/pages/home/report/ReportActionCompose/suggestionProps.js +++ /dev/null @@ -1,39 +0,0 @@ -import PropTypes from 'prop-types'; - -const baseProps = { - /** The current input value */ - value: PropTypes.string.isRequired, - - /** Callback to update the current input value */ - setValue: PropTypes.func.isRequired, - - /** The current selection value */ - selection: PropTypes.shape({ - start: PropTypes.number.isRequired, - end: PropTypes.number.isRequired, - }).isRequired, - - /** Callback to update the current selection */ - setSelection: PropTypes.func.isRequired, - - /** Whether the composer is expanded */ - isComposerFullSize: PropTypes.bool.isRequired, - - /** Callback to update the comment draft */ - updateComment: PropTypes.func.isRequired, - - /** Meaures the parent container's position and dimensions. */ - measureParentContainer: PropTypes.func.isRequired, - - /** Report composer focus state */ - isComposerFocused: PropTypes.bool, -}; - -const implementationBaseProps = { - /** Whether to use the small or the big suggestion picker */ - isAutoSuggestionPickerLarge: PropTypes.bool.isRequired, - - ...baseProps, -}; - -export {baseProps, implementationBaseProps}; diff --git a/src/pages/home/report/ReportDropUI.js b/src/pages/home/report/ReportDropUI.tsx similarity index 87% rename from src/pages/home/report/ReportDropUI.js rename to src/pages/home/report/ReportDropUI.tsx index c1c3b8e506ab..fad58d60bbfa 100644 --- a/src/pages/home/report/ReportDropUI.js +++ b/src/pages/home/report/ReportDropUI.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; @@ -8,12 +7,11 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -const propTypes = { +type ReportDropUIProps = { /** Callback to execute when a file is dropped. */ - onDrop: PropTypes.func.isRequired, + onDrop: (event: DragEvent) => void; }; - -function ReportDropUI({onDrop}) { +function ReportDropUI({onDrop}: ReportDropUIProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); return ( @@ -33,6 +31,5 @@ function ReportDropUI({onDrop}) { } ReportDropUI.displayName = 'ReportDropUI'; -ReportDropUI.propTypes = propTypes; export default ReportDropUI; diff --git a/src/pages/home/report/ReportTypingIndicator.js b/src/pages/home/report/ReportTypingIndicator.tsx similarity index 70% rename from src/pages/home/report/ReportTypingIndicator.js rename to src/pages/home/report/ReportTypingIndicator.tsx index 41471eaa50de..3ff8f2b0eb8e 100755 --- a/src/pages/home/report/ReportTypingIndicator.js +++ b/src/pages/home/report/ReportTypingIndicator.tsx @@ -1,7 +1,6 @@ -import PropTypes from 'prop-types'; import React, {memo, useMemo} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; import Text from '@components/Text'; import TextWithEllipsis from '@components/TextWithEllipsis'; import useLocalize from '@hooks/useLocalize'; @@ -9,28 +8,30 @@ import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportUtils from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportUserIsTyping} from '@src/types/onyx'; -const propTypes = { +type ReportTypingIndicatorOnyxProps = { /** Key-value pairs of user accountIDs/logins and whether or not they are typing. Keys are accountIDs or logins. */ - userTypingStatuses: PropTypes.objectOf(PropTypes.bool), + userTypingStatuses: OnyxEntry; }; -const defaultProps = { - userTypingStatuses: {}, +type ReportTypingIndicatorProps = ReportTypingIndicatorOnyxProps & { + // eslint-disable-next-line react/no-unused-prop-types -- This is used by withOnyx + reportID: string; }; -function ReportTypingIndicator({userTypingStatuses}) { +function ReportTypingIndicator({userTypingStatuses}: ReportTypingIndicatorProps) { const {translate} = useLocalize(); const {isOffline} = useNetwork(); const styles = useThemeStyles(); - const usersTyping = useMemo(() => _.filter(_.keys(userTypingStatuses), (loginOrAccountID) => userTypingStatuses[loginOrAccountID]), [userTypingStatuses]); + const usersTyping = useMemo(() => Object.keys(userTypingStatuses ?? {}).filter((loginOrAccountID) => userTypingStatuses?.[loginOrAccountID]), [userTypingStatuses]); const firstUserTyping = usersTyping[0]; const isUserTypingADisplayName = Number.isNaN(Number(firstUserTyping)); // If we are offline, the user typing statuses are not up-to-date so do not show them - if (isOffline || !firstUserTyping) { + if (!!isOffline || !firstUserTyping) { return null; } @@ -40,6 +41,7 @@ function ReportTypingIndicator({userTypingStatuses}) { if (usersTyping.length === 1) { return ( ({ userTypingStatuses: { key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`, - initialValue: {}, }, })(memo(ReportTypingIndicator)); diff --git a/src/types/modules/pusher.d.ts b/src/types/modules/pusher.d.ts index 676d7a7ee2fc..e9aa50085e8d 100644 --- a/src/types/modules/pusher.d.ts +++ b/src/types/modules/pusher.d.ts @@ -9,6 +9,6 @@ declare global { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface File { source: string; - uri: string; + uri?: string; } } diff --git a/src/types/modules/react-native.d.ts b/src/types/modules/react-native.d.ts index 6300d416035a..21f1d620b14f 100644 --- a/src/types/modules/react-native.d.ts +++ b/src/types/modules/react-native.d.ts @@ -283,14 +283,9 @@ declare module 'react-native' { * Extracted from react-native-web, packages/react-native-web/src/exports/TextInput/types.js */ interface WebTextInputProps extends WebSharedProps { - dir?: 'auto' | 'ltr' | 'rtl'; disabled?: boolean; - enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send'; - readOnly?: boolean; } interface TextInputProps extends WebTextInputProps { - // TODO: remove once the app is updated to RN 0.73 - smartInsertDelete?: boolean; isFullComposerAvailable?: boolean; } diff --git a/src/types/onyx/OnyxCommon.ts b/src/types/onyx/OnyxCommon.ts index 1c5d46610286..8b96a89a2a1b 100644 --- a/src/types/onyx/OnyxCommon.ts +++ b/src/types/onyx/OnyxCommon.ts @@ -31,7 +31,7 @@ type Icon = { type: AvatarType; /** Owner of the avatar. If user, displayName. If workspace, policy name */ - name: string; + name?: string; /** Avatar id */ id?: number | string; diff --git a/src/types/onyx/ReportAction.ts b/src/types/onyx/ReportAction.ts index 6f1da93f7f14..bb5bf50ec6cf 100644 --- a/src/types/onyx/ReportAction.ts +++ b/src/types/onyx/ReportAction.ts @@ -1,4 +1,5 @@ import type {ValueOf} from 'type-fest'; +import type {FileObject} from '@components/AttachmentModal'; import type {AvatarSource} from '@libs/UserUtils'; import type CONST from '@src/CONST'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; @@ -184,7 +185,7 @@ type ReportActionBase = OnyxCommon.OnyxValueWithOfflineFeedback<{ isFirstItem?: boolean; /** Informations about attachments of report action */ - attachmentInfo?: File | EmptyObject; + attachmentInfo?: FileObject | EmptyObject; /** Receipt tied to report action */ receipt?: Receipt;