diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index 5dc19e5475ea7..93917404400dc 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -1,5 +1,5 @@ import mapValues from 'lodash/mapValues'; -import React, {useMemo, useState} from 'react'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -9,18 +9,22 @@ import Icon from '@components/Icon'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import PressableWithoutFocus from '@components/Pressable/PressableWithoutFocus'; import ReceiptAudit, {ReceiptAuditMessages} from '@components/ReceiptAudit'; import ReceiptEmptyState from '@components/ReceiptEmptyState'; +import Tooltip from '@components/Tooltip'; import useActiveRoute from '@hooks/useActiveRoute'; import useAncestors from '@hooks/useAncestors'; import useCardFeedErrors from '@hooks/useCardFeedErrors'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useEnvironment from '@hooks/useEnvironment'; +import useFilesValidation from '@hooks/useFilesValidation'; import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction'; import useHover from '@hooks/useHover'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useOriginalReportID from '@hooks/useOriginalReportID'; import useReportIsArchived from '@hooks/useReportIsArchived'; @@ -29,7 +33,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolations from '@hooks/useTransactionViolations'; import {getBrokenConnectionUrlToFixPersonalCard} from '@libs/CardUtils'; -import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import {hasHoverSupport} from '@libs/DeviceCapabilities'; import {getMicroSecondOnyxErrorWithTranslationKey, isReceiptError} from '@libs/ErrorUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; @@ -66,6 +70,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {TransactionPendingFieldsKey} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {isElementHovered, resetButtonHoverState} from './receiptHoverUtils'; import ReportActionItemImage from './ReportActionItemImage'; type MoneyRequestReceiptViewProps = { @@ -145,9 +150,23 @@ function MoneyRequestReceiptView({report, readonly = false, updatedTransaction, const theme = useTheme(); const ancestors = useAncestors(report); const {hovered, bind: hoverBind} = useHover(); - const isTouchScreen = canUseTouchScreen(); + const {isOffline} = useNetwork(); + const receiptContainerRef = useRef(null); + const addButtonRef = useRef(null); + const [isPickerOpen, setIsPickerOpen] = useState(false); + const deviceHasHoverSupport = hasHoverSupport(); const lazyIcons = useMemoizedLazyExpensifyIcons(['Expand', 'ReceiptPlus']); + // Browsers don't fire mouseenter when an element mounts under the cursor + useEffect(() => { + if (isLoading) { + return; + } + if (isElementHovered(receiptContainerRef)) { + hoverBind.onMouseEnter(); + } + }, [isLoading, hoverBind]); + // Flags for allowing or disallowing editing an expense // Used for non-restricted fields such as: description, category, tag, billable, etc... const isReportArchived = useReportIsArchived(report?.reportID); @@ -160,6 +179,23 @@ function MoneyRequestReceiptView({report, readonly = false, updatedTransaction, const canEditReceipt = isEditable && canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.RECEIPT, undefined, isChatReportArchived, undefined, transaction, moneyRequestReport, policy); + const onAttachmentFilesValidated = (files: FileObject[]) => { + if (!report?.reportID) { + return; + } + const notifyReportID = moneyRequestReport?.reportID ? [report.reportID, moneyRequestReport.reportID] : report.reportID; + addAttachmentWithComment({ + report, + notifyReportID, + ancestors, + attachments: files, + currentUserAccountID, + timezone: currentUserTimezone, + }); + }; + + const {validateFiles, PDFValidationComponent, ErrorModal: AttachmentErrorModal} = useFilesValidation(onAttachmentFilesValidated); + const iouType = useMemo(() => { if (isTrackExpense) { return CONST.IOU.TYPE.TRACK; @@ -338,7 +374,8 @@ function MoneyRequestReceiptView({report, readonly = false, updatedTransaction, const isMapDistanceRequest = !!transaction && isDistanceRequest && !isManualDistanceRequest(transaction); const canShowReceiptActions = hasReceipt && !isLoading && isEditable && !isMapDistanceRequest && !mergeTransactionID; - + const receiptPendingAction = isDistanceRequest ? getPendingFieldAction('waypoints') : getPendingFieldAction('receipt'); + const isReceiptOfflinePending = isOffline && !!receiptPendingAction; const receiptAuditMessagesRow = ( @@ -397,7 +434,8 @@ function MoneyRequestReceiptView({report, readonly = false, updatedTransaction, )} {(hasReceipt || !isEmptyObject(errors)) && ( { @@ -434,85 +472,91 @@ function MoneyRequestReceiptView({report, readonly = false, updatedTransaction, > {hasReceipt && ( !isLoading && hoverBind.onMouseEnter()} onMouseLeave={hoverBind.onMouseLeave} > - setIsLoading(false)} - onLoadFailure={() => setIsLoading(false)} - /> + + setIsLoading(false)} + onLoadFailure={() => setIsLoading(false)} + /> + {canShowReceiptActions && ( - - + + {({openPicker}) => ( - { - openPicker({ - onPicked: (files) => { - if (!report?.reportID) { - return; + + { + setIsPickerOpen(true); + resetButtonHoverState(addButtonRef); + const onPickerClosed = () => { + setIsPickerOpen(false); + if (isElementHovered(receiptContainerRef)) { + hoverBind.onMouseEnter(); } - addAttachmentWithComment({ - report, - notifyReportID: moneyRequestReport?.reportID ?? report.reportID, - ancestors, - attachments: files, - currentUserAccountID, - timezone: currentUserTimezone, - }); - }, - }); - }} - style={styles.receiptActionButton} - accessibilityLabel={translate('reportActionCompose.addAttachment')} - role={CONST.ROLE.BUTTON} - sentryLabel={CONST.SENTRY_LABEL.RECEIPT.ADD_ATTACHMENT_BUTTON} - > - + }; + openPicker({ + onPicked: (files) => { + onPickerClosed(); + validateFiles(files); + }, + onCanceled: onPickerClosed, + }); + }} + style={styles.receiptActionButton} + hoverStyle={styles.buttonDefaultHovered} + accessibilityLabel={translate('receipt.addAdditionalReceipt')} + role={CONST.ROLE.BUTTON} + sentryLabel={CONST.SENTRY_LABEL.RECEIPT.ADD_ATTACHMENT_BUTTON} + > - - + + )} - - Navigation.navigate( - ROUTES.TRANSACTION_RECEIPT.getRoute(report?.reportID, (updatedTransaction ?? transaction)?.transactionID, readonly || !canEditReceipt), - ) - } - style={styles.receiptActionButton} - accessibilityLabel={translate('accessibilityHints.viewAttachment')} - role={CONST.ROLE.BUTTON} - sentryLabel={CONST.SENTRY_LABEL.RECEIPT.ENLARGE_BUTTON} - > - + + + Navigation.navigate( + ROUTES.TRANSACTION_RECEIPT.getRoute(report?.reportID, (updatedTransaction ?? transaction)?.transactionID, readonly || !canEditReceipt), + ) + } + style={styles.receiptActionButton} + hoverStyle={styles.buttonDefaultHovered} + accessibilityLabel={translate('accessibilityHints.viewAttachment')} + role={CONST.ROLE.BUTTON} + sentryLabel={CONST.SENTRY_LABEL.RECEIPT.ENLARGE_BUTTON} + > - - + + )} @@ -524,6 +568,8 @@ function MoneyRequestReceiptView({report, readonly = false, updatedTransaction, )} {!shouldShowReceiptEmptyState && !hasReceipt && } {!!shouldShowAuditMessage && !hasReceipt && receiptAuditMessagesRow} + {AttachmentErrorModal} + {PDFValidationComponent} ); } diff --git a/src/components/ReportActionItem/receiptHoverUtils/index.native.ts b/src/components/ReportActionItem/receiptHoverUtils/index.native.ts new file mode 100644 index 0000000000000..a7564507ba3b2 --- /dev/null +++ b/src/components/ReportActionItem/receiptHoverUtils/index.native.ts @@ -0,0 +1,8 @@ +// No-op on native — hover states don't exist on mobile +function resetButtonHoverState() {} + +function isElementHovered(): boolean { + return false; +} + +export {resetButtonHoverState, isElementHovered}; diff --git a/src/components/ReportActionItem/receiptHoverUtils/index.ts b/src/components/ReportActionItem/receiptHoverUtils/index.ts new file mode 100644 index 0000000000000..6afbeb418a33a --- /dev/null +++ b/src/components/ReportActionItem/receiptHoverUtils/index.ts @@ -0,0 +1,16 @@ +import type {RefObject} from 'react'; +import type {View} from 'react-native'; + +/** Reset stale button hover/tooltip when file picker opens (browsers don't fire mouseleave). */ +function resetButtonHoverState(addButtonRef: RefObject) { + const buttonEl = addButtonRef.current as unknown as HTMLElement; + buttonEl?.dispatchEvent(new PointerEvent('pointerleave')); + buttonEl?.dispatchEvent(new MouseEvent('mouseout', {bubbles: true, relatedTarget: document.body})); +} + +/** Check if cursor is over the element (web only). */ +function isElementHovered(ref: RefObject): boolean { + return !!(ref.current as unknown as HTMLElement)?.matches?.(':hover'); +} + +export {resetButtonHoverState, isElementHovered}; diff --git a/src/hooks/useHover.ts b/src/hooks/useHover.ts index d715f5422aec6..fec4c6474fb18 100644 --- a/src/hooks/useHover.ts +++ b/src/hooks/useHover.ts @@ -1,14 +1,14 @@ import {useState} from 'react'; -import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabilities'; +import {hasHoverSupport} from '@libs/DeviceCapabilities'; const useHover = () => { const [hovered, setHovered] = useState(false); - const canUseTouchScreen = canUseTouchScreenUtil(); + const deviceHasHoverSupport = hasHoverSupport(); return { hovered, bind: { - onMouseEnter: () => !canUseTouchScreen && setHovered(true), - onMouseLeave: () => !canUseTouchScreen && setHovered(false), + onMouseEnter: () => deviceHasHoverSupport && setHovered(true), + onMouseLeave: () => deviceHasHoverSupport && setHovered(false), }, }; }; diff --git a/src/languages/de.ts b/src/languages/de.ts index 56eef0b683adf..9c31b902873ac 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1140,6 +1140,7 @@ const translations: TranslationDeepObject = { deleteReceipt: 'Beleg löschen', deleteConfirmation: 'Sind Sie sicher, dass Sie diesen Beleg löschen möchten?', addReceipt: 'Beleg hinzufügen', + addAdditionalReceipt: 'Zusätzlichen Beleg hinzufügen', scanFailed: 'Der Beleg konnte nicht gescannt werden, da Händler, Datum oder Betrag fehlen.', crop: 'Zuschneiden', addAReceipt: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 9e1b3c985c71b..b7d316f539b36 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1182,6 +1182,7 @@ const translations = { deleteReceipt: 'Delete receipt', deleteConfirmation: 'Are you sure you want to delete this receipt?', addReceipt: 'Add receipt', + addAdditionalReceipt: 'Add additional receipt', scanFailed: "The receipt couldn't be scanned, as it's missing a merchant, date, or amount.", crop: 'Crop', addAReceipt: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 28c768351a01a..2f4a65662e1c5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1054,6 +1054,7 @@ const translations: TranslationDeepObject = { deleteReceipt: 'Eliminar recibo', deleteConfirmation: '¿Estás seguro de que quieres borrar este recibo?', addReceipt: 'Añadir recibo', + addAdditionalReceipt: 'Añadir recibo adicional', scanFailed: 'El recibo no pudo ser escaneado, ya que falta el comerciante, la fecha o el monto.', crop: 'Recortar', addAReceipt: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index e3141cb9aa261..f82e1ed274e61 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1144,6 +1144,7 @@ const translations: TranslationDeepObject = { deleteReceipt: 'Supprimer le reçu', deleteConfirmation: 'Voulez-vous vraiment supprimer ce reçu ?', addReceipt: 'Ajouter un reçu', + addAdditionalReceipt: 'Ajouter un reçu supplémentaire', scanFailed: 'Le reçu n’a pas pu être scanné, car il lui manque un commerçant, une date ou un montant.', crop: 'Recadrer', addAReceipt: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 2814a3f55957f..6cf0b983d4597 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1139,6 +1139,7 @@ const translations: TranslationDeepObject = { deleteReceipt: 'Elimina ricevuta', deleteConfirmation: 'Sei sicuro di voler eliminare questa ricevuta?', addReceipt: 'Aggiungi ricevuta', + addAdditionalReceipt: 'Aggiungi ricevuta aggiuntiva', scanFailed: 'La ricevuta non può essere acquisita perché manca il nome dell’esercente, la data o l’importo.', crop: 'Ritaglia', addAReceipt: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1fb0fb5313e16..567ff2ce7687f 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1131,6 +1131,7 @@ const translations: TranslationDeepObject = { deleteReceipt: '領収書を削除', deleteConfirmation: 'この領収書を削除してもよろしいですか?', addReceipt: '領収書を追加', + addAdditionalReceipt: 'レシートを追加', scanFailed: 'このレシートは、店舗名、日付、または金額が不足しているためスキャンできませんでした。', crop: 'トリミング', addAReceipt: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 5bee400ffe6f5..a49f3bdd2f184 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1138,6 +1138,7 @@ const translations: TranslationDeepObject = { deleteReceipt: 'Bon verwijderen', deleteConfirmation: 'Weet je zeker dat je deze bon wilt verwijderen?', addReceipt: 'Bon toevoegen', + addAdditionalReceipt: 'Voeg extra bon toe', scanFailed: 'De bon is niet gescand, omdat er een handelaar, datum of bedrag ontbreekt.', crop: 'Bijsnijden', addAReceipt: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 3bea0ab6b5ffd..ea344db40ea78 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1138,6 +1138,7 @@ const translations: TranslationDeepObject = { deleteReceipt: 'Usuń paragon', deleteConfirmation: 'Czy na pewno chcesz usunąć ten paragon?', addReceipt: 'Dodaj paragon', + addAdditionalReceipt: 'Dodaj dodatkowy paragon', scanFailed: 'Nie można było zeskanować paragonu, ponieważ brakuje na nim sprzedawcy, daty lub kwoty.', crop: 'Przytnij', addAReceipt: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index e2c3fb53a3f5f..4eebbaf6782a2 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1137,6 +1137,7 @@ const translations: TranslationDeepObject = { deleteReceipt: 'Excluir recibo', deleteConfirmation: 'Tem certeza de que deseja excluir este recibo?', addReceipt: 'Adicionar recibo', + addAdditionalReceipt: 'Adicionar recibo adicional', scanFailed: 'O recibo não pôde ser digitalizado porque está faltando o comerciante, a data ou o valor.', crop: 'Cortar', addAReceipt: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 96415f4fa273d..25f9e3a0b747c 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1114,6 +1114,7 @@ const translations: TranslationDeepObject = { deleteReceipt: '删除收据', deleteConfirmation: '确定要删除这张收据吗?', addReceipt: '添加收据', + addAdditionalReceipt: '添加额外收据', scanFailed: '无法扫描此收据,因为缺少商家、日期或金额。', crop: '裁剪', addAReceipt: { diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 31d47f7dd603e..cf36b9f5f712b 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -346,7 +346,7 @@ type AddCommentParams = { type AddActionsParams = { report: OnyxEntry; - notifyReportID: string; + notifyReportID: string | string[]; ancestors: Ancestor[]; timezoneParam: Timezone; currentUserAccountID: number; @@ -359,7 +359,7 @@ type AddActionsParams = { type AddAttachmentWithCommentParams = { report: OnyxEntry; - notifyReportID: string; + notifyReportID: string | string[]; ancestors: Ancestor[]; attachments: FileObject | FileObject[]; currentUserAccountID: number; @@ -643,12 +643,17 @@ function subscribeToNewActionEvent(reportID: string, callback: SubscriberCallbac } /** Notify the ReportActionsView that a new comment has arrived */ -function notifyNewAction(reportID: string | undefined, reportAction: ReportAction | undefined, isFromCurrentUser: boolean) { - const actionSubscriber = newActionSubscribers.find((subscriber) => subscriber.reportID === reportID); - if (!actionSubscriber) { +function notifyNewAction(reportID: string | string[] | undefined, reportAction: ReportAction | undefined, isFromCurrentUser: boolean) { + if (!reportID) { return; } - actionSubscriber.callback(isFromCurrentUser, reportAction); + const ids = Array.isArray(reportID) ? reportID : [reportID]; + for (const id of ids) { + const actionSubscriber = newActionSubscribers.find((subscriber) => subscriber.reportID === id); + if (actionSubscriber) { + actionSubscriber.callback(isFromCurrentUser, reportAction); + } + } } /** diff --git a/src/styles/index.ts b/src/styles/index.ts index 50b3d07dafbff..0c5ff37176085 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3627,6 +3627,8 @@ const staticStyles = (theme: ThemeColors) => height: 40, alignItems: 'center', justifyContent: 'center', + backgroundColor: theme.buttonDefaultBG, + borderRadius: 20, }, bgGreenSuccess: { diff --git a/tests/ui/components/MoneyRequestReceiptViewTest.tsx b/tests/ui/components/MoneyRequestReceiptViewTest.tsx index 6a14647d407e4..9ea3f4f33734d 100644 --- a/tests/ui/components/MoneyRequestReceiptViewTest.tsx +++ b/tests/ui/components/MoneyRequestReceiptViewTest.tsx @@ -247,7 +247,7 @@ describe('MoneyRequestReceiptView', () => { await waitForBatchedUpdatesWithAct(); expect(screen.queryByLabelText(translateLocal('accessibilityHints.viewAttachment'))).toBeNull(); - expect(screen.queryByLabelText(translateLocal('reportActionCompose.addAttachment'))).toBeNull(); + expect(screen.queryByLabelText(translateLocal('receipt.addAdditionalReceipt'))).toBeNull(); }); it('shows action buttons when transaction has a receipt', async () => { @@ -264,7 +264,7 @@ describe('MoneyRequestReceiptView', () => { await waitForBatchedUpdatesWithAct(); expect(screen.getByLabelText(translateLocal('accessibilityHints.viewAttachment'))).toBeTruthy(); - expect(screen.getByLabelText(translateLocal('reportActionCompose.addAttachment'))).toBeTruthy(); + expect(screen.getByLabelText(translateLocal('receipt.addAdditionalReceipt'))).toBeTruthy(); }); it('shows action buttons when receipt is scanning', async () => { @@ -281,7 +281,7 @@ describe('MoneyRequestReceiptView', () => { await waitForBatchedUpdatesWithAct(); expect(screen.getByLabelText(translateLocal('accessibilityHints.viewAttachment'))).toBeTruthy(); - expect(screen.getByLabelText(translateLocal('reportActionCompose.addAttachment'))).toBeTruthy(); + expect(screen.getByLabelText(translateLocal('receipt.addAdditionalReceipt'))).toBeTruthy(); }); it('does not show action buttons in readonly mode', async () => { @@ -301,7 +301,7 @@ describe('MoneyRequestReceiptView', () => { await waitForBatchedUpdatesWithAct(); expect(screen.queryByLabelText(translateLocal('accessibilityHints.viewAttachment'))).toBeNull(); - expect(screen.queryByLabelText(translateLocal('reportActionCompose.addAttachment'))).toBeNull(); + expect(screen.queryByLabelText(translateLocal('receipt.addAdditionalReceipt'))).toBeNull(); }); }); });