diff --git a/src/components/CheckboxWithLabel.js b/src/components/CheckboxWithLabel.js index 0a90a9be46e2..146e37ceb730 100644 --- a/src/components/CheckboxWithLabel.js +++ b/src/components/CheckboxWithLabel.js @@ -7,6 +7,7 @@ import variables from '@styles/variables'; import Checkbox from './Checkbox'; import FormHelpMessage from './FormHelpMessage'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; +import refPropTypes from './refPropTypes'; import Text from './Text'; /** @@ -54,7 +55,7 @@ const propTypes = { defaultValue: PropTypes.bool, /** React ref being forwarded to the Checkbox input */ - forwardedRef: PropTypes.func, + forwardedRef: refPropTypes, /** The ID used to uniquely identify the input in a Form */ /* eslint-disable-next-line react/no-unused-prop-types */ diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js index 9f2744a058d1..9fc1224f96c0 100644 --- a/src/components/EmojiPicker/EmojiPicker.js +++ b/src/components/EmojiPicker/EmojiPicker.js @@ -135,6 +135,9 @@ const EmojiPicker = forwardRef((props, ref) => { }); }); return () => { + if (!emojiPopoverDimensionListener) { + return; + } emojiPopoverDimensionListener.remove(); }; }, [isEmojiPickerVisible, isSmallScreenWidth, emojiPopoverAnchorOrigin]); diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js index fa0cc3ebd723..92c76da5936d 100644 --- a/src/components/Form/FormProvider.js +++ b/src/components/Form/FormProvider.js @@ -317,7 +317,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC errors={errors} enabledWhenOffline={enabledWhenOffline} > - {children} + {_.isFunction(children) ? children({inputValues}) : children} ); diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 28c92c6e6326..371b8dfec43d 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -96,6 +96,15 @@ function isReimbursementQueuedAction(reportAction: OnyxEntry) { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED; } +function isChannelLogMemberAction(reportAction: OnyxEntry) { + return ( + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.REMOVE_FROM_ROOM || + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM || + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.REMOVE_FROM_ROOM + ); +} + /** * Returns whether the comment is a thread parent message/the first message in a thread */ @@ -657,4 +666,5 @@ export { shouldReportActionBeVisible, shouldReportActionBeVisibleAsLastAction, getFirstVisibleReportActionID, + isChannelLogMemberAction, }; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index a67c3278d9e5..1e4b161bb75c 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -15,6 +15,7 @@ import * as IOU from './actions/IOU'; import * as CurrencyUtils from './CurrencyUtils'; import DateUtils from './DateUtils'; import isReportMessageAttachment from './isReportMessageAttachment'; +import * as LocalePhoneNumber from './LocalePhoneNumber'; import * as Localize from './Localize'; import linkingConfig from './Navigation/linkingConfig'; import Navigation from './Navigation/Navigation'; @@ -4165,6 +4166,48 @@ function getIOUReportActionDisplayMessage(reportAction) { }); } +/** + * Return room channel log display message + * + * @param {Object} reportAction + * @returns {String} + */ +function getChannelLogMemberMessage(reportAction) { + const verb = + reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM + ? 'invited' + : 'removed'; + + const mentions = _.map(reportAction.originalMessage.targetAccountIDs, (accountID) => { + const personalDetail = lodashGet(allPersonalDetails, accountID); + const displayNameOrLogin = + LocalePhoneNumber.formatPhoneNumber(lodashGet(personalDetail, 'login', '')) || lodashGet(personalDetail, 'displayName', '') || Localize.translateLocal('common.hidden'); + return `@${displayNameOrLogin}`; + }); + + const lastMention = mentions.pop(); + let message = ''; + + if (mentions.length === 0) { + message = `${verb} ${lastMention}`; + } else if (mentions.length === 1) { + message = `${verb} ${mentions[0]} and ${lastMention}`; + } else { + message = `${verb} ${mentions.join(', ')}, and ${lastMention}`; + } + + const roomName = lodashGet(reportAction, 'originalMessage.roomName', ''); + if (roomName) { + const preposition = + reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM + ? ' to' + : ' from'; + message += `${preposition} ${roomName}`; + } + + return message; +} + /** * Checks if a report is a group chat. * @@ -4388,6 +4431,7 @@ export { parseReportRouteParams, getReimbursementQueuedActionMessage, getPersonalDetailsForAccountID, + getChannelLogMemberMessage, getRoom, shouldDisableWelcomeMessage, }; diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 22a1bc5441e6..58d7a9399533 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -3,7 +3,7 @@ import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import Str from 'expensify-common/lib/str'; import lodashDebounce from 'lodash/debounce'; import lodashGet from 'lodash/get'; -import {InteractionManager} from 'react-native'; +import {DeviceEventEmitter, InteractionManager} from 'react-native'; import Onyx from 'react-native-onyx'; import _ from 'underscore'; import * as ActiveClientManager from '@libs/ActiveClientManager'; @@ -937,6 +937,7 @@ function markCommentAsUnread(reportID, reportActionCreated) { ], }, ); + DeviceEventEmitter.emit(`unreadAction_${reportID}`, lastReadTime); } /** diff --git a/src/pages/EnablePayments/AdditionalDetailsStep.js b/src/pages/EnablePayments/AdditionalDetailsStep.js index 45738c376181..d7cabe144dd4 100644 --- a/src/pages/EnablePayments/AdditionalDetailsStep.js +++ b/src/pages/EnablePayments/AdditionalDetailsStep.js @@ -5,9 +5,10 @@ import React from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import DatePicker from '@components/DatePicker'; -import Form from '@components/Form'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import NewDatePicker from '@components/NewDatePicker'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; @@ -178,7 +179,7 @@ function AdditionalDetailsStep({walletAdditionalDetails, translate, currentUserP {translate('additionalDetailsStep.helpLink')} -
- - - - - - + ); diff --git a/src/pages/ReimbursementAccount/ACHContractStep.js b/src/pages/ReimbursementAccount/ACHContractStep.js index 9dde145f3de6..b52a8c4c9a62 100644 --- a/src/pages/ReimbursementAccount/ACHContractStep.js +++ b/src/pages/ReimbursementAccount/ACHContractStep.js @@ -5,7 +5,8 @@ import React, {useState} from 'react'; import {View} from 'react-native'; import _ from 'underscore'; import CheckboxWithLabel from '@components/CheckboxWithLabel'; -import Form from '@components/Form'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; @@ -157,7 +158,7 @@ function ACHContractStep(props) { shouldShowGetAssistanceButton guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_BANK_ACCOUNT} /> -
{props.translate('beneficialOwnersStep.checkAllThatApply')} - - )} {props.translate('beneficialOwnersStep.agreement')} - - )} - + ); } diff --git a/src/pages/ReimbursementAccount/AddressForm.js b/src/pages/ReimbursementAccount/AddressForm.js index 0f7c878c9058..ce4df7ae665f 100644 --- a/src/pages/ReimbursementAccount/AddressForm.js +++ b/src/pages/ReimbursementAccount/AddressForm.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; import AddressSearch from '@components/AddressSearch'; +import InputWrapper from '@components/Form/InputWrapper'; import StatePicker from '@components/StatePicker'; import TextInput from '@components/TextInput'; import useThemeStyles from '@styles/useThemeStyles'; @@ -96,7 +97,8 @@ function AddressForm(props) { return ( <> - - - - -
{translate('companyStep.subtitle')} - - - - - ({value: key, label: translate(`companyStep.incorporationTypes.${key}`)}))} @@ -235,7 +241,7 @@ function CompanyStep({reimbursementAccount, reimbursementAccountDraft, getDefaul /> - - - - + ); } diff --git a/src/pages/ReimbursementAccount/IdentityForm.js b/src/pages/ReimbursementAccount/IdentityForm.js index 16dbfb7b8c83..f762dbd28954 100644 --- a/src/pages/ReimbursementAccount/IdentityForm.js +++ b/src/pages/ReimbursementAccount/IdentityForm.js @@ -3,7 +3,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; import _ from 'underscore'; -import DatePicker from '@components/DatePicker'; +import InputWrapper from '@components/Form/InputWrapper'; +import NewDatePicker from '@components/NewDatePicker'; import TextInput from '@components/TextInput'; import useThemeStyles from '@styles/useThemeStyles'; import CONST from '@src/CONST'; @@ -142,7 +143,8 @@ function IdentityForm(props) { - - - - -
- - + ); }); diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index 5a1266d15a42..4f35926c5957 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -281,6 +281,9 @@ export default [ } else if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { const displayMessage = ReportUtils.getIOUReportActionDisplayMessage(reportAction); Clipboard.setString(displayMessage); + } else if (ReportActionsUtils.isChannelLogMemberAction(reportAction)) { + const logMessage = ReportUtils.getChannelLogMemberMessage(reportAction); + Clipboard.setString(logMessage); } else if (content) { const parser = new ExpensiMark(); if (!Clipboard.canSetHtml()) { diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index eb555500a557..dd537959c91f 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -2,6 +2,7 @@ import {useRoute} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {DeviceEventEmitter} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import _ from 'underscore'; import InvertedFlatList from '@components/InvertedFlatList'; @@ -82,16 +83,23 @@ const defaultProps = { const VERTICAL_OFFSET_THRESHOLD = 200; const MSG_VISIBLE_THRESHOLD = 250; -// Seems that there is an architecture issue that prevents us from using the reportID with useRef -// the useRef value gets reset when the reportID changes, so we use a global variable to keep track -let prevReportID = null; - // In the component we are subscribing to the arrival of new actions. // As there is the possibility that there are multiple instances of a ReportScreen // for the same report, we only ever want one subscription to be active, as // the subscriptions could otherwise be conflicting. const newActionUnsubscribeMap = {}; +// Caching the reportID and reportActionID for unread markers ensures persistent tracking +// across multiple reports, preserving the green line placement and allowing retrieval +// of the relevant reportActionID for displaying the green line. +// We need to persist it across reports because there are at least 3 ReportScreen components created so the +// internal states are resetted or recreated. +const cacheUnreadMarkers = new Map(); + +// Seems that there is an architecture issue that prevents us from using the reportID with useRef +// the useRef value gets reset when the reportID changes, so we use a global variable to keep track +let prevReportID = null; + /** * Create a unique key for each action in the FlatList. * We use the reportActionID that is a string representation of a random 64-bit int, which should be @@ -137,12 +145,21 @@ function ReportActionsList({ const route = useRoute(); const opacity = useSharedValue(0); const userActiveSince = useRef(null); - const [currentUnreadMarker, setCurrentUnreadMarker] = useState(null); + const unreadActionSubscription = useRef(null); + const markerInit = () => { + if (!cacheUnreadMarkers.has(report.reportID)) { + return null; + } + return cacheUnreadMarkers.get(report.reportID); + }; + const [currentUnreadMarker, setCurrentUnreadMarker] = useState(markerInit); const scrollingVerticalOffset = useRef(0); const readActionSkipped = useRef(false); const hasHeaderRendered = useRef(false); const hasFooterRendered = useRef(false); const reportActionSize = useRef(sortedReportActions.length); + const lastReadTimeRef = useRef(report.lastReadTime); + const linkedReportActionID = lodashGet(route, 'params.reportActionID', ''); // This state is used to force a re-render when the user manually marks a message as unread @@ -186,25 +203,41 @@ function ReportActionsList({ return; } + cacheUnreadMarkers.delete(report.reportID); reportActionSize.current = sortedReportActions.length; setCurrentUnreadMarker(null); // eslint-disable-next-line react-hooks/exhaustive-deps }, [sortedReportActions.length, report.reportID]); useEffect(() => { - const didManuallyMarkReportAsUnread = report.lastReadTime < DateUtils.getDBTime() && ReportUtils.isUnread(report); - if (didManuallyMarkReportAsUnread) { - // Clearing the current unread marker so that it can be recalculated - setCurrentUnreadMarker(null); - setMessageManuallyMarkedUnread(new Date().getTime()); + if (!userActiveSince.current || report.reportID !== prevReportID) { return; } - + if (!messageManuallyMarkedUnread && lastReadTimeRef.current && lastReadTimeRef.current < report.lastReadTime) { + cacheUnreadMarkers.delete(report.reportID); + } + lastReadTimeRef.current = report.lastReadTime; setMessageManuallyMarkedUnread(0); - // We only care when a new lastReadTime is set in the report // eslint-disable-next-line react-hooks/exhaustive-deps - }, [report.lastReadTime]); + }, [report.lastReadTime, report.reportID]); + + useEffect(() => { + // If the reportID changes, we reset the userActiveSince to null, we need to do it because + // this component doesn't unmount when the reportID changes + if (unreadActionSubscription.current) { + unreadActionSubscription.current.remove(); + unreadActionSubscription.current = null; + } + + // Listen to specific reportID for unread event and set the marker to new message + unreadActionSubscription.current = DeviceEventEmitter.addListener(`unreadAction_${report.reportID}`, (newLastReadTime) => { + cacheUnreadMarkers.delete(report.reportID); + lastReadTimeRef.current = newLastReadTime; + setCurrentUnreadMarker(null); + setMessageManuallyMarkedUnread(new Date().getTime()); + }); + }, [report.reportID]); useEffect(() => { // Why are we doing this, when in the cleanup of the useEffect we are already calling the unsubscribe function? @@ -303,17 +336,21 @@ function ReportActionsList({ let shouldDisplay = false; if (!currentUnreadMarker) { const nextMessage = sortedReportActions[index + 1]; - const isCurrentMessageUnread = isMessageUnread(reportAction, report.lastReadTime); - shouldDisplay = isCurrentMessageUnread && (!nextMessage || !isMessageUnread(nextMessage, report.lastReadTime)); + const isCurrentMessageUnread = isMessageUnread(reportAction, lastReadTimeRef.current); + shouldDisplay = isCurrentMessageUnread && (!nextMessage || !isMessageUnread(nextMessage, lastReadTimeRef.current)); if (!messageManuallyMarkedUnread) { shouldDisplay = shouldDisplay && reportAction.actorAccountID !== Report.getCurrentUserAccountID(); } + if (shouldDisplay) { + cacheUnreadMarkers.set(report.reportID, reportAction.reportActionID); + } } else { shouldDisplay = reportAction.reportActionID === currentUnreadMarker; } + return shouldDisplay; }, - [currentUnreadMarker, sortedReportActions, report.lastReadTime, messageManuallyMarkedUnread], + [currentUnreadMarker, sortedReportActions, report.reportID, messageManuallyMarkedUnread], ); useEffect(() => { @@ -327,13 +364,14 @@ function ReportActionsList({ } markerFound = true; if (!currentUnreadMarker && currentUnreadMarker !== reportAction.reportActionID) { + cacheUnreadMarkers.set(report.reportID, reportAction.reportActionID); setCurrentUnreadMarker(reportAction.reportActionID); } }); if (!markerFound) { setCurrentUnreadMarker(null); } - }, [sortedReportActions, report.lastReadTime, messageManuallyMarkedUnread, shouldDisplayNewMarker, currentUnreadMarker]); + }, [sortedReportActions, report.lastReadTime, report.reportID, messageManuallyMarkedUnread, shouldDisplayNewMarker, currentUnreadMarker]); const renderItem = useCallback( ({item: reportAction, index}) => ( diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js index 20344a08a2c8..eab9ab5a7510 100644 --- a/src/pages/iou/IOUCurrencySelection.js +++ b/src/pages/iou/IOUCurrencySelection.js @@ -157,20 +157,29 @@ function IOUCurrencySelection(props) { onEntryTransitionEnd={() => optionsSelectorRef.current && optionsSelectorRef.current.focus()} testID={IOUCurrencySelection.displayName} > - Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType, reportID))} - /> - + {({didScreenTransitionEnd}) => ( + <> + Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType, reportID))} + /> + { + if (!didScreenTransitionEnd) { + return; + } + confirmCurrencySelection(option); + }} + headerMessage={headerMessage} + initiallyFocusedOptionKey={initiallyFocusedOptionKey} + showScrollIndicator + /> + + )} ); } diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js index 03bc70822899..5794575aa600 100644 --- a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js +++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js @@ -11,6 +11,7 @@ import TextInput from '@components/TextInput'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles'; import compose from '@libs/compose'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; import getPermittedDecimalSeparator from '@libs/getPermittedDecimalSeparator'; import Navigation from '@libs/Navigation/Navigation'; import * as NumberUtils from '@libs/NumberUtils'; @@ -136,7 +137,9 @@ class WorkspaceRateAndUnitPage extends React.Component { validate(values) { const errors = {}; const decimalSeparator = this.props.toLocaleDigit('.'); - const rateValueRegex = RegExp(String.raw`^-?\d{0,8}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{1,3})?$`, 'i'); + const outputCurrency = lodashGet(this.props, 'policy.outputCurrency', CONST.CURRENCY.USD); + // Allow one more decimal place for accuracy + const rateValueRegex = RegExp(String.raw`^-?\d{0,8}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{1,${CurrencyUtils.getCurrencyDecimals(outputCurrency) + 1}})?$`, 'i'); if (!rateValueRegex.test(values.rate) || values.rate === '') { errors.rate = 'workspace.reimburse.invalidRateError'; } else if (NumberUtils.parseFloatAnyLocale(values.rate) <= 0) { diff --git a/tests/ui/UnreadIndicatorsTest.js b/tests/ui/UnreadIndicatorsTest.js index f4cf1c33ba17..b7db50edd5f7 100644 --- a/tests/ui/UnreadIndicatorsTest.js +++ b/tests/ui/UnreadIndicatorsTest.js @@ -3,7 +3,7 @@ import {addSeconds, format, subMinutes, subSeconds} from 'date-fns'; import {utcToZonedTime} from 'date-fns-tz'; import lodashGet from 'lodash/get'; import React from 'react'; -import {AppState, Linking} from 'react-native'; +import {AppState, DeviceEventEmitter, Linking} from 'react-native'; import Onyx from 'react-native-onyx'; import App from '../../src/App'; import CONFIG from '../../src/CONFIG'; @@ -87,7 +87,7 @@ function scrollUpToRevealNewMessagesBadge() { function isNewMessagesBadgeVisible() { const hintText = Localize.translateLocal('accessibilityHints.scrollToNewestMessages'); const badge = screen.queryByAccessibilityHint(hintText); - return Math.round(badge.props.style.transform[0].translateY) === 10; + return Math.round(badge.props.style.transform[0].translateY) === -40; } /** @@ -258,8 +258,12 @@ describe('Unread Indicators', () => { signInAndGetAppWithUnreadChat() // Navigate to the unread chat from the sidebar .then(() => navigateToSidebarOption(0)) - // Navigate to the unread chat from the sidebar - .then(() => navigateToSidebarOption(0)) + .then(() => { + // Verify the unread indicator is present + const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator'); + const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText); + expect(unreadIndicator).toHaveLength(1); + }) .then(() => { expect(areYouOnChatListScreen()).toBe(false); // Then navigate back to the sidebar @@ -268,9 +272,15 @@ describe('Unread Indicators', () => { .then(() => { // Verify the LHN is now open expect(areYouOnChatListScreen()).toBe(true); + // Tap on the chat again return navigateToSidebarOption(0); }) + .then(() => { + // Sending event to clear the unread indicator cache, given that the test doesn't behave as the app + DeviceEventEmitter.emit(`unreadAction_${REPORT_ID}`, format(new Date(), CONST.DATE.FNS_DB_FORMAT_STRING)); + return waitForBatchedUpdatesWithAct(); + }) .then(() => { // Verify the unread indicator is not present const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator');