Skip to content

Commit feae7c5

Browse files
authored
Merge pull request Expensify#65184 from software-mansion-labs/borys3kk-feature-zoom-receipt-on-hover
Hover-to-view for receipt thumbnails
2 parents bc8c57e + 818cfe7 commit feae7c5

10 files changed

Lines changed: 252 additions & 9 deletions

File tree

src/CONST/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,6 +1038,7 @@ const CONST = {
10381038
SHUTTER_SIZE: 90,
10391039
MAX_REPORT_PREVIEW_RECEIPTS: 3,
10401040
},
1041+
RECEIPT_PREVIEW_TOP_BOTTOM_MARGIN: 120,
10411042
REPORT: {
10421043
ROLE: {
10431044
ADMIN: 'admin',
@@ -1512,6 +1513,8 @@ const CONST = {
15121513
SEARCH_MOST_RECENT_OPTIONS: 'search_most_recent_options',
15131514
DEBOUNCE_HANDLE_SEARCH: 'debounce_handle_search',
15141515
FAST_SEARCH_TREE_CREATION: 'fast_search_tree_creation',
1516+
SHOW_HOVER_PREVIEW_DELAY: 270,
1517+
SHOW_HOVER_PREVIEW_ANIMATION_DURATION: 200,
15151518
},
15161519
PRIORITY_MODE: {
15171520
GSD: 'gsd',

src/components/DistanceEReceipt.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,12 @@ import Text from './Text';
2222
type DistanceEReceiptProps = {
2323
/** The transaction for the distance expense */
2424
transaction: Transaction;
25+
26+
/** Whether the distanceEReceipt is shown as hover preview */
27+
hoverPreview?: boolean;
2528
};
2629

27-
function DistanceEReceipt({transaction}: DistanceEReceiptProps) {
30+
function DistanceEReceipt({transaction, hoverPreview = false}: DistanceEReceiptProps) {
2831
const styles = useThemeStyles();
2932
const {translate} = useLocalize();
3033
const thumbnail = hasReceipt(transaction) ? getThumbnailAndImageURIs(transaction).thumbnail : null;
@@ -42,7 +45,7 @@ function DistanceEReceipt({transaction}: DistanceEReceiptProps) {
4245
[waypoints],
4346
);
4447
return (
45-
<View style={[styles.flex1, styles.alignItemsCenter]}>
48+
<View style={[styles.flex1, styles.alignItemsCenter, hoverPreview && styles.mhv5]}>
4649
<ScrollView
4750
style={styles.w100}
4851
contentContainerStyle={[styles.flexGrow1, styles.justifyContentCenter, styles.alignItemsCenter]}

src/components/SelectionList/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,11 +273,20 @@ type TransactionListItemType = ListItem &
273273
/** Key used internally by React */
274274
keyForList: string;
275275

276+
/** The name of the file used for a receipt */
277+
filename?: string;
278+
276279
/** Attendees in the transaction */
277280
attendees?: Attendee[];
278281

279282
/** Precomputed violations */
280283
violations?: TransactionViolation[];
284+
285+
/** The CC for this transaction */
286+
cardID?: number;
287+
288+
/** The display name of the purchaser card, if any */
289+
cardName?: string;
281290
};
282291

283292
type ReportActionListItemType = ListItem &

src/components/TransactionItemRow/DataCells/ReceiptCell.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {View} from 'react-native';
33
import type {ViewStyle} from 'react-native';
44
import {Receipt} from '@components/Icon/Expensicons';
55
import ReceiptImage from '@components/ReceiptImage';
6+
import ReceiptPreview from '@components/TransactionItemRow/ReceiptPreview';
7+
import useHover from '@hooks/useHover';
68
import useStyleUtils from '@hooks/useStyleUtils';
79
import useTheme from '@hooks/useTheme';
810
import useThemeStyles from '@hooks/useThemeStyles';
@@ -18,10 +20,10 @@ function ReceiptCell({transactionItem, isSelected, style}: {transactionItem: Tra
1820
const styles = useThemeStyles();
1921
const StyleUtils = useStyleUtils();
2022
const backgroundStyles = isSelected ? StyleUtils.getBackgroundColorStyle(theme.buttonHoveredBG) : StyleUtils.getBackgroundColorStyle(theme.border);
21-
23+
const {hovered, bind} = useHover();
24+
const isEReceipt = transactionItem.hasEReceipt && !hasReceiptSource(transactionItem);
2225
let source = transactionItem?.receipt?.source ?? '';
23-
24-
if (source && typeof source === 'string') {
26+
if (source) {
2527
const filename = getFileName(source);
2628
const receiptURIs = getThumbnailAndImageURIs(transactionItem, null, filename);
2729
source = tryResolveUrlFromApiRoot(receiptURIs.thumbnail ?? receiptURIs.image ?? '');
@@ -36,10 +38,12 @@ function ReceiptCell({transactionItem, isSelected, style}: {transactionItem: Tra
3638
backgroundStyles,
3739
style,
3840
]}
41+
onMouseEnter={bind.onMouseEnter}
42+
onMouseLeave={bind.onMouseLeave}
3943
>
4044
<ReceiptImage
4145
source={source}
42-
isEReceipt={transactionItem.hasEReceipt && !hasReceiptSource(transactionItem)}
46+
isEReceipt={isEReceipt}
4347
transactionID={transactionItem.transactionID}
4448
shouldUseThumbnailImage={!transactionItem?.receipt?.source}
4549
isAuthTokenRequired
@@ -52,6 +56,12 @@ function ReceiptCell({transactionItem, isSelected, style}: {transactionItem: Tra
5256
loadingIndicatorStyles={styles.bgTransparent}
5357
transactionItem={transactionItem}
5458
/>
59+
<ReceiptPreview
60+
source={source}
61+
hovered={hovered}
62+
isEReceipt={!!isEReceipt}
63+
transactionItem={transactionItem}
64+
/>
5565
</View>
5666
);
5767
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
function ReceiptPreview() {
2+
return null;
3+
}
4+
5+
export default ReceiptPreview;
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import React, {useCallback, useEffect, useRef, useState} from 'react';
2+
import ReactDOM from 'react-dom';
3+
import type {LayoutChangeEvent} from 'react-native';
4+
import {View} from 'react-native';
5+
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated';
6+
import DistanceEReceipt from '@components/DistanceEReceipt';
7+
import EReceipt from '@components/EReceipt';
8+
import BaseImage from '@components/Image/BaseImage';
9+
import type {ImageOnLoadEvent} from '@components/Image/types';
10+
import useDebouncedState from '@hooks/useDebouncedState';
11+
import useResponsiveLayout from '@hooks/useResponsiveLayout';
12+
import useThemeStyles from '@hooks/useThemeStyles';
13+
import useWindowDimensions from '@hooks/useWindowDimensions';
14+
import {isDistanceRequest} from '@libs/TransactionUtils';
15+
import variables from '@styles/variables';
16+
import CONST from '@src/CONST';
17+
import type {Transaction} from '@src/types/onyx';
18+
19+
const eReceiptAspectRatio = variables.eReceiptBGHWidth / variables.eReceiptBGHeight;
20+
21+
type ReceiptPreviewProps = {
22+
/** Path to the image to be opened in the preview */
23+
source: string;
24+
25+
/** Whether the preview should be shown (e.g. if we are hovered over certain ReceiptCell) */
26+
hovered: boolean;
27+
28+
/** Is preview for an e-receipt */
29+
isEReceipt: boolean;
30+
31+
/** Transaction object related to the preview */
32+
transactionItem: Transaction;
33+
};
34+
35+
function ReceiptPreview({source, hovered, isEReceipt = false, transactionItem}: ReceiptPreviewProps) {
36+
const isDistanceEReceipt = isDistanceRequest(transactionItem);
37+
const styles = useThemeStyles();
38+
const [eReceiptScaleFactor, setEReceiptScaleFactor] = useState(0);
39+
const [imageAspectRatio, setImageAspectRatio] = useState<string | number | undefined>(undefined);
40+
const [distanceEReceiptAspectRatio, setDistanceEReceiptAspectRatio] = useState<string | number | undefined>(undefined);
41+
const [shouldShow, debounceShouldShow, setShouldShow] = useDebouncedState(false, CONST.TIMING.SHOW_HOVER_PREVIEW_DELAY);
42+
const {shouldUseNarrowLayout} = useResponsiveLayout();
43+
const hasMeasured = useRef(false);
44+
const {windowHeight} = useWindowDimensions();
45+
46+
const handleDistanceEReceiptLayout = (e: LayoutChangeEvent) => {
47+
if (hasMeasured.current) {
48+
return;
49+
}
50+
hasMeasured.current = true;
51+
52+
const {height, width} = e.nativeEvent.layout;
53+
if (height === 0) {
54+
// on the initial layout, measured height is 0, so we want to set everything on the second one
55+
hasMeasured.current = false;
56+
return;
57+
}
58+
if (height * eReceiptScaleFactor > windowHeight - CONST.RECEIPT_PREVIEW_TOP_BOTTOM_MARGIN) {
59+
setDistanceEReceiptAspectRatio(variables.eReceiptBGHWidth / (windowHeight - CONST.RECEIPT_PREVIEW_TOP_BOTTOM_MARGIN));
60+
return;
61+
}
62+
setDistanceEReceiptAspectRatio(variables.eReceiptBGHWidth / height);
63+
setEReceiptScaleFactor(width / variables.eReceiptBGHWidth);
64+
};
65+
66+
const updateImageAspectRatio = useCallback(
67+
(width: number, height: number) => {
68+
if (!source) {
69+
return;
70+
}
71+
72+
setImageAspectRatio(height ? width / height : 'auto');
73+
},
74+
[source],
75+
);
76+
77+
const handleLoad = useCallback(
78+
(event: ImageOnLoadEvent) => {
79+
const {width, height} = event.nativeEvent;
80+
81+
updateImageAspectRatio(width, height);
82+
},
83+
[updateImageAspectRatio],
84+
);
85+
86+
const handleEReceiptLayout = (e: LayoutChangeEvent) => {
87+
const {width} = e.nativeEvent.layout;
88+
setEReceiptScaleFactor(width / variables.eReceiptBGHWidth);
89+
};
90+
91+
useEffect(() => {
92+
setShouldShow(hovered);
93+
}, [hovered, setShouldShow]);
94+
95+
if (shouldUseNarrowLayout || !debounceShouldShow || !shouldShow || (!source && !isEReceipt && !isDistanceEReceipt)) {
96+
return null;
97+
}
98+
99+
const shouldShowImage = source && !(isEReceipt || isDistanceEReceipt);
100+
const shouldShowDistanceEReceipt = isDistanceEReceipt && !isEReceipt;
101+
102+
return ReactDOM.createPortal(
103+
<Animated.View
104+
entering={FadeIn.duration(CONST.TIMING.SHOW_HOVER_PREVIEW_ANIMATION_DURATION)}
105+
exiting={FadeOut.duration(CONST.TIMING.SHOW_HOVER_PREVIEW_ANIMATION_DURATION)}
106+
style={[styles.receiptPreview, styles.flexColumn, styles.alignItemsCenter, styles.justifyContentStart]}
107+
>
108+
{shouldShowImage ? (
109+
<View style={[styles.w100]}>
110+
<BaseImage
111+
source={{uri: source}}
112+
style={[styles.w100, {aspectRatio: imageAspectRatio}]}
113+
onLoad={handleLoad}
114+
/>
115+
</View>
116+
) : (
117+
<View style={styles.receiptPreviewEReceiptsContainer}>
118+
{shouldShowDistanceEReceipt ? (
119+
<View
120+
onLayout={handleDistanceEReceiptLayout}
121+
style={[{transformOrigin: 'center', scale: eReceiptScaleFactor, aspectRatio: distanceEReceiptAspectRatio}]}
122+
>
123+
<DistanceEReceipt
124+
transaction={transactionItem}
125+
hoverPreview
126+
/>
127+
</View>
128+
) : (
129+
<View
130+
onLayout={handleEReceiptLayout}
131+
style={[styles.receiptPreviewEReceipt, {aspectRatio: eReceiptAspectRatio, scale: eReceiptScaleFactor}]}
132+
>
133+
<EReceipt
134+
transactionID={transactionItem.transactionID}
135+
transactionItem={transactionItem}
136+
/>
137+
</View>
138+
)}
139+
</View>
140+
)}
141+
</Animated.View>,
142+
document.body,
143+
);
144+
}
145+
146+
export default ReceiptPreview;

src/libs/SearchUIUtils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -752,12 +752,10 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata
752752
const report = data[`${ONYXKEYS.COLLECTION.REPORT}${transactionItem.reportID}`];
753753
const policy = data[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`];
754754
const shouldShowBlankTo = !report || isOpenExpenseReport(report);
755-
756755
const transactionViolations = getTransactionViolations(allViolations, transactionItem);
757756
// Use Map.get() for faster lookups with default values
758757
const from = personalDetailsMap.get(transactionItem.accountID.toString()) ?? emptyPersonalDetails;
759758
const to = transactionItem.managerID && !shouldShowBlankTo ? (personalDetailsMap.get(transactionItem.managerID.toString()) ?? emptyPersonalDetails) : emptyPersonalDetails;
760-
761759
const {formattedFrom, formattedTo, formattedTotal, formattedMerchant, date} = getTransactionItemCommonFormattedProperties(transactionItem, from, to, policy);
762760

763761
const transactionSection: TransactionListItemType = {
@@ -781,7 +779,7 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata
781779
isAmountColumnWide: shouldShowAmountInWideColumn,
782780
isTaxAmountColumnWide: shouldShowTaxAmountInWideColumn,
783781
violations: transactionViolations,
784-
782+
filename: transactionItem.filename,
785783
// Manually copying all the properties from transactionItem
786784
transactionID: transactionItem.transactionID,
787785
created: transactionItem.created,
@@ -819,6 +817,8 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata
819817
errors: transactionItem.errors,
820818
isActionLoading: transactionItem.isActionLoading,
821819
hasViolation: transactionItem.hasViolation,
820+
cardID: transactionItem.cardID,
821+
cardName: transactionItem.cardName,
822822
};
823823

824824
transactionsSections.push(transactionSection);

src/styles/index.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5693,6 +5693,32 @@ const styles = (theme: ThemeColors) =>
56935693
aspectRatio: 1.7,
56945694
},
56955695

5696+
receiptPreview: {
5697+
position: 'absolute',
5698+
left: 60,
5699+
top: 60,
5700+
width: 380,
5701+
maxHeight: 'calc(100vh - 120px)',
5702+
borderRadius: variables.componentBorderRadiusLarge,
5703+
borderWidth: 1,
5704+
borderColor: theme.border,
5705+
overflow: 'hidden',
5706+
boxShadow: theme.shadow,
5707+
backgroundColor: theme.appBG,
5708+
},
5709+
5710+
receiptPreviewEReceiptsContainer: {
5711+
...sizing.w100,
5712+
...sizing.h100,
5713+
backgroundColor: colors.green800,
5714+
},
5715+
5716+
receiptPreviewEReceipt: {
5717+
...flex.flexColumn,
5718+
...flex.justifyContentCenter,
5719+
...flex.alignItemsCenter,
5720+
},
5721+
56965722
topBarWrapper: {
56975723
zIndex: 15,
56985724
},

src/types/onyx/SearchResults.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,9 @@ type SearchTransaction = {
409409
/** The ID of the report the transaction is associated with */
410410
reportID: string;
411411

412+
/** The name of the file used for a receipt */
413+
filename?: string;
414+
412415
/** The report ID of the transaction thread associated with the transaction */
413416
transactionThreadReportID: string;
414417

@@ -435,6 +438,12 @@ type SearchTransaction = {
435438

436439
/** The type of action that's pending */
437440
pendingAction?: OnyxCommon.PendingAction;
441+
442+
/** The CC for this transaction */
443+
cardID?: number;
444+
445+
/** The display name of the purchaser card, if any */
446+
cardName?: string;
438447
};
439448

440449
/** Model of tasks search result */

0 commit comments

Comments
 (0)