Skip to content

Commit 35d22a6

Browse files
authored
Merge pull request #94308 from software-mansion-labs/@adamgrzybowski/fix-recently-added-bugs
2 parents 0be0365 + f78b07b commit 35d22a6

8 files changed

Lines changed: 193 additions & 36 deletions

File tree

src/components/TransactionItemRow/index.tsx

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,41 +9,25 @@ import {getCompanyCardDescription} from '@libs/CardUtils';
99
import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
1010
import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils';
1111
import {isExpenseReport, isSettled, shouldShowMarkAsDone} from '@libs/ReportUtils';
12-
import StringUtils from '@libs/StringUtils';
1312
import {
1413
getAmount,
1514
getDescription,
1615
getExchangeRate,
17-
getMerchant,
16+
getMerchantName,
1817
getCreated as getTransactionCreated,
1918
hasMissingSmartscanFields,
2019
isAmountMissing,
2120
isMerchantMissing,
22-
isScanning,
2321
shouldShowAttendees as shouldShowAttendeesUtils,
2422
} from '@libs/TransactionUtils';
2523
import CONST from '@src/CONST';
26-
import type {TranslationPaths} from '@src/languages/types';
2724
import ONYXKEYS from '@src/ONYXKEYS';
2825
import TransactionItemRowNarrow from './TransactionItemRowNarrow';
2926
import TransactionItemRowWide from './TransactionItemRowWide';
30-
import type {TransactionItemRowProps, TransactionWithOptionalSearchFields} from './types';
27+
import type {TransactionItemRowProps} from './types';
3128

3229
const EMPTY_ACTIVE_STYLE: StyleProp<ViewStyle> = [];
3330

34-
function getMerchantName(transactionItem: TransactionWithOptionalSearchFields, translate: (key: TranslationPaths) => string): string {
35-
const shouldShowMerchant = transactionItem.shouldShowMerchant ?? true;
36-
37-
let merchant = transactionItem?.formattedMerchant ?? getMerchant(transactionItem);
38-
39-
if (isScanning(transactionItem) && shouldShowMerchant) {
40-
merchant = translate('iou.receiptStatusTitle');
41-
}
42-
43-
const merchantName = StringUtils.getFirstLine(merchant);
44-
return merchantName !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && merchantName !== CONST.TRANSACTION.DEFAULT_MERCHANT ? (merchantName ?? '') : '';
45-
}
46-
4731
function TransactionItemRow({
4832
transactionItem,
4933
report,

src/libs/TransactionUtils/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
isSettled,
4747
isThread,
4848
} from '@libs/ReportUtils';
49+
import StringUtils from '@libs/StringUtils';
4950
import {isInvalidMerchantValue} from '@libs/ValidationUtils';
5051
import type {UpdateMoneyRequestDataKeys} from '@userActions/IOU/UpdateMoneyRequest';
5152
import type {IOURequestType, IOUType} from '@src/CONST';
@@ -1117,6 +1118,24 @@ function getMerchantOrDescription(transaction: OnyxEntry<Transaction>) {
11171118
return !isMerchantMissing(transaction) ? getMerchant(transaction) : getDescription(transaction);
11181119
}
11191120

1121+
/**
1122+
* Resolves the merchant string to display for a transaction. Returns the localized scanning label while a receipt is
1123+
* scanning, and normalizes the `DEFAULT_MERCHANT` ("Expense") and `PARTIAL_TRANSACTION_MERCHANT` ("(none)") placeholder
1124+
* sentinels to an empty string so they never leak into the UI.
1125+
*/
1126+
function getMerchantName(transaction: TransactionWithOptionalSearchFields, translate: (key: TranslationPaths) => string): string {
1127+
const shouldShowMerchant = transaction.shouldShowMerchant ?? true;
1128+
1129+
let merchant = transaction?.formattedMerchant ?? getMerchant(transaction);
1130+
1131+
if (isScanning(transaction) && shouldShowMerchant) {
1132+
merchant = translate('iou.receiptStatusTitle');
1133+
}
1134+
1135+
const merchantName = StringUtils.getFirstLine(merchant);
1136+
return merchantName !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && merchantName !== CONST.TRANSACTION.DEFAULT_MERCHANT ? (merchantName ?? '') : '';
1137+
}
1138+
11201139
function getReportOwnerAsAttendee(creatorDetails: OnyxEntry<PersonalDetails>): Attendee | undefined {
11211140
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
11221141
const creatorLogin = creatorDetails?.login || creatorDetails?.displayName || '';
@@ -3002,6 +3021,7 @@ export {
30023021
getOriginalAmount,
30033022
getFormattedAttendees,
30043023
getMerchant,
3024+
getMerchantName,
30053025
hasAnyTransactionWithoutRTERViolation,
30063026
getMerchantOrDescription,
30073027
getMCCGroup,

src/pages/home/RecentlyAddedSection/RecentlyAddedRow.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import type {RecentlyAddedExpense} from './useRecentlyAddedData';
1919
/** Width of the date column, shared with the section's column header so labels line up with the values. */
2020
const DATE_COLUMN_WIDTH = 72;
2121

22+
/** Wider date column used when a row's date includes the year (e.g. "Jun 7, 2025"), so it isn't truncated. */
23+
const DATE_COLUMN_WIDTH_WIDE = 102;
24+
2225
type RecentlyAddedRowProps = {
2326
/** The expense to render */
2427
expense: RecentlyAddedExpense;
@@ -31,17 +34,23 @@ type RecentlyAddedRowProps = {
3134

3235
/** Whether the hovered receipt preview may be shown. Becomes false once the screen blurs so the preview is dismissed after opening an expense. */
3336
shouldShowReceiptPreview: boolean;
37+
38+
/** Width of the date column, widened by the section when any visible expense's date includes the year. */
39+
dateColumnWidth: number;
3440
};
3541

36-
function RecentlyAddedRow({expense, onPress, shouldShowSeparator, shouldShowReceiptPreview}: RecentlyAddedRowProps) {
42+
function RecentlyAddedRow({expense, onPress, shouldShowSeparator, shouldShowReceiptPreview, dateColumnWidth}: RecentlyAddedRowProps) {
3743
const styles = useThemeStyles();
3844
const theme = useTheme();
3945
const StyleUtils = useStyleUtils();
4046
const {shouldUseNarrowLayout} = useResponsiveLayout();
4147
const {convertToDisplayString} = useCurrencyListActions();
4248
const icons = useMemoizedLazyExpensifyIcons(['ArrowRight']);
4349

44-
const formattedDate = DateUtils.formatWithUTCTimeZone(expense.created, CONST.DATE.MONTH_DAY_ABBR_FORMAT);
50+
const formattedDate = DateUtils.formatWithUTCTimeZone(
51+
expense.created,
52+
DateUtils.doesDateBelongToAPastYear(expense.created) ? CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT : CONST.DATE.MONTH_DAY_ABBR_FORMAT,
53+
);
4554

4655
const formattedAmount = convertToDisplayString(expense.amount, expense.currency);
4756

@@ -107,7 +116,7 @@ function RecentlyAddedRow({expense, onPress, shouldShowSeparator, shouldShowRece
107116
shouldUseNarrowLayout={false}
108117
/>
109118
</View>
110-
<View style={StyleUtils.getWidthStyle(DATE_COLUMN_WIDTH)}>
119+
<View style={StyleUtils.getWidthStyle(dateColumnWidth)}>
111120
<Text numberOfLines={1}>{formattedDate}</Text>
112121
</View>
113122
<Text
@@ -137,4 +146,4 @@ function RecentlyAddedRow({expense, onPress, shouldShowSeparator, shouldShowRece
137146
}
138147

139148
export default RecentlyAddedRow;
140-
export {DATE_COLUMN_WIDTH};
149+
export {DATE_COLUMN_WIDTH, DATE_COLUMN_WIDTH_WIDE};

src/pages/home/RecentlyAddedSection/index.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Text from '@components/Text';
88
import {useWideRHPActions} from '@components/WideRHPContextProvider';
99
import WidgetContainer from '@components/WidgetContainer';
1010
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
11+
import useIsAnonymousUser from '@hooks/useIsAnonymousUser';
1112
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
1213
import useLocalize from '@hooks/useLocalize';
1314
import useOnyx from '@hooks/useOnyx';
@@ -17,6 +18,7 @@ import useStyleUtils from '@hooks/useStyleUtils';
1718
import useTheme from '@hooks/useTheme';
1819
import useThemeStyles from '@hooks/useThemeStyles';
1920
import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation';
21+
import DateUtils from '@libs/DateUtils';
2022
import Navigation from '@libs/Navigation/Navigation';
2123
import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils';
2224
import type {TransactionThreadNavigationDescriptor} from '@libs/TransactionThreadNavigationUtils';
@@ -27,7 +29,7 @@ import CONST from '@src/CONST';
2729
import ONYXKEYS from '@src/ONYXKEYS';
2830
import ROUTES from '@src/ROUTES';
2931
import EmptyState from './EmptyState';
30-
import RecentlyAddedRow, {DATE_COLUMN_WIDTH} from './RecentlyAddedRow';
32+
import RecentlyAddedRow, {DATE_COLUMN_WIDTH, DATE_COLUMN_WIDTH_WIDE} from './RecentlyAddedRow';
3133
import type {RecentlyAddedExpense} from './useRecentlyAddedData';
3234
import {useRecentlyAddedData} from './useRecentlyAddedData';
3335

@@ -56,6 +58,7 @@ function RecentlyAddedSection() {
5658
const {calculatePopoverPosition} = usePopoverPosition();
5759
const {markReportIDAsExpense} = useWideRHPActions();
5860
const {email: currentUserEmail, accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
61+
const isAnonymousUser = useIsAnonymousUser();
5962
const [betas] = useOnyx(ONYXKEYS.BETAS);
6063
const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
6164
const [isOverflowMenuVisible, setIsOverflowMenuVisible] = useState(false);
@@ -64,6 +67,11 @@ function RecentlyAddedSection() {
6467

6568
const hasExpenses = transactions.length > 0;
6669

70+
// Mirror the Spend table: when any visible expense's date includes the year (a past-year date), widen the
71+
// shared date column so "Jun 7, 2025" isn't truncated. The header and every row use the same width to stay aligned.
72+
const shouldShowYear = transactions.some((expense) => DateUtils.doesDateBelongToAPastYear(expense.created));
73+
const dateColumnWidth = shouldShowYear ? DATE_COLUMN_WIDTH_WIDE : DATE_COLUMN_WIDTH;
74+
6775
const openExpense = (expense: RecentlyAddedExpense) => {
6876
// Resolve only the tapped expense now. getReportIDToOpenForExpense may create a transaction thread, so
6977
// resolving every sibling up front would create a thread for each multi-expense sibling on a single tap.
@@ -103,6 +111,12 @@ function RecentlyAddedSection() {
103111
);
104112
};
105113

114+
// Guests (anonymous users) viewing a public room have no expenses of their own, so the section is hidden
115+
// entirely rather than showing the empty state.
116+
if (isAnonymousUser) {
117+
return null;
118+
}
119+
106120
const overflowMenu = hasExpenses ? (
107121
<>
108122
<PressableWithoutFeedback
@@ -161,7 +175,7 @@ function RecentlyAddedSection() {
161175
<View style={StyleUtils.getReportTableColumnStyles(CONST.SEARCH.TABLE_COLUMNS.TYPE)}>
162176
<Text style={styles.textMicroSupporting}>{translate('common.type')}</Text>
163177
</View>
164-
<View style={StyleUtils.getWidthStyle(DATE_COLUMN_WIDTH)}>
178+
<View style={StyleUtils.getWidthStyle(dateColumnWidth)}>
165179
<Text style={styles.textMicroSupporting}>{translate('common.date')}</Text>
166180
</View>
167181
<Text style={[styles.flex1, styles.textMicroSupporting]}>{translate('common.merchant')}</Text>
@@ -176,6 +190,7 @@ function RecentlyAddedSection() {
176190
onPress={() => openExpense(expense)}
177191
shouldShowSeparator={index < transactions.length - 1}
178192
shouldShowReceiptPreview={isFocused}
193+
dateColumnWidth={dateColumnWidth}
179194
/>
180195
))}
181196
</View>

src/pages/home/RecentlyAddedSection/useRecentlyAddedData.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import {useIsFocused} from '@react-navigation/native';
22
import {useEffect, useEffectEvent, useMemo} from 'react';
33
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
4+
import useLocalize from '@hooks/useLocalize';
45
import useNetwork from '@hooks/useNetwork';
56
import useOnyx from '@hooks/useOnyx';
67
import {search} from '@libs/actions/Search';
78
import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils';
89
import {buildQueryStringFromFilterFormValues, buildSearchQueryJSON} from '@libs/SearchQueryUtils';
9-
import {getAmount, getCreated, getCurrency, getMerchant} from '@libs/TransactionUtils';
10+
import {getAmount, getCreated, getCurrency, getMerchantName} from '@libs/TransactionUtils';
1011
import CONST from '@src/CONST';
1112
import ONYXKEYS from '@src/ONYXKEYS';
1213
import type {Report, ReportAction, Transaction} from '@src/types/onyx';
@@ -52,6 +53,7 @@ type RecentlyAddedExpense = {
5253
function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} {
5354
const {accountID} = useCurrentUserPersonalDetails();
5455
const {isOffline} = useNetwork();
56+
const {translate} = useLocalize();
5557
const isFocused = useIsFocused();
5658

5759
const query = useMemo(
@@ -114,6 +116,8 @@ function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} {
114116
}
115117

116118
const reportOwnerByReportID = new Map<string, number | undefined>();
119+
const reportTypeByReportID = new Map<string, string | undefined>();
120+
const reportChatTypeByReportID = new Map<string, string | undefined>();
117121
const snapshotTransactions: Transaction[] = [];
118122
const snapshotReportActions: ReportAction[] = [];
119123
// Snapshot data is a keyed record where the key prefix determines the value type.
@@ -128,6 +132,8 @@ function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} {
128132
const report = value as Report;
129133
if (report?.reportID) {
130134
reportOwnerByReportID.set(report.reportID, report.ownerAccountID);
135+
reportTypeByReportID.set(report.reportID, report.type);
136+
reportChatTypeByReportID.set(report.reportID, report.chatType);
131137
}
132138
continue;
133139
}
@@ -164,17 +170,28 @@ function useRecentlyAddedData(): {transactions: RecentlyAddedExpense[]} {
164170
return firstKey < secondKey ? 1 : -1;
165171
})
166172
.slice(0, CONST.HOME.SECTION_VISIBLE_LIMIT)
167-
.map((transaction) => ({
168-
transactionID: transaction.transactionID,
169-
reportID: transaction.reportID,
170-
created: getCreated(transaction),
171-
merchant: getMerchant(transaction),
172-
amount: getAmount(transaction),
173-
currency: getCurrency(transaction),
174-
threadReportID: getIOUActionForTransactionID(snapshotReportActions, transaction.transactionID)?.childReportID,
175-
transaction,
176-
}));
177-
}, [snapshotData, accountID, insertedByTransactionID]);
173+
.map((transaction) => {
174+
const reportType = reportTypeByReportID.get(transaction.reportID);
175+
const isFromExpenseReport = reportType === CONST.REPORT.TYPE.EXPENSE;
176+
// Self-DM and unreported (tracked) expenses support signed amounts like expense reports, so their
177+
// sign must be preserved too. Without this, a self-DM credit/refund is collapsed to its absolute
178+
// value and loses its negative sign.
179+
const isFromTrackedExpense =
180+
transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID || reportChatTypeByReportID.get(transaction.reportID) === CONST.REPORT.CHAT_TYPE.SELF_DM;
181+
return {
182+
transactionID: transaction.transactionID,
183+
reportID: transaction.reportID,
184+
created: getCreated(transaction),
185+
merchant: getMerchantName(transaction, translate),
186+
// Expense-report, self-DM, and tracked transactions are stored with an inverted sign, so the
187+
// displayed amount must be negated for them (mirrors the Search transaction list).
188+
amount: getAmount(transaction, isFromExpenseReport, isFromTrackedExpense),
189+
currency: getCurrency(transaction),
190+
threadReportID: getIOUActionForTransactionID(snapshotReportActions, transaction.transactionID)?.childReportID,
191+
transaction,
192+
};
193+
});
194+
}, [snapshotData, accountID, insertedByTransactionID, translate]);
178195

179196
return {transactions};
180197
}

tests/ui/RecentlyAddedSectionTest.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,35 @@ describe('RecentlyAddedSection', () => {
230230
});
231231
});
232232

233+
describe('anonymous user', () => {
234+
it('hides the entire section (including the empty state) for guests', async () => {
235+
mockUseRecentlyAddedData.mockReturnValue({transactions: []});
236+
await act(async () => {
237+
await Onyx.merge(ONYXKEYS.SESSION, {authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS});
238+
});
239+
await waitForBatchedUpdatesWithAct();
240+
241+
renderRecentlyAddedSection();
242+
await waitForBatchedUpdatesWithAct();
243+
244+
expect(screen.queryByTestId('recentlyAddedEmptyState')).not.toBeOnTheScreen();
245+
expect(screen.queryByText('homePage.recentlyAddedSection.title')).not.toBeOnTheScreen();
246+
});
247+
248+
it('still hides the section for guests even when expenses are present', async () => {
249+
mockUseRecentlyAddedData.mockReturnValue({transactions: [ROW_1]});
250+
await act(async () => {
251+
await Onyx.merge(ONYXKEYS.SESSION, {authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS});
252+
});
253+
await waitForBatchedUpdatesWithAct();
254+
255+
renderRecentlyAddedSection();
256+
await waitForBatchedUpdatesWithAct();
257+
258+
expect(screen.queryByTestId('recentlyAddedRow-t1')).not.toBeOnTheScreen();
259+
});
260+
});
261+
233262
describe('rows', () => {
234263
it('renders one row per expense with the merchant name', async () => {
235264
mockUseRecentlyAddedData.mockReturnValue({transactions: [ROW_1, ROW_2]});

tests/unit/HomePage/RecentlyAddedSection/useRecentlyAddedDataTest.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,49 @@ describe('useRecentlyAddedData — unreported expenses', () => {
208208
});
209209
});
210210

211+
describe('useRecentlyAddedData — amount sign', () => {
212+
it('preserves the negative sign for self-DM credits/refunds', () => {
213+
setupSnapshot(
214+
[makeTransaction({transactionID: 'selfDMCredit', reportID: 'selfDM', amount: 1000, inserted: '2026-06-01 10:00:00'})],
215+
[makeReport('selfDM', ACCOUNT_ID, {type: CONST.REPORT.TYPE.CHAT, chatType: CONST.REPORT.CHAT_TYPE.SELF_DM})],
216+
);
217+
218+
const {result} = renderHook(() => useRecentlyAddedData());
219+
220+
expect(result.current.transactions.at(0)?.amount).toBe(-1000);
221+
});
222+
223+
it('preserves the negative sign for unreported (tracked) credits/refunds', () => {
224+
setupSnapshot([makeTransaction({transactionID: 'trackedCredit', reportID: CONST.REPORT.UNREPORTED_REPORT_ID, amount: 1000, inserted: '2026-06-01 10:00:00'})], []);
225+
226+
const {result} = renderHook(() => useRecentlyAddedData());
227+
228+
expect(result.current.transactions.at(0)?.amount).toBe(-1000);
229+
});
230+
231+
it('negates the inverted sign of expense-report transactions', () => {
232+
setupSnapshot(
233+
[makeTransaction({transactionID: 'expense', reportID: 'report_owned', amount: 1000, inserted: '2026-06-01 10:00:00'})],
234+
[makeReport('report_owned', ACCOUNT_ID, {type: CONST.REPORT.TYPE.EXPENSE})],
235+
);
236+
237+
const {result} = renderHook(() => useRecentlyAddedData());
238+
239+
expect(result.current.transactions.at(0)?.amount).toBe(-1000);
240+
});
241+
242+
it('returns the absolute amount for non self-DM, non expense-report transactions', () => {
243+
setupSnapshot(
244+
[makeTransaction({transactionID: 'iou', reportID: 'report_iou', amount: -1000, inserted: '2026-06-01 10:00:00'})],
245+
[makeReport('report_iou', ACCOUNT_ID, {type: CONST.REPORT.TYPE.IOU})],
246+
);
247+
248+
const {result} = renderHook(() => useRecentlyAddedData());
249+
250+
expect(result.current.transactions.at(0)?.amount).toBe(1000);
251+
});
252+
});
253+
211254
describe('useRecentlyAddedData — empty snapshot', () => {
212255
it('returns no expenses when the snapshot has not loaded yet', () => {
213256
delete onyxData[`${ONYXKEYS.COLLECTION.SNAPSHOT}${SNAPSHOT_HASH}`];

0 commit comments

Comments
 (0)