diff --git a/packages/icons/src/generated/icons/CoinSlash.tsx b/packages/icons/src/generated/icons/CoinSlash.tsx index f5b1ad48575c..04a6e8b9b350 100644 --- a/packages/icons/src/generated/icons/CoinSlash.tsx +++ b/packages/icons/src/generated/icons/CoinSlash.tsx @@ -4,7 +4,7 @@ const SvgCoinSlash = (props: SVGProps) => ( - + diff --git a/suite-common/icons/cryptoAssets/cryptoIcons/rhc.svg b/suite-common/icons/cryptoAssets/cryptoIcons/rhc.svg index 13ff862824a0..0ac4dde9d7de 100644 --- a/suite-common/icons/cryptoAssets/cryptoIcons/rhc.svg +++ b/suite-common/icons/cryptoAssets/cryptoIcons/rhc.svg @@ -1,8 +1,4 @@ - - + + diff --git a/suite-common/icons/cryptoAssets/networkIcons/hype.svg b/suite-common/icons/cryptoAssets/networkIcons/hype.svg index ec919dea6e87..375cf6b3f4ec 100644 --- a/suite-common/icons/cryptoAssets/networkIcons/hype.svg +++ b/suite-common/icons/cryptoAssets/networkIcons/hype.svg @@ -1,7 +1,3 @@ - + diff --git a/suite-common/icons/cryptoAssets/networkIcons/rhc.svg b/suite-common/icons/cryptoAssets/networkIcons/rhc.svg index 2a9e79aafd61..d14a702ca487 100644 --- a/suite-common/icons/cryptoAssets/networkIcons/rhc.svg +++ b/suite-common/icons/cryptoAssets/networkIcons/rhc.svg @@ -1,7 +1,3 @@ - + diff --git a/suite-common/icons/generateIconFont.ts b/suite-common/icons/generateIconFont.ts index 55760cd0a2ba..8972c05c814e 100644 --- a/suite-common/icons/generateIconFont.ts +++ b/suite-common/icons/generateIconFont.ts @@ -79,6 +79,7 @@ const usedIcons = [ 'devicesFilled', 'discover', 'discoverFilled', + 'downloadSimple', 'eject', 'ejectSimple', 'everstakeLogo', diff --git a/suite-common/icons/iconFontsMobile/TrezorSuiteIcons.json b/suite-common/icons/iconFontsMobile/TrezorSuiteIcons.json index e4771a04d6e0..261d1ca4abb4 100644 --- a/suite-common/icons/iconFontsMobile/TrezorSuiteIcons.json +++ b/suite-common/icons/iconFontsMobile/TrezorSuiteIcons.json @@ -127,76 +127,77 @@ "everstakeLogo": 61822, "ejectSimple": 61823, "eject": 61824, - "discoverFilled": 61825, - "discover": 61826, - "devicesFilled": 61827, - "devices": 61828, - "deviceMobileCamera": 61829, - "detective": 61830, - "database": 61831, - "currencyBtc": 61832, - "cube": 61833, - "creditCard": 61834, - "cpu": 61835, - "copy": 61836, - "coins": 61837, - "coinVerticalCheck": 61838, - "coinSlash": 61839, - "coin": 61840, - "code": 61841, - "clockClockwise": 61842, - "clock": 61843, - "clipboard": 61844, - "circuitry": 61845, - "circleDashed": 61846, - "checks": 61847, - "checkCircleFilled": 61848, - "checkCircle": 61849, - "check": 61850, - "chatsTeardrop": 61851, - "chatCircle": 61852, - "change": 61853, - "caretUpFilled": 61854, - "caretUpDown": 61855, - "caretUp": 61856, - "caretRight": 61857, - "caretLeft": 61858, - "caretDownFilled": 61859, - "caretDown": 61860, - "caretCircleRight": 61861, - "cameraSlash": 61862, - "calendarBlank": 61863, - "calendar": 61864, - "cableUsbC": 61865, - "bugBeetle": 61866, - "browsers": 61867, - "browser": 61868, - "broadcast": 61869, - "bookmarkSimple": 61870, - "bluetoothSlash": 61871, - "bluetoothConnected": 61872, - "bluetooth": 61873, - "bank": 61874, - "backspace": 61875, - "atom": 61876, - "article": 61877, - "arrowsLeftRight": 61878, - "arrowsDownUp": 61879, - "arrowsCounterClockwise": 61880, - "arrowsClockwise": 61881, - "arrowUpRight": 61882, - "arrowUp": 61883, - "arrowUUpLeft": 61884, - "arrowURightDown": 61885, - "arrowSquareOut": 61886, - "arrowRight": 61887, - "arrowLineUpRight": 61888, - "arrowLineUp": 61889, - "arrowLineDown": 61890, - "arrowLeft": 61891, - "arrowFatLinesRight": 61892, - "arrowDownLeft": 61893, - "arrowDown": 61894, - "arrowCounterClockwise": 61895, - "arrowBendRightUp": 61896 + "downloadSimple": 61825, + "discoverFilled": 61826, + "discover": 61827, + "devicesFilled": 61828, + "devices": 61829, + "deviceMobileCamera": 61830, + "detective": 61831, + "database": 61832, + "currencyBtc": 61833, + "cube": 61834, + "creditCard": 61835, + "cpu": 61836, + "copy": 61837, + "coins": 61838, + "coinVerticalCheck": 61839, + "coinSlash": 61840, + "coin": 61841, + "code": 61842, + "clockClockwise": 61843, + "clock": 61844, + "clipboard": 61845, + "circuitry": 61846, + "circleDashed": 61847, + "checks": 61848, + "checkCircleFilled": 61849, + "checkCircle": 61850, + "check": 61851, + "chatsTeardrop": 61852, + "chatCircle": 61853, + "change": 61854, + "caretUpFilled": 61855, + "caretUpDown": 61856, + "caretUp": 61857, + "caretRight": 61858, + "caretLeft": 61859, + "caretDownFilled": 61860, + "caretDown": 61861, + "caretCircleRight": 61862, + "cameraSlash": 61863, + "calendarBlank": 61864, + "calendar": 61865, + "cableUsbC": 61866, + "bugBeetle": 61867, + "browsers": 61868, + "browser": 61869, + "broadcast": 61870, + "bookmarkSimple": 61871, + "bluetoothSlash": 61872, + "bluetoothConnected": 61873, + "bluetooth": 61874, + "bank": 61875, + "backspace": 61876, + "atom": 61877, + "article": 61878, + "arrowsLeftRight": 61879, + "arrowsDownUp": 61880, + "arrowsCounterClockwise": 61881, + "arrowsClockwise": 61882, + "arrowUpRight": 61883, + "arrowUp": 61884, + "arrowUUpLeft": 61885, + "arrowURightDown": 61886, + "arrowSquareOut": 61887, + "arrowRight": 61888, + "arrowLineUpRight": 61889, + "arrowLineUp": 61890, + "arrowLineDown": 61891, + "arrowLeft": 61892, + "arrowFatLinesRight": 61893, + "arrowDownLeft": 61894, + "arrowDown": 61895, + "arrowCounterClockwise": 61896, + "arrowBendRightUp": 61897 } diff --git a/suite-common/icons/iconFontsMobile/TrezorSuiteIcons.ttf b/suite-common/icons/iconFontsMobile/TrezorSuiteIcons.ttf index 0fb88969e6ca..92aeacf44964 100644 Binary files a/suite-common/icons/iconFontsMobile/TrezorSuiteIcons.ttf and b/suite-common/icons/iconFontsMobile/TrezorSuiteIcons.ttf differ diff --git a/suite-common/trading/src/index.ts b/suite-common/trading/src/index.ts index 218f6b0cf377..bfd4e820718e 100644 --- a/suite-common/trading/src/index.ts +++ b/suite-common/trading/src/index.ts @@ -42,6 +42,7 @@ export * from './utils/tradingAccountUtils'; export * from './utils/buy/buyUtils'; export * from './utils/receiveAccountUtils'; export * from './utils/tradeOperationUtils'; +export * from './utils/tradeHistoryExportUtils'; export * from './utils/exchange/composeDexTxSimulationAction'; export * from './utils/exchange/exchangeUtils'; export * from './utils/exchange/getExchangeIssue'; diff --git a/suite-common/trading/src/utils/tradeHistoryExportUtils.test.ts b/suite-common/trading/src/utils/tradeHistoryExportUtils.test.ts new file mode 100644 index 000000000000..b4c2301c065f --- /dev/null +++ b/suite-common/trading/src/utils/tradeHistoryExportUtils.test.ts @@ -0,0 +1,309 @@ +import { type BuyTrade, type Coins, type CryptoId, type SellFiatTrade } from 'invity-api'; + +import coins from '../__fixtures__/coins.json'; +import { type TradingRootState, initialState } from '../reducers/tradingCommonReducer'; +import { + type TradingTransaction, + type TradingTransactionBuy, + type TradingTransactionExchange, + type TradingTransactionSell, +} from '../types'; +import { + TRADING_HISTORY_CSV_COLUMNS, + type TradingHistoryCsvColumnLabels, + buildTradingHistoryCsv, + getTradingHistoryCsvRow, + getTradingHistoryCsvType, + prepareTradingHistoryCsv, + sanitizeTradingCsvValue, +} from './tradeHistoryExportUtils'; + +const labels: TradingHistoryCsvColumnLabels = { + orderId: 'Trade ID', + date: 'Date and time', + type: 'Type', + spentAmount: 'Spent amount', + spendTicker: 'Spend ticker', + spendNetwork: 'Spend network', + spendTransactionId: 'Spend transaction ID', + receiveAmount: 'Receive amount', + receiveTicker: 'Receive ticker', + receiveNetwork: 'Receive network', + provider: 'Provider', + status: 'Status', + receiveTransactionId: 'Receive transaction ID', + paymentId: 'Payment ID', +}; + +const resolvers = { + getCoinSymbol: (cryptoId: CryptoId) => + ( + ({ + bitcoin: 'BTC', + ethereum: 'ETH', + 'ethereum--0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': 'USDC', + }) as Record + )[cryptoId], + getProviderName: (name: string | undefined) => + name === 'btcdirect-sell' ? 'BTC Direct' : name, +}; + +const CSV_BOM = ''; + +const buyTrade: TradingTransactionBuy = { + tradeType: 'buy', + date: '2025-04-10T20:21:25.042Z', + key: 'buy-key', + data: { + orderId: 'buy-order', + exchange: 'mercuryo', + fiatCurrency: 'USD', + fiatStringAmount: '1234', + receiveCurrency: 'ethereum' as CryptoId, + receiveStringAmount: '0.462586', + receiveTxHash: 'buy-receive-hash', + paymentId: 'buy-payment', + status: 'SUCCESS', + } as BuyTrade, + receiveAccountKey: undefined, + selectedAccountKey: undefined, +}; + +const sellTrade: TradingTransactionSell = { + tradeType: 'sell', + date: '2025-01-01T20:12:25.042Z', + key: 'sell-key', + data: { + orderId: 'sell-order', + exchange: 'btcdirect-sell', + cryptoCurrency: 'bitcoin' as CryptoId, + cryptoStringAmount: '1.22', + fiatCurrency: 'USD', + fiatStringAmount: '100', + txid: 'sell-send-hash', + paymentId: 'sell-payment', + status: 'SUCCESS', + } as SellFiatTrade, + sendAccountKey: undefined, +}; + +const exchangeTrade: TradingTransactionExchange = { + tradeType: 'exchange', + date: '2025-02-12T20:11:03.042Z', + key: 'exchange-key', + data: { + orderId: 'exchange-order', + exchange: 'changelly', + send: 'ethereum--0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CryptoId, + sendStringAmount: '10.1232', + receive: 'bitcoin' as CryptoId, + receiveStringAmount: '0.462586', + receiveTxHash: 'exchange-receive-hash', + status: 'SUCCESS', + }, + sendAccountKey: undefined, + receiveAccountKey: undefined, +}; + +describe('tradeHistoryExportUtils', () => { + describe('sanitizeTradingCsvValue', () => { + it('returns plain values unchanged', () => { + expect(sanitizeTradingCsvValue('abc')).toBe('abc'); + }); + + it('wraps values containing the separator', () => { + expect(sanitizeTradingCsvValue('a,b')).toBe('"a,b"'); + }); + + it('escapes and wraps values containing quotes', () => { + expect(sanitizeTradingCsvValue('a"b')).toBe('"a""b"'); + }); + + it('wraps values containing newlines', () => { + expect(sanitizeTradingCsvValue('a\nb')).toBe('"a\nb"'); + }); + + it('neutralizes formula-injection prefixes', () => { + expect(sanitizeTradingCsvValue('=cmd')).toBe("'=cmd"); + expect(sanitizeTradingCsvValue('@SUM(1)')).toBe("'@SUM(1)"); + }); + + it('neutralizes and wraps a formula prefix that also contains a separator', () => { + expect(sanitizeTradingCsvValue('=a,b')).toBe('"\'=a,b"'); + }); + }); + + describe('getTradingHistoryCsvType', () => { + it.each([ + ['buy', 'buy'], + ['sell', 'sell'], + ['exchange', 'swap'], + ] as const)('maps %s to %s', (tradeType, expected) => { + expect(getTradingHistoryCsvType(tradeType)).toBe(expected); + }); + }); + + describe('getTradingHistoryCsvRow', () => { + it('maps a buy trade (fiat spent, crypto received, no spend tx)', () => { + expect(getTradingHistoryCsvRow(buyTrade, resolvers)).toEqual({ + orderId: 'buy-order', + date: '2025-04-10T20:21:25.042Z', + type: 'buy', + spentAmount: '1234', + spendTicker: 'USD', + spendNetwork: '', + spendTransactionId: '', + receiveAmount: '0.462586', + receiveTicker: 'ETH', + receiveNetwork: 'Ethereum', + provider: 'mercuryo', + status: 'SUCCESS', + receiveTransactionId: 'buy-receive-hash', + paymentId: 'buy-payment', + }); + }); + + it('maps a sell trade (crypto spent, fiat received, no receive tx)', () => { + expect(getTradingHistoryCsvRow(sellTrade, resolvers)).toEqual({ + orderId: 'sell-order', + date: '2025-01-01T20:12:25.042Z', + type: 'sell', + spentAmount: '1.22', + spendTicker: 'BTC', + spendNetwork: 'Bitcoin', + spendTransactionId: 'sell-send-hash', + receiveAmount: '100', + receiveTicker: 'USD', + receiveNetwork: '', + provider: 'BTC Direct', + status: 'SUCCESS', + receiveTransactionId: '', + paymentId: 'sell-payment', + }); + }); + + it('maps an exchange trade to swap (crypto-to-crypto, no payment id)', () => { + expect(getTradingHistoryCsvRow(exchangeTrade, resolvers)).toEqual({ + orderId: 'exchange-order', + date: '2025-02-12T20:11:03.042Z', + type: 'swap', + spentAmount: '10.1232', + spendTicker: 'USDC', + spendNetwork: 'Ethereum', + spendTransactionId: '', + receiveAmount: '0.462586', + receiveTicker: 'BTC', + receiveNetwork: 'Bitcoin', + provider: 'changelly', + status: 'SUCCESS', + receiveTransactionId: 'exchange-receive-hash', + paymentId: '', + }); + }); + + it('falls back to the raw crypto id when the ticker cannot be resolved', () => { + const trade: TradingTransactionExchange = { + ...exchangeTrade, + data: { ...exchangeTrade.data, send: 'unknown-coin' as CryptoId }, + }; + + expect(getTradingHistoryCsvRow(trade, resolvers).spendTicker).toBe('unknown-coin'); + }); + + it('uses empty strings for missing optional values', () => { + const trade: TradingTransactionExchange = { + tradeType: 'exchange', + date: '2025-02-12T20:11:03.042Z', + key: 'exchange-key', + data: { + orderId: 'only-order', + sendStringAmount: undefined, + receiveStringAmount: undefined, + }, + sendAccountKey: undefined, + receiveAccountKey: undefined, + }; + + expect(getTradingHistoryCsvRow(trade, resolvers)).toEqual({ + orderId: 'only-order', + date: '2025-02-12T20:11:03.042Z', + type: 'swap', + spentAmount: '', + spendTicker: '', + spendNetwork: '', + spendTransactionId: '', + receiveAmount: '', + receiveTicker: '', + receiveNetwork: '', + provider: '', + status: '', + receiveTransactionId: '', + paymentId: '', + }); + }); + }); + + describe('buildTradingHistoryCsv', () => { + const header = TRADING_HISTORY_CSV_COLUMNS.map(column => labels[column]).join(','); + + it('returns only the header for an empty trade list', () => { + expect(buildTradingHistoryCsv(labels)([], resolvers)).toBe(CSV_BOM + header); + }); + + it('builds a header plus one line per trade', () => { + const trades: TradingTransaction[] = [buyTrade, sellTrade, exchangeTrade]; + const csv = buildTradingHistoryCsv(labels)(trades, resolvers); + const lines = csv.split('\n'); + + expect(lines).toHaveLength(4); + expect(lines[0]).toBe(CSV_BOM + header); + expect(lines[1]).toBe( + 'buy-order,2025-04-10T20:21:25.042Z,buy,1234,USD,,,0.462586,ETH,Ethereum,mercuryo,SUCCESS,buy-receive-hash,buy-payment', + ); + }); + + it('uses the injected translated labels for the header row', () => { + const czLabels = Object.fromEntries( + TRADING_HISTORY_CSV_COLUMNS.map(column => [column, `cs:${column}`]), + ) as TradingHistoryCsvColumnLabels; + + const [headerRow] = buildTradingHistoryCsv(czLabels)([], resolvers).split('\n'); + + expect(headerRow).toBe( + CSV_BOM + TRADING_HISTORY_CSV_COLUMNS.map(column => `cs:${column}`).join(','), + ); + }); + + it('sanitizes values that contain the separator', () => { + const trade: TradingTransactionBuy = { + ...buyTrade, + data: { ...buyTrade.data, orderId: 'a,b' } as BuyTrade, + }; + const csv = buildTradingHistoryCsv(labels)([trade], resolvers); + const [, row] = csv.split('\n'); + + expect(row?.startsWith('"a,b",')).toBe(true); + }); + }); + + describe('prepareTradingHistoryCsv', () => { + const state: TradingRootState = { + wallet: { + trading: { + ...initialState, + info: { ...initialState.info, coins: coins as unknown as Coins }, + }, + }, + }; + + it('resolves coin tickers from state and falls back to the raw provider name', () => { + const csv = prepareTradingHistoryCsv(labels)(state, [sellTrade]); + const [, row] = csv.split('\n'); + + // BTC ticker resolved from state coins, provider name falls back to the raw exchange id. + expect(row).toBe( + 'sell-order,2025-01-01T20:12:25.042Z,sell,1.22,BTC,Bitcoin,sell-send-hash,100,USD,,btcdirect-sell,SUCCESS,,sell-payment', + ); + }); + }); +}); diff --git a/suite-common/trading/src/utils/tradeHistoryExportUtils.ts b/suite-common/trading/src/utils/tradeHistoryExportUtils.ts new file mode 100644 index 000000000000..31c2ed8730e4 --- /dev/null +++ b/suite-common/trading/src/utils/tradeHistoryExportUtils.ts @@ -0,0 +1,132 @@ +import { type CryptoId } from 'invity-api'; + +import { type TradingRootState } from '../reducers/tradingCommonReducer'; +import { + selectTradingCoinSymbolByCryptoId, + selectTradingProviderCompanyName, +} from '../selectors/tradingSelectors'; +import { type TradingTransaction, type TradingType } from '../types'; +import { cryptoIdToNetwork, isBuyTrade, isExchangeTrade, isSellFiatTrade } from '../utils'; +import { getTradeOperationData } from './tradeOperationUtils'; + +const CSV_NEWLINE = '\n'; +const CSV_SEPARATOR = ','; +const CSV_LEADING_CHARACTERS_TO_ESCAPE_REGEX = /^[\s\uFEFF]*[=+\-@=+-@]/u; +const CSV_BOM = '\uFEFF'; + +export const sanitizeTradingCsvValue = (value: string): string => { + const sanitizedValue = CSV_LEADING_CHARACTERS_TO_ESCAPE_REGEX.test(value) ? `'${value}` : value; + + if ( + sanitizedValue.includes(CSV_SEPARATOR) || + sanitizedValue.includes('"') || + sanitizedValue.includes('\n') || + sanitizedValue.includes('\r') + ) { + return `"${sanitizedValue.replace(/"/g, '""')}"`; + } + + return sanitizedValue; +}; + +export const TRADING_HISTORY_CSV_COLUMNS = [ + 'orderId', + 'date', + 'type', + 'spentAmount', + 'spendTicker', + 'spendNetwork', + 'spendTransactionId', + 'receiveAmount', + 'receiveTicker', + 'receiveNetwork', + 'provider', + 'status', + 'receiveTransactionId', + 'paymentId', +] as const; + +export type TradingHistoryCsvColumn = (typeof TRADING_HISTORY_CSV_COLUMNS)[number]; +export type TradingHistoryCsvColumnLabels = Record; +export type TradingHistoryCsvRow = Record; + +type TradingHistoryCsvResolvers = { + getCoinSymbol: (cryptoId: CryptoId) => string | undefined; + getProviderName: (name: string | undefined, tradeType: TradingType) => string | undefined; +}; + +export const getTradingHistoryCsvType = (tradeType: TradingType): string => + tradeType === 'exchange' ? 'swap' : tradeType; + +export const getTradingHistoryCsvRow = ( + trade: TradingTransaction, + { getCoinSymbol, getProviderName }: TradingHistoryCsvResolvers, +): TradingHistoryCsvRow => { + const { tradeType, data } = trade; + const { fromValue, fromCurrency, toValue, toCurrency, isFromCrypto, isToCrypto } = + getTradeOperationData(data); + + const resolveTicker = ( + currency: string | CryptoId | undefined, + isCrypto: boolean | undefined, + ) => { + if (!currency) { + return ''; + } + + return isCrypto ? (getCoinSymbol(currency as CryptoId) ?? currency) : currency; + }; + + const resolveNetwork = ( + currency: string | CryptoId | undefined, + isCrypto: boolean | undefined, + ) => (isCrypto && currency ? (cryptoIdToNetwork(currency as CryptoId)?.name ?? '') : ''); + + const spendTransactionId = isSellFiatTrade(data) ? (data.txid ?? '') : ''; + const receiveTransactionId = + isBuyTrade(data) || isExchangeTrade(data) ? (data.receiveTxHash ?? '') : ''; + + return { + orderId: data.orderId ?? '', + date: trade.date ?? '', + type: getTradingHistoryCsvType(tradeType), + spentAmount: fromValue ?? '', + spendTicker: resolveTicker(fromCurrency, isFromCrypto), + spendNetwork: resolveNetwork(fromCurrency, isFromCrypto), + spendTransactionId, + receiveAmount: toValue ?? '', + receiveTicker: resolveTicker(toCurrency, isToCrypto), + receiveNetwork: resolveNetwork(toCurrency, isToCrypto), + provider: getProviderName(data.exchange, tradeType) ?? '', + status: data.status ?? '', + receiveTransactionId, + paymentId: 'paymentId' in data ? (data.paymentId ?? '') : '', + }; +}; + +export const buildTradingHistoryCsv = + (labels: TradingHistoryCsvColumnLabels) => + (trades: TradingTransaction[], resolvers: TradingHistoryCsvResolvers): string => { + const header = TRADING_HISTORY_CSV_COLUMNS.map(column => + sanitizeTradingCsvValue(labels[column]), + ).join(CSV_SEPARATOR); + + const rows = trades.map(trade => { + const row = getTradingHistoryCsvRow(trade, resolvers); + + return TRADING_HISTORY_CSV_COLUMNS.map(column => + sanitizeTradingCsvValue(row[column]), + ).join(CSV_SEPARATOR); + }); + + return CSV_BOM + [header, ...rows].join(CSV_NEWLINE); + }; + +export const prepareTradingHistoryCsv = + (labels: TradingHistoryCsvColumnLabels) => + (state: TradingRootState, trades: TradingTransaction[]): string => + buildTradingHistoryCsv(labels)(trades, { + getCoinSymbol: cryptoId => selectTradingCoinSymbolByCryptoId(state, cryptoId), + getProviderName: (name, tradeType) => + selectTradingProviderCompanyName(state, name, tradeType), + }); diff --git a/suite-native/bip329/jest.config.js b/suite-native/bip329/jest.config.js new file mode 100644 index 000000000000..3a43f58155f3 --- /dev/null +++ b/suite-native/bip329/jest.config.js @@ -0,0 +1,11 @@ +/** + * Jest configuration for native packages. + * Keeping this file next to the package.json file instead of providing configuration + * with `-c ../../jest.config.native` option in package.json scripts + * allows us to run jest tests directly from IDEs. + */ +const baseConfig = require('../../jest.config.native'); + +module.exports = { + ...baseConfig, +}; diff --git a/suite-native/bip329/package.json b/suite-native/bip329/package.json index 2cf7c7b3e4c6..cad773d3bd36 100644 --- a/suite-native/bip329/package.json +++ b/suite-native/bip329/package.json @@ -8,7 +8,9 @@ "scripts": { "depcheck": "yarn g:depcheck", "type-check": "yarn g:tsc --build", - "lint:js": "yarn g:eslint '**/*.{ts,tsx,js}'" + "lint:js": "yarn g:eslint '**/*.{ts,tsx,js}'", + "test:unit": "yarn g:jest:native", + "test:unit:watch": "yarn g:jest --watch" }, "dependencies": { "@suite-common/bip329": "workspace:*", diff --git a/suite-native/bip329/src/Bip329ExportButton.tsx b/suite-native/bip329/src/Bip329ExportButton.tsx index fd06a027327c..1b93809f87fc 100644 --- a/suite-native/bip329/src/Bip329ExportButton.tsx +++ b/suite-native/bip329/src/Bip329ExportButton.tsx @@ -54,6 +54,9 @@ export const Bip329ExportButton = ({ } switch (result.reason) { + case 'cancelled': + // User dismissed the directory picker; nothing to report. + return; case 'exportFailed': showToast({ intent: 'critical', diff --git a/suite-native/bip329/src/exportBip329.test.ts b/suite-native/bip329/src/exportBip329.test.ts new file mode 100644 index 000000000000..ad13f0b4c5aa --- /dev/null +++ b/suite-native/bip329/src/exportBip329.test.ts @@ -0,0 +1,114 @@ +import { Platform } from 'react-native'; + +import { Directory } from 'expo-file-system'; +import * as Sharing from 'expo-sharing'; + +import { type AllLabelsForAccount } from '@suite-common/suite-sync'; + +import { exportBip329 } from './exportBip329'; + +const mockDirectoryWrite = jest.fn(); +const mockCreateFile = jest.fn(() => ({ write: mockDirectoryWrite })); + +jest.mock('expo-file-system', () => ({ + File: jest.fn().mockImplementation(() => ({ + exists: false, + create: jest.fn(), + write: jest.fn(), + uri: 'file:///cache/account_labels.jsonl', + })), + Paths: { cache: 'file:///cache' }, + Directory: { + pickDirectoryAsync: jest.fn(), + }, +})); + +jest.mock('expo-sharing', () => ({ + shareAsync: jest.fn(), +})); + +const mockPickDirectoryAsync = jest.mocked(Directory.pickDirectoryAsync); +const mockShareAsync = jest.mocked(Sharing.shareAsync); + +const originalPlatformOS = Platform.OS; +const setPlatformOS = (os: typeof Platform.OS) => { + Platform.OS = os; +}; + +const LABELS: AllLabelsForAccount = { accountLabel: null, addressLabels: [], outputLabels: [] }; + +describe('exportBip329', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + setPlatformOS(originalPlatformOS); + }); + + describe('on android', () => { + beforeEach(() => { + setPlatformOS('android'); + }); + + it('writes the file into the picked directory and returns success', async () => { + mockPickDirectoryAsync.mockResolvedValue({ createFile: mockCreateFile } as never); + + const result = await exportBip329(null, LABELS); + + expect(result).toEqual({ success: true }); + expect(mockCreateFile).toHaveBeenCalledTimes(1); + expect(mockDirectoryWrite).toHaveBeenCalledTimes(1); + }); + + it('returns the cancelled reason when the user dismisses the directory picker', async () => { + mockPickDirectoryAsync.mockRejectedValue( + Object.assign(new Error('The file picker was cancelled by the user'), { + code: 'ERR_PICKER_CANCELLED', + }), + ); + + const result = await exportBip329(null, LABELS); + + expect(result).toEqual({ success: false, reason: 'cancelled' }); + }); + + it('returns the exportFailed reason for any other picker error', async () => { + mockPickDirectoryAsync.mockRejectedValue(new Error('boom')); + + const result = await exportBip329(null, LABELS); + + expect(result).toEqual({ success: false, reason: 'exportFailed' }); + }); + }); + + describe('on ios', () => { + beforeEach(() => { + setPlatformOS('ios'); + }); + + it('shares the cached file and returns success', async () => { + mockShareAsync.mockResolvedValue(undefined); + + const result = await exportBip329(null, LABELS); + + expect(result).toEqual({ success: true }); + expect(mockShareAsync).toHaveBeenCalledWith( + 'file:///cache/account_labels.jsonl', + expect.objectContaining({ UTI: 'public.jsonl' }), + ); + }); + }); + + describe('on an unsupported platform', () => { + beforeEach(() => { + setPlatformOS('web'); + }); + + it('returns the fileSavingNotSupported reason', async () => { + const result = await exportBip329(null, LABELS); + + expect(result).toEqual({ success: false, reason: 'fileSavingNotSupported' }); + }); + }); +}); diff --git a/suite-native/bip329/src/exportBip329.ts b/suite-native/bip329/src/exportBip329.ts index 8d2ffd4d657c..cdd2f8ab8ffe 100644 --- a/suite-native/bip329/src/exportBip329.ts +++ b/suite-native/bip329/src/exportBip329.ts @@ -9,7 +9,9 @@ import { sanitizeFilename } from '@trezor/utils'; type ExportBip329Result = | { success: true } - | { success: false; reason: 'fileSavingNotSupported' | 'exportFailed' }; + | { success: false; reason: 'fileSavingNotSupported' | 'exportFailed' | 'cancelled' }; + +const PICKER_CANCELLED_ERROR_CODE = 'ERR_PICKER_CANCELLED'; const createJsonlContent = (labels: AllLabelsForAccount): string => { const labelsToExport = suiteSyncToBip329({ @@ -39,7 +41,6 @@ const saveFile = async (fileName: string, content: string): Promise => { newFile.write(content); } else if (Platform.OS === 'ios') { await Sharing.shareAsync(cachedFile.uri, { - mimeType: 'application/jsonl', UTI: 'public.jsonl', }); } else { @@ -61,6 +62,10 @@ export const exportBip329 = async ( return { success: false, reason: 'fileSavingNotSupported' }; } + if (error?.code === PICKER_CANCELLED_ERROR_CODE) { + return { success: false, reason: 'cancelled' }; + } + return { success: false, reason: 'exportFailed' }; } diff --git a/suite-native/intl/src/messages.ts b/suite-native/intl/src/messages.ts index 7718a5296c4d..8dd4209d8467 100644 --- a/suite-native/intl/src/messages.ts +++ b/suite-native/intl/src/messages.ts @@ -3738,6 +3738,29 @@ export const messages = { description: 'Your trades will appear here and you can track their status.', button: 'Back to trade form', }, + export: { + button: 'Export past trades', + confirmButton: 'Download CSV', + exportSuccessfulToast: 'Trade history exported.', + exportFailedToast: 'Trade history export failed.', + fileSavingNotSupportedToast: 'Saving files is not supported on this device.', + columns: { + orderId: 'Trade ID', + date: 'Date and time', + type: 'Type', + spentAmount: 'Spent amount', + spendTicker: 'Spend ticker', + spendNetwork: 'Spend network', + spendTransactionId: 'Spend transaction ID', + receiveAmount: 'Receive amount', + receiveTicker: 'Receive ticker', + receiveNetwork: 'Receive network', + provider: 'Provider', + status: 'Status', + receiveTransactionId: 'Receive transaction ID', + paymentId: 'Payment ID', + }, + }, statusIcon: { success: 'Successful trade', error: 'Failed trade', diff --git a/suite-native/module-trading/src/screens/TradingHistoryScreen.tsx b/suite-native/module-trading/src/screens/TradingHistoryScreen.tsx index 7a57c7e75d6d..616e1505ae92 100644 --- a/suite-native/module-trading/src/screens/TradingHistoryScreen.tsx +++ b/suite-native/module-trading/src/screens/TradingHistoryScreen.tsx @@ -1,6 +1,6 @@ import { Translation } from '@suite-native/intl'; import { Screen, ScreenHeader } from '@suite-native/navigation'; -import { TradingHistory } from '@suite-native/trading-history'; +import { TradingHistory, TradingHistoryExportButton } from '@suite-native/trading-history'; export const TradingHistoryScreen = () => ( ( } closeActionType="back" + rightIcon={} /> } isScrollable={false} diff --git a/suite-native/trading-history/package.json b/suite-native/trading-history/package.json index 6c49d7dc9984..f364c23ea28b 100644 --- a/suite-native/trading-history/package.json +++ b/suite-native/trading-history/package.json @@ -21,6 +21,7 @@ "@suite-common/trading": "workspace:*", "@suite-common/wallet-config": "workspace:*", "@suite-common/wallet-core": "workspace:*", + "@suite-native/alerts": "workspace:*", "@suite-native/atoms": "workspace:*", "@suite-native/clipboard": "workspace:*", "@suite-native/device": "workspace:*", @@ -28,6 +29,7 @@ "@suite-native/intl": "workspace:*", "@suite-native/labeling": "workspace:*", "@suite-native/link": "workspace:*", + "@suite-native/toasts": "workspace:*", "@suite-native/trading-atoms": "workspace:*", "@suite-native/trading-browser-auth": "workspace:*", "@suite-native/trading-provider-utils": "workspace:*", @@ -35,6 +37,8 @@ "@suite-native/trading-state": "workspace:*", "@trezor/styles-native": "workspace:*", "@trezor/type-utils": "workspace:*", + "expo-file-system": "~56.0.7", + "expo-sharing": "~56.0.18", "react": "19.2.3", "react-native": "0.85.3", "react-native-safe-area-context": "5.6.2", diff --git a/suite-native/trading-history/src/components/TradingHistoryExportButton.test.tsx b/suite-native/trading-history/src/components/TradingHistoryExportButton.test.tsx new file mode 100644 index 000000000000..9a6e1256e16c --- /dev/null +++ b/suite-native/trading-history/src/components/TradingHistoryExportButton.test.tsx @@ -0,0 +1,164 @@ +import { act, fireEvent, screen } from '@suite-native/test-utils-store'; +import { accounts, getBuyTrade } from '@suite-native/trading-fixtures'; + +import { exportTradingHistoryCsv } from '../exportTradingHistoryCsv'; +import { TradingHistoryExportButton } from './TradingHistoryExportButton'; +import { + type PreloadedStatePartial, + type TradingTestPreloadedState, + renderWithTradingHistoryProvider, +} from '../test-utils/tradingHistoryTestUtils'; + +const mockShowAlert = jest.fn(); +const mockHideAlert = jest.fn(); +const mockShowToast = jest.fn(); + +jest.mock('@suite-native/alerts', () => ({ + useAlert: () => ({ showAlert: mockShowAlert, hideAlert: mockHideAlert }), +})); + +jest.mock('@suite-native/toasts', () => ({ + ...jest.requireActual('@suite-native/toasts'), + useToast: () => ({ showToast: mockShowToast }), +})); + +jest.mock('../exportTradingHistoryCsv', () => ({ + exportTradingHistoryCsv: jest.fn(), +})); + +const mockExportTradingHistoryCsv = jest.mocked(exportTradingHistoryCsv); + +const getOverrides = ( + trades: TradingTestPreloadedState['wallet']['trading']['trades'], +): PreloadedStatePartial => ({ + device: { + devices: [], + selectedDevice: { + state: { + staticSessionId: '1@2:3', + }, + }, + }, + wallet: { + trading: { + trades, + }, + accounts, + }, +}); + +describe('TradingHistoryExportButton', () => { + const renderExportButton = ({ + trades = [getBuyTrade({ status: 'SUCCESS' })], + }: { + trades?: TradingTestPreloadedState['wallet']['trading']['trades']; + } = {}) => + renderWithTradingHistoryProvider(, { + overrides: getOverrides(trades), + }); + + const confirmExport = async () => { + fireEvent.press(screen.getByTestId('@trading/history/export-button')); + await act(() => mockShowAlert.mock.calls[0]![0].onPressPrimaryButton()); + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockExportTradingHistoryCsv.mockResolvedValue({ success: true }); + }); + + it('renders nothing when the device has no trades', () => { + renderExportButton({ trades: [] }); + + expect(screen.queryByTestId('@trading/history/export-button')).toBeNull(); + }); + + it('renders the export button when the device has trades', () => { + renderExportButton(); + + expect(screen.getByTestId('@trading/history/export-button')).toBeOnTheScreen(); + }); + + it('opens a confirmation alert instead of exporting immediately', () => { + renderExportButton(); + + fireEvent.press(screen.getByTestId('@trading/history/export-button')); + + expect(mockShowAlert).toHaveBeenCalledTimes(1); + expect(mockExportTradingHistoryCsv).not.toHaveBeenCalled(); + + const alert = mockShowAlert.mock.calls[0]![0]; + expect(alert.pictogramVariant).toBe('success'); + expect(alert.icon).toBe('downloadSimple'); + expect(alert.title.props.id).toBe('moduleTrading.tradeHistory.export.button'); + expect(alert.primaryButtonTitle.props.id).toBe( + 'moduleTrading.tradeHistory.export.confirmButton', + ); + expect(alert.secondaryButtonTitle.props.id).toBe('generic.buttons.cancel'); + expect(alert.onPressSecondaryButton).toBe(mockHideAlert); + }); + + it('exports the CSV and shows a success toast when the download is confirmed', async () => { + renderExportButton(); + + await confirmExport(); + + expect(mockExportTradingHistoryCsv).toHaveBeenCalledTimes(1); + const [csvContent] = mockExportTradingHistoryCsv.mock.calls[0]!; + expect(csvContent.split('\n')).toHaveLength(2); + + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ intent: 'neutral', icon: 'check' }), + ); + expect(mockShowToast.mock.calls[0]![0].message.props.id).toBe( + 'moduleTrading.tradeHistory.export.exportSuccessfulToast', + ); + }); + + it('shows a critical toast when the export fails', async () => { + mockExportTradingHistoryCsv.mockResolvedValue({ success: false, reason: 'exportFailed' }); + renderExportButton(); + + await confirmExport(); + + expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ intent: 'critical' })); + expect(mockShowToast.mock.calls[0]![0].message.props.id).toBe( + 'moduleTrading.tradeHistory.export.exportFailedToast', + ); + }); + + it('shows a critical toast when file saving is not supported', async () => { + mockExportTradingHistoryCsv.mockResolvedValue({ + success: false, + reason: 'fileSavingNotSupported', + }); + renderExportButton(); + + await confirmExport(); + + expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ intent: 'critical' })); + expect(mockShowToast.mock.calls[0]![0].message.props.id).toBe( + 'moduleTrading.tradeHistory.export.fileSavingNotSupportedToast', + ); + }); + + it('shows no toast when the export is cancelled by dismissing the directory picker', async () => { + mockExportTradingHistoryCsv.mockResolvedValue({ success: false, reason: 'cancelled' }); + renderExportButton(); + + await confirmExport(); + + expect(mockExportTradingHistoryCsv).toHaveBeenCalledTimes(1); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('dismisses the alert without exporting when cancelled', () => { + renderExportButton(); + + fireEvent.press(screen.getByTestId('@trading/history/export-button')); + mockShowAlert.mock.calls[0]![0].onPressSecondaryButton(); + + expect(mockHideAlert).toHaveBeenCalledTimes(1); + expect(mockExportTradingHistoryCsv).not.toHaveBeenCalled(); + }); +}); diff --git a/suite-native/trading-history/src/components/TradingHistoryExportButton.tsx b/suite-native/trading-history/src/components/TradingHistoryExportButton.tsx new file mode 100644 index 000000000000..7bc191593c86 --- /dev/null +++ b/suite-native/trading-history/src/components/TradingHistoryExportButton.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; +import { useSelector, useStore } from 'react-redux'; + +import { + type TradingRootStateWithDeviceAndAccounts, + prepareTradingHistoryCsv, + selectDeviceHasTradingTrades, + selectDeviceTradingTradesOrderedByDate, +} from '@suite-common/trading'; +import { useAlert } from '@suite-native/alerts'; +import { IconButton } from '@suite-native/atoms'; +import { Translation, useTranslate } from '@suite-native/intl'; +import { useToast } from '@suite-native/toasts'; +import { exhaustive } from '@trezor/type-utils'; + +import { + type ExportTradingHistoryCsvResult, + exportTradingHistoryCsv, +} from '../exportTradingHistoryCsv'; +import { useTradingHistoryCsvColumnLabels } from '../hooks/useTradingHistoryCsvColumnLabels'; + +export const TradingHistoryExportButton = () => { + const { showToast } = useToast(); + const { showAlert, hideAlert } = useAlert(); + const { translate } = useTranslate(); + const store = useStore(); + const [isExporting, setIsExporting] = useState(false); + const hasTrades = useSelector(selectDeviceHasTradingTrades); + const columnLabels = useTradingHistoryCsvColumnLabels(); + + if (!hasTrades) { + return null; + } + + const performExport = async () => { + let result: ExportTradingHistoryCsvResult = { success: false, reason: 'exportFailed' }; + + try { + setIsExporting(true); + + // wait for alert animation to finish before starting the export, 500 ms ought to be enough for anybody. + await new Promise(resolve => setTimeout(resolve, 500)); + + const state = store.getState(); + const trades = selectDeviceTradingTradesOrderedByDate(state); + const csvContent = prepareTradingHistoryCsv(columnLabels)(state, trades); + + result = await exportTradingHistoryCsv(csvContent); + } finally { + setIsExporting(false); + } + + if (result.success) { + showToast({ + intent: 'neutral', + icon: 'check', + message: ( + + ), + }); + + return; + } + + switch (result.reason) { + case 'cancelled': + // User dismissed the directory picker; nothing to report. + return; + case 'exportFailed': + showToast({ + intent: 'critical', + message: ( + + ), + }); + + return; + case 'fileSavingNotSupported': + showToast({ + intent: 'critical', + message: ( + + ), + }); + + return; + default: + exhaustive(result.reason); + } + }; + + const handleExport = () => { + showAlert({ + pictogramVariant: 'success', + icon: 'downloadSimple', + title: , + primaryButtonTitle: ( + + ), + onPressPrimaryButton: performExport, + secondaryButtonTitle: , + onPressSecondaryButton: hideAlert, + }); + }; + + return ( + + ); +}; diff --git a/suite-native/trading-history/src/exportTradingHistoryCsv.test.ts b/suite-native/trading-history/src/exportTradingHistoryCsv.test.ts new file mode 100644 index 000000000000..c0007588105f --- /dev/null +++ b/suite-native/trading-history/src/exportTradingHistoryCsv.test.ts @@ -0,0 +1,112 @@ +import { Platform } from 'react-native'; + +import { Directory } from 'expo-file-system'; +import * as Sharing from 'expo-sharing'; + +import { exportTradingHistoryCsv } from './exportTradingHistoryCsv'; + +const mockDirectoryWrite = jest.fn(); +const mockCreateFile = jest.fn(() => ({ write: mockDirectoryWrite })); + +jest.mock('expo-file-system', () => ({ + File: jest.fn().mockImplementation(() => ({ + exists: false, + create: jest.fn(), + write: jest.fn(), + uri: 'file:///cache/trade-history.csv', + })), + Paths: { cache: 'file:///cache' }, + Directory: { + pickDirectoryAsync: jest.fn(), + }, +})); + +jest.mock('expo-sharing', () => ({ + shareAsync: jest.fn(), +})); + +const mockPickDirectoryAsync = jest.mocked(Directory.pickDirectoryAsync); +const mockShareAsync = jest.mocked(Sharing.shareAsync); + +const originalPlatformOS = Platform.OS; +const setPlatformOS = (os: typeof Platform.OS) => { + Platform.OS = os; +}; + +const CSV_CONTENT = 'a,b,c'; + +describe('exportTradingHistoryCsv', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + setPlatformOS(originalPlatformOS); + }); + + describe('on android', () => { + beforeEach(() => { + setPlatformOS('android'); + }); + + it('writes the file into the picked directory and returns success', async () => { + mockPickDirectoryAsync.mockResolvedValue({ createFile: mockCreateFile } as never); + + const result = await exportTradingHistoryCsv(CSV_CONTENT); + + expect(result).toEqual({ success: true }); + expect(mockCreateFile).toHaveBeenCalledTimes(1); + expect(mockDirectoryWrite).toHaveBeenCalledWith(CSV_CONTENT); + }); + + it('returns the cancelled reason when the user dismisses the directory picker', async () => { + mockPickDirectoryAsync.mockRejectedValue( + Object.assign(new Error('The file picker was cancelled by the user'), { + code: 'ERR_PICKER_CANCELLED', + }), + ); + + const result = await exportTradingHistoryCsv(CSV_CONTENT); + + expect(result).toEqual({ success: false, reason: 'cancelled' }); + }); + + it('returns the exportFailed reason for any other picker error', async () => { + mockPickDirectoryAsync.mockRejectedValue(new Error('boom')); + + const result = await exportTradingHistoryCsv(CSV_CONTENT); + + expect(result).toEqual({ success: false, reason: 'exportFailed' }); + }); + }); + + describe('on ios', () => { + beforeEach(() => { + setPlatformOS('ios'); + }); + + it('shares the cached file and returns success', async () => { + mockShareAsync.mockResolvedValue(undefined); + + const result = await exportTradingHistoryCsv(CSV_CONTENT); + + expect(result).toEqual({ success: true }); + expect(mockShareAsync).toHaveBeenCalledWith( + 'file:///cache/trade-history.csv', + expect.objectContaining({ UTI: 'public.comma-separated-values-text' }), + ); + }); + }); + + describe('on an unsupported platform', () => { + beforeEach(() => { + setPlatformOS('web'); + }); + + it('returns the fileSavingNotSupported reason', async () => { + const result = await exportTradingHistoryCsv(CSV_CONTENT); + + expect(result).toEqual({ success: false, reason: 'fileSavingNotSupported' }); + }); + }); +}); diff --git a/suite-native/trading-history/src/exportTradingHistoryCsv.ts b/suite-native/trading-history/src/exportTradingHistoryCsv.ts new file mode 100644 index 000000000000..635ee17d0d32 --- /dev/null +++ b/suite-native/trading-history/src/exportTradingHistoryCsv.ts @@ -0,0 +1,63 @@ +import { Platform } from 'react-native'; + +import { Directory, File, Paths } from 'expo-file-system'; +import * as Sharing from 'expo-sharing'; + +export type ExportTradingHistoryCsvResult = + | { success: true } + | { success: false; reason: 'fileSavingNotSupported' | 'exportFailed' | 'cancelled' }; + +const CSV_MIME_TYPE = 'text/csv'; + +const PICKER_CANCELLED_ERROR_CODE = 'ERR_PICKER_CANCELLED'; + +const buildFileName = (): string => { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + + return `trade-history-${timestamp}.csv`; +}; + +const saveFile = async (fileName: string, content: string): Promise => { + const cachedFile = new File(Paths.cache, fileName); + + if (cachedFile.exists) { + cachedFile.delete(); + } + + cachedFile.create(); + cachedFile.write(content); + + if (Platform.OS === 'android') { + const dir = await Directory.pickDirectoryAsync(); + const newFile = dir.createFile(fileName, CSV_MIME_TYPE); + newFile.write(content); + } else if (Platform.OS === 'ios') { + await Sharing.shareAsync(cachedFile.uri, { + UTI: 'public.comma-separated-values-text', + }); + } else { + throw new Error('fileSavingNotSupported'); + } +}; + +export const exportTradingHistoryCsv = async ( + csvContent: string, +): Promise => { + const fileName = buildFileName(); + + try { + await saveFile(fileName, csvContent); + } catch (error) { + if (error instanceof Error && error.message === 'fileSavingNotSupported') { + return { success: false, reason: 'fileSavingNotSupported' }; + } + + if (error?.code === PICKER_CANCELLED_ERROR_CODE) { + return { success: false, reason: 'cancelled' }; + } + + return { success: false, reason: 'exportFailed' }; + } + + return { success: true }; +}; diff --git a/suite-native/trading-history/src/hooks/useTradingHistoryCsvColumnLabels.ts b/suite-native/trading-history/src/hooks/useTradingHistoryCsvColumnLabels.ts new file mode 100644 index 000000000000..58fb93480a4c --- /dev/null +++ b/suite-native/trading-history/src/hooks/useTradingHistoryCsvColumnLabels.ts @@ -0,0 +1,59 @@ +import { useMemo } from 'react'; + +import { + TRADING_HISTORY_CSV_COLUMNS, + type TradingHistoryCsvColumn, + type TradingHistoryCsvColumnLabels, +} from '@suite-common/trading'; +import { type Translate, useTranslate } from '@suite-native/intl'; +import { exhaustive } from '@trezor/type-utils'; + +const getColumnLabel = (translate: Translate, column: TradingHistoryCsvColumn): string => { + switch (column) { + case 'orderId': + return translate('moduleTrading.tradeHistory.export.columns.orderId'); + case 'date': + return translate('moduleTrading.tradeHistory.export.columns.date'); + case 'type': + return translate('moduleTrading.tradeHistory.export.columns.type'); + case 'spentAmount': + return translate('moduleTrading.tradeHistory.export.columns.spentAmount'); + case 'spendTicker': + return translate('moduleTrading.tradeHistory.export.columns.spendTicker'); + case 'spendNetwork': + return translate('moduleTrading.tradeHistory.export.columns.spendNetwork'); + case 'spendTransactionId': + return translate('moduleTrading.tradeHistory.export.columns.spendTransactionId'); + case 'receiveAmount': + return translate('moduleTrading.tradeHistory.export.columns.receiveAmount'); + case 'receiveTicker': + return translate('moduleTrading.tradeHistory.export.columns.receiveTicker'); + case 'receiveNetwork': + return translate('moduleTrading.tradeHistory.export.columns.receiveNetwork'); + case 'provider': + return translate('moduleTrading.tradeHistory.export.columns.provider'); + case 'status': + return translate('moduleTrading.tradeHistory.export.columns.status'); + case 'receiveTransactionId': + return translate('moduleTrading.tradeHistory.export.columns.receiveTransactionId'); + case 'paymentId': + return translate('moduleTrading.tradeHistory.export.columns.paymentId'); + default: + return exhaustive(column); + } +}; + +export const useTradingHistoryCsvColumnLabels = (): TradingHistoryCsvColumnLabels => { + const { translate } = useTranslate(); + + return useMemo( + () => + Object.fromEntries( + TRADING_HISTORY_CSV_COLUMNS.map((column): [TradingHistoryCsvColumn, string] => [ + column, + getColumnLabel(translate, column), + ]), + ) as TradingHistoryCsvColumnLabels, + [translate], + ); +}; diff --git a/suite-native/trading-history/src/index.ts b/suite-native/trading-history/src/index.ts index f1f7c30de8d4..4a65ca9c7ca1 100644 --- a/suite-native/trading-history/src/index.ts +++ b/suite-native/trading-history/src/index.ts @@ -1,4 +1,5 @@ export { TradingHistory } from './components/TradingHistory'; +export { TradingHistoryExportButton } from './components/TradingHistoryExportButton'; export { TradeStatusBadge, getBadgeIconName, getBadgeVariant } from './components/TradeStatusBadge'; export { TradeDetailHeader } from './components/TradeDetailSheet/TradeDetailHeader'; export { diff --git a/suite-native/trading-history/tsconfig.json b/suite-native/trading-history/tsconfig.json index 6264a3816bc1..672e0c8ed0d0 100644 --- a/suite-native/trading-history/tsconfig.json +++ b/suite-native/trading-history/tsconfig.json @@ -12,6 +12,7 @@ { "path": "../../suite-common/wallet-core" }, + { "path": "../alerts" }, { "path": "../atoms" }, { "path": "../clipboard" }, { "path": "../device" }, @@ -19,6 +20,7 @@ { "path": "../intl" }, { "path": "../labeling" }, { "path": "../link" }, + { "path": "../toasts" }, { "path": "../trading-atoms" }, { "path": "../trading-browser-auth" }, { "path": "../trading-provider-utils" }, diff --git a/yarn.lock b/yarn.lock index 88650bccba96..e59c648ef955 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14216,6 +14216,7 @@ __metadata: "@suite-common/trading": "workspace:*" "@suite-common/wallet-config": "workspace:*" "@suite-common/wallet-core": "workspace:*" + "@suite-native/alerts": "workspace:*" "@suite-native/atoms": "workspace:*" "@suite-native/clipboard": "workspace:*" "@suite-native/device": "workspace:*" @@ -14226,6 +14227,7 @@ __metadata: "@suite-native/link": "workspace:*" "@suite-native/test-utils": "workspace:*" "@suite-native/test-utils-store": "workspace:*" + "@suite-native/toasts": "workspace:*" "@suite-native/trading-atoms": "workspace:*" "@suite-native/trading-browser-auth": "workspace:*" "@suite-native/trading-fixtures": "workspace:*" @@ -14235,6 +14237,8 @@ __metadata: "@trezor/styles-native": "workspace:*" "@trezor/type-utils": "workspace:*" "@types/invity-api": "npm:^1.1.19" + expo-file-system: "npm:~56.0.7" + expo-sharing: "npm:~56.0.18" react: "npm:19.2.3" react-native: "npm:0.85.3" react-native-safe-area-context: "npm:5.6.2"