diff --git a/apps/extension/e2e/transaction/cadence-transaction.test.ts b/apps/extension/e2e/transaction/cadence-transaction.test.ts index 22adf209f..6f8649631 100644 --- a/apps/extension/e2e/transaction/cadence-transaction.test.ts +++ b/apps/extension/e2e/transaction/cadence-transaction.test.ts @@ -28,7 +28,11 @@ export const sendTokenFlow = async ({ await page.getByTestId('next').click(); await page.getByTestId('confirm').click(); // Wait for the transaction to be completed - const txId = await waitForTransaction({ page, successtext: /Executed|Sealed/, ingoreFlowCharge }); + const txId = await waitForTransaction({ + page, + successtext: /success|Finalized|Executed|Sealed/, + ingoreFlowCharge, + }); return { txId, tokenname, amount, ingoreFlowCharge }; }; @@ -47,7 +51,11 @@ export const moveTokenFlow = async ({ await page.getByRole('button', { name: 'Move' }).click(); // Wait for the transaction to be completed - const txId = await waitForTransaction({ page, successtext: /Executed|Sealed/, ingoreFlowCharge }); + const txId = await waitForTransaction({ + page, + successtext: /success|Finalized|Executed|Sealed/, + ingoreFlowCharge, + }); return { txId, tokenname, amount, ingoreFlowCharge }; }; @@ -66,7 +74,11 @@ export const moveTokenFlowHomepage = async ({ await page.getByPlaceholder('0.00').fill(amount); await page.getByRole('button', { name: 'Move' }).click(); // Wait for the transaction to be completed - const txId = await waitForTransaction({ page, successtext: /Executed|Sealed/, ingoreFlowCharge }); + const txId = await waitForTransaction({ + page, + successtext: /success|Finalized|Executed|Sealed/, + ingoreFlowCharge, + }); return { txId, tokenname, amount, ingoreFlowCharge }; }; @@ -79,7 +91,7 @@ let txList: { txId: string; tokenname: string; amount: string; ingoreFlowCharge: //Send FLOW token from Flow to Flow test('send Cadence transactions', async ({ page, extensionId }) => { - test.setTimeout(120_000); + test.setTimeout(600_000); await loginToSenderAccount({ page, extensionId }); await switchToMainAccount({ @@ -126,7 +138,7 @@ test('send Cadence transactions', async ({ page, extensionId }) => { //Send FLOW token to evm test('send Cadence transactions to evm', async ({ page, extensionId }) => { - test.setTimeout(120_000); + test.setTimeout(600_000); await loginToSenderAccount({ page, extensionId }); await switchToMainAccount({ @@ -172,7 +184,7 @@ test('send Cadence transactions to evm', async ({ page, extensionId }) => { }); test('send from evm to Flow', async ({ page, extensionId }) => { - test.setTimeout(120_000); + test.setTimeout(600_000); await loginToSenderAccount({ page, extensionId }); await switchToEOAAccount({ diff --git a/apps/extension/e2e/transaction/ft-transaction.test.ts b/apps/extension/e2e/transaction/ft-transaction.test.ts index 45bf70b88..9b72c7ebf 100644 --- a/apps/extension/e2e/transaction/ft-transaction.test.ts +++ b/apps/extension/e2e/transaction/ft-transaction.test.ts @@ -37,7 +37,11 @@ export const sendFT = async ({ await page.getByTestId('next').click(); await page.getByTestId('confirm').click(); // Wait for the transaction to be completed - const txId = await waitForTransaction({ page, successtext: /Executed|Sealed/, ingoreFlowCharge }); + const txId = await waitForTransaction({ + page, + successtext: /success|Finalized|Executed|Sealed/, + ingoreFlowCharge, + }); return { txId, tokenName, amount, ingoreFlowCharge }; }; @@ -47,7 +51,7 @@ test.beforeEach(async ({ page, extensionId }) => { //Send FLOW token from Flow to Flow test('send FTs ', async ({ page, extensionId }) => { - test.setTimeout(120_000); + test.setTimeout(600_000); await loginToSenderAccount({ page, extensionId, @@ -87,7 +91,7 @@ test('send FTs ', async ({ page, extensionId }) => { }); test('send FTs with Coa ', async ({ page, extensionId }) => { - test.setTimeout(120_000); + test.setTimeout(600_000); const txList: { txId: string; tokenName: string; amount: string; ingoreFlowCharge: boolean }[] = []; await switchToEvmAddress({ @@ -119,7 +123,7 @@ test('send FTs with Coa ', async ({ page, extensionId }) => { }); test('send FTs with EOA ', async ({ page, extensionId }) => { - test.setTimeout(120_000); + test.setTimeout(600_000); await loginToEOAAccount({ page, extensionId, @@ -156,7 +160,7 @@ test('send FTs with EOA ', async ({ page, extensionId }) => { // child account ft receive test // test('send FTs tp child ', async ({ page, extensionId }) => { -// test.setTimeout(120_000); +// test.setTimeout(600_000); // const txList: { txId: string; tokenName: string; amount: string; ingoreFlowCharge: boolean }[] = // []; // await switchToMainAccount({ @@ -196,7 +200,7 @@ test('send FTs with EOA ', async ({ page, extensionId }) => { // // child account ft send test // test('send FTs with child ', async ({ page, extensionId }) => { -// test.setTimeout(120_000); +// test.setTimeout(600_000); // const txList: { txId: string; tokenName: string; amount: string; ingoreFlowCharge: boolean }[] = // []; // await switchToChildAccount({ diff --git a/apps/extension/e2e/utils/helper.ts b/apps/extension/e2e/utils/helper.ts index 2b1b5f318..113095407 100644 --- a/apps/extension/e2e/utils/helper.ts +++ b/apps/extension/e2e/utils/helper.ts @@ -548,7 +548,7 @@ const getActivityItemRegexp = (txId: string, ingoreFlowCharge = false) => { export const checkNFTTrx = async ({ page, sealedText, collectionName, txId, isEvm = false }) => { const activityItemRegexp = getActivityItemRegexp(txId); - const sealedItem = page.getByTestId(activityItemRegexp).filter({ hasText: sealedText }); + const sealedItem = page.getByTestId(activityItemRegexp).filter({ hasText: sealedText }).first(); await expect(sealedItem).toBeVisible({ timeout: 60_000, }); @@ -591,7 +591,7 @@ export const checkSentNFT = async ({ ingoreFlowCharge = false, }) => { const activityItemRegexp = getActivityItemRegexp(txId, ingoreFlowCharge); - const sealedItem = page.getByTestId(activityItemRegexp).filter({ hasText: sealedText }); + const sealedItem = page.getByTestId(activityItemRegexp).filter({ hasText: sealedText }).first(); await expect(sealedItem).toBeVisible({ timeout: 60_000, }); @@ -637,8 +637,8 @@ export const waitForTransaction = async ({ // Get the executed item with the cadence txId that was put in the url and status is success const executedItem = page.getByTestId(activityItemRegexp).filter({ hasText: successtext }); - await expect(executedItem).toBeVisible({ - timeout: 100_000, + await expect(executedItem.first()).toBeVisible({ + timeout: 200_000, }); if (amount) { diff --git a/apps/extension/src/background/index.ts b/apps/extension/src/background/index.ts index 008020636..2a931dd92 100644 --- a/apps/extension/src/background/index.ts +++ b/apps/extension/src/background/index.ts @@ -102,6 +102,10 @@ async function restoreAppState() { await userWalletService.init(); await transactionActivityService.init(); + // Re-register FCL monitoring for any transactions that were pending before + // the service worker was restarted. This prevents items from being stuck + // in a non-terminal status (e.g. PENDING, Executed) indefinitely. + userWalletService.resumePendingTransactions().catch(() => {}); await nftService.init(); await googleDriveService.init({ baseURL: 'https://www.googleapis.com/', diff --git a/apps/extension/src/core/service/transaction-activity.ts b/apps/extension/src/core/service/transaction-activity.ts index 9f80d54cd..ea2b05e36 100644 --- a/apps/extension/src/core/service/transaction-activity.ts +++ b/apps/extension/src/core/service/transaction-activity.ts @@ -393,6 +393,25 @@ class TransactionActivity { ); const existingTxList = existingTxStore?.list || []; const existingPendingList = await this.getPendingList(network, address); + + // If the in-memory pending list is empty (e.g., service worker was restarted), + // recover any in-progress items from the cached list so they are not lost while + // the API has not yet indexed those transactions. + // This includes PENDING, Executed, and Finalized states — all of which represent + // transactions that are not yet confirmed by the indexer. Recovering only PENDING + // items misses the case where updatePending already advanced the status before + // the SW restarted (common for EVM/Cadence hybrid transactions like withdrawCoa). + const TERMINAL_STATUSES = new Set(['SEALED', 'EXPIRED', 'ERROR']); + if (existingPendingList.length === 0 && existingTxList.length > 0) { + const cachedPendingItems = existingTxList.filter( + (item) => !TERMINAL_STATUSES.has((item.status ?? '').toUpperCase()) + ); + if (cachedPendingItems.length > 0) { + existingPendingList.push(...cachedPendingItems); + this.setPendingList(network, address, existingPendingList); + } + } + const txList: TransferItem[] = []; data?.transactions?.forEach(async (tx) => { const transactionHolder = { @@ -428,11 +447,13 @@ class TransactionActivity { transactionHolder.transferType = tx.transfer_type; transactionHolder.additionalMessage = tx.additional_message; // see if there's a pending item for this transaction + // Use case-insensitive comparison to handle API/FCL hash casing differences + const normalizedTxid = tx.txid.toLowerCase(); const pendingItemIndex = existingPendingList.findIndex( (item) => - item.hash.includes(tx.txid) || - item.cadenceTxId?.includes(tx.txid) || - item.evmTxIds?.includes(tx.txid) + item.hash.toLowerCase().includes(normalizedTxid) || + item.cadenceTxId?.toLowerCase().includes(normalizedTxid) || + item.evmTxIds?.some((id) => id.toLowerCase().includes(normalizedTxid)) ); if (pendingItemIndex !== -1) { // Store the cadence transaction id @@ -443,9 +464,9 @@ class TransactionActivity { // see if there's an existing transaction with cadenceId in the store const existingTx = existingTxList.find( (item) => - item.hash.includes(tx.txid) || - item.cadenceTxId?.includes(tx.txid) || - item.evmTxIds?.includes(tx.txid) + item.hash.toLowerCase().includes(normalizedTxid) || + item.cadenceTxId?.toLowerCase().includes(normalizedTxid) || + item.evmTxIds?.some((id) => id.toLowerCase().includes(normalizedTxid)) ); if (existingTx && existingTx.cadenceTxId) { // Found existing cadence transaction id @@ -595,6 +616,28 @@ class TransactionActivity { return this.getPendingList(network, address); }; + /** + * Returns Cadence txIds from cached non-terminal transactions that need FCL monitoring + * re-registered after a service worker restart. Only items with a cadenceTxId and a + * non-terminal status (i.e., not SEALED/EXPIRED/ERROR) are returned. + */ + getRecoverableCadenceTxIds = async (network: string, address: string): Promise => { + const TERMINAL_STATUSES = new Set(['SEALED', 'EXPIRED', 'ERROR']); + const existingTxStore = await getInvalidData( + transferListKey(network, address, '0', '15') + ); + if (!existingTxStore?.list?.length) { + return []; + } + const recoverableIds = new Set(); + for (const item of existingTxStore.list) { + if (item.cadenceTxId && !TERMINAL_STATUSES.has((item.status ?? '').toUpperCase())) { + recoverableIds.add(item.cadenceTxId); + } + } + return Array.from(recoverableIds); + }; + getCount = async ( network: string, address: string, diff --git a/apps/extension/src/core/service/userWallet.ts b/apps/extension/src/core/service/userWallet.ts index 8da9db290..49ed93279 100644 --- a/apps/extension/src/core/service/userWallet.ts +++ b/apps/extension/src/core/service/userWallet.ts @@ -1014,6 +1014,10 @@ class UserWallet { try { transactionActivityService.setPending(network, address, txId, icon, title); + // Ensure FCL is configured for the correct network before subscribing. + // unlock() forces FCL to 'mainnet'; without this call, testnet transactions + // would never be found and onceExecuted/onceSealed would never resolve. + await fclEnsureNetwork(network); const fclTx = fcl.tx(txId); // Wait for the transaction to be executed @@ -1157,6 +1161,58 @@ class UserWallet { } }; + /** + * Re-registers FCL monitoring for any pending Cadence transactions that were in + * progress before a service worker restart. This is necessary because FCL + * onceExecuted/onceSealed promise chains are lost when the SW restarts, leaving + * items permanently stuck in a non-terminal status (PENDING, Executed, Finalized). + * + * Unlike listenTransaction, this does NOT call setPending — the cached entry already + * exists. It only re-attaches FCL event subscriptions so updatePending can advance + * the status once on-chain confirmation arrives. + */ + resumePendingTransactions = async (): Promise => { + try { + // Ensure FCL is configured for the correct network. At SW startup, + // openapiService.init() calls setupFcl() before userWalletService.init() + // loads the persisted store, so FCL may be configured for 'mainnet' even + // when the wallet is on 'testnet'. Re-running setupFcl() here ensures FCL + // uses the right endpoints before we call onceExecuted/onceSealed. + await this.setupFcl(); + const network = this.getNetwork(); + const address = await this.getCurrentAddress(); + if (!network || !address) { + return; + } + const cadenceTxIds = await transactionActivityService.getRecoverableCadenceTxIds( + network, + address + ); + for (const txId of cadenceTxIds) { + this.reattachFclMonitoring(network, address, txId).catch(() => {}); + } + } catch { + // Do not throw — SW startup should not fail due to recovery errors + } + }; + + private reattachFclMonitoring = async ( + network: string, + address: string, + txId: string + ): Promise => { + if (!txId || !txId.match(/^0?x?[0-9a-fA-F]{64}/)) { + return; + } + await fclEnsureNetwork(network); + const fclTx = fcl.tx(txId); + const txStatusExecuted = await fclTx.onceExecuted(); + await transactionActivityService.updatePending(network, address, txId, txStatusExecuted); + const txStatusSealed = await fclTx.onceSealed(); + await transactionActivityService.updatePending(network, address, txId, txStatusSealed); + await transactionActivityService.pollTransferList(address, txId, network); + }; + authorizationFunction = async (account) => { // authorization function need to return an account const address = fcl.withPrefix(await this.getParentAddress()); diff --git a/apps/extension/src/ui/hooks/useTransferListHook.ts b/apps/extension/src/ui/hooks/useTransferListHook.ts index 9eb804cc0..06df3c82f 100644 --- a/apps/extension/src/ui/hooks/useTransferListHook.ts +++ b/apps/extension/src/ui/hooks/useTransferListHook.ts @@ -1,12 +1,14 @@ import { useEffect, useState } from 'react'; -import { transferListKey, type TransferListStore } from '@/data-model'; +import { transferListKey, triggerRefresh, type TransferListStore } from '@/data-model'; import { useWallet } from '@/ui/hooks/use-wallet'; import { useProfiles } from '@/ui/hooks/useProfileHook'; import { useCachedData } from './use-data'; import { useNetwork } from './useNetworkHook'; +const PENDING_POLL_INTERVAL_MS = 5_000; + export const useTransferList = () => { const wallet = useWallet(); const { network } = useNetwork(); @@ -18,6 +20,21 @@ export const useTransferList = () => { network && currentAddress ? transferListKey(network, currentAddress, '0', '15') : null ); + // Poll for updates while there are pending transactions. + // This guards against the case where chrome.storage.onChanged events are + // not reliably delivered to the popup from the background service worker. + useEffect(() => { + if (!network || !currentAddress) return; + if (!transferListStore?.pendingCount) return; + + const key = transferListKey(network, currentAddress, '0', '15'); + const id = setInterval(() => { + triggerRefresh(key); + }, PENDING_POLL_INTERVAL_MS); + + return () => clearInterval(id); + }, [network, currentAddress, transferListStore?.pendingCount]); + const [monitor, setMonitor] = useState(null); const [flowscanURL, setFlowscanURL] = useState(null); const [viewSourceURL, setViewSourceURL] = useState(null); diff --git a/apps/extension/src/ui/views/TransferList/index.tsx b/apps/extension/src/ui/views/TransferList/index.tsx index 2101f3f92..66a442a49 100644 --- a/apps/extension/src/ui/views/TransferList/index.tsx +++ b/apps/extension/src/ui/views/TransferList/index.tsx @@ -172,6 +172,7 @@ const TransferList = () => { {' '} {(transactions || []).map((tx) => { const txCombinedKey = `${tx.cadenceTxId || tx.hash}${tx.evmTxIds ? `_${tx.evmTxIds.join('_')}` : ''}_${tx.transferType}_${tx.additionalMessage}_${tx.interaction}`; + const txStableId = tx.cadenceTxId || tx.hash; return ( void; @@ -43,6 +51,16 @@ export interface ActivityScreenProps { export function ActivityScreen({ onActivityPress }: ActivityScreenProps = {}): ReactElement { const { t } = useTranslation(); const isExtension = bridge.getPlatform() === 'extension'; + + const cardLabels = useMemo( + () => ({ + sent: t('activity.sent'), + received: t('activity.received'), + to: t('activity.detail.to'), + from: t('activity.detail.from'), + }), + [t] + ); const network = bridge.getNetwork() || 'mainnet'; const activeAccount = useWalletStore(walletSelectors.getActiveAccount); @@ -139,13 +157,10 @@ export function ActivityScreen({ onActivityPress }: ActivityScreenProps = {}): R {!isLoading && isError && ( )} @@ -153,7 +168,7 @@ export function ActivityScreen({ onActivityPress }: ActivityScreenProps = {}): R {!isLoading && !isError && groupedActivity.length === 0 && ( )} @@ -167,7 +182,11 @@ export function ActivityScreen({ onActivityPress }: ActivityScreenProps = {}): R {group.items.map((item, index) => ( - handleActivityPress(item)} /> + handleActivityPress(item)} + labels={{ ...cardLabels, status: getActivityStatusLabel(item, t) }} + /> {index < group.items.length - 1 && ( )} diff --git a/packages/screens/src/locales/en.json b/packages/screens/src/locales/en.json index 26569d237..17b2bf9ab 100644 --- a/packages/screens/src/locales/en.json +++ b/packages/screens/src/locales/en.json @@ -154,8 +154,6 @@ }, "sent": "Sent", "received": "Received", - "today": "Today", - "yesterday": "Yesterday", "detail": { "appInteraction": "App Interaction", "youSent": "You Sent", @@ -173,7 +171,8 @@ "networkEvm": "Flow EVM", "nftsFrom": "NFTS from {{collection}}", "nftCount": "{{count}} NFTS" - } + }, + "loadMore": "Load more" }, "buttons": { "retry": "Retry", @@ -587,11 +586,10 @@ }, "drawer": { "title": "Claim assets", - "token": "Token", - "from": "From", "cta": "Claim", "to": "To" - } + }, + "title": "Claim" }, "addTokens": { "claimBannerTitle": "Claim received tokens", @@ -606,7 +604,6 @@ "website": "Website", "items": "Items", "about": "About", - "claimButton": "Claim assets", "empty": "No NFTs", "warningText": "By accepting this NFT collection you will automatically accept future deposits of it as well" } diff --git a/packages/screens/src/locales/es.json b/packages/screens/src/locales/es.json index c1e4b1b6c..e4aed20c0 100644 --- a/packages/screens/src/locales/es.json +++ b/packages/screens/src/locales/es.json @@ -154,8 +154,6 @@ }, "sent": "Enviado", "received": "Recibido", - "today": "Hoy", - "yesterday": "Ayer", "detail": { "appInteraction": "Interacción con App", "youSent": "Enviaste", @@ -173,7 +171,8 @@ "networkEvm": "Flow EVM", "nftsFrom": "NFTS de {{collection}}", "nftCount": "{{count}} NFTS" - } + }, + "loadMore": "Cargar más" }, "buttons": { "retry": "Reintentar", @@ -535,11 +534,10 @@ }, "drawer": { "title": "Reclamar activo", - "token": "Token", - "from": "De", "cta": "Reclamar", "to": "A" - } + }, + "title": "Reclamar" }, "addTokens": { "claimBannerTitle": "Reclamar tokens recibidos", @@ -554,7 +552,6 @@ "website": "Sitio web", "items": "Elementos", "about": "Acerca de", - "claimButton": "Reclamar activos", "empty": "Sin NFTs", "warningText": "Al aceptar esta colección de NFT, aceptarás automáticamente futuros depósitos de la misma" } diff --git a/packages/screens/src/locales/jp.json b/packages/screens/src/locales/jp.json index 022975741..78255782b 100644 --- a/packages/screens/src/locales/jp.json +++ b/packages/screens/src/locales/jp.json @@ -154,8 +154,6 @@ }, "sent": "送信済み", "received": "受信済み", - "today": "今日", - "yesterday": "昨日", "detail": { "appInteraction": "アプリ操作", "youSent": "送信額", @@ -173,7 +171,8 @@ "networkEvm": "Flow EVM", "nftsFrom": "{{collection}}のNFTS", "nftCount": "{{count}} NFTS" - } + }, + "loadMore": "さらに読み込む" }, "buttons": { "retry": "再試行", @@ -535,11 +534,10 @@ }, "drawer": { "title": "資産を請求する", - "token": "トークン", - "from": "送信元", "cta": "請求する", "to": "宛先" - } + }, + "title": "請求する" }, "addTokens": { "claimBannerTitle": "受け取ったトークンを請求する", @@ -554,7 +552,6 @@ "website": "ウェブサイト", "items": "アイテム数", "about": "概要", - "claimButton": "資産を受け取る", "empty": "NFT なし", "warningText": "この NFT コレクションを受け入れると、今後の入金も自動的に受け入れます" } diff --git a/packages/screens/src/locales/ru.json b/packages/screens/src/locales/ru.json index d0c98df9e..aafe2837c 100644 --- a/packages/screens/src/locales/ru.json +++ b/packages/screens/src/locales/ru.json @@ -154,8 +154,6 @@ }, "sent": "Отправлено", "received": "Получено", - "today": "Сегодня", - "yesterday": "Вчера", "detail": { "appInteraction": "Взаимодействие с приложением", "youSent": "Вы отправили", @@ -173,7 +171,8 @@ "networkEvm": "Flow EVM", "nftsFrom": "NFTS из {{collection}}", "nftCount": "{{count}} NFTS" - } + }, + "loadMore": "Загрузить ещё" }, "buttons": { "retry": "Повторить", @@ -547,11 +546,10 @@ }, "drawer": { "title": "Получить актив", - "token": "Токен", - "from": "От", "cta": "Получить", "to": "Получатель" - } + }, + "title": "Получить" }, "addTokens": { "claimBannerTitle": "Получить полученные токены", @@ -566,7 +564,6 @@ "website": "Сайт", "items": "Элементы", "about": "О коллекции", - "claimButton": "Принять активы", "empty": "Нет NFT", "warningText": "Принимая эту коллекцию NFT, вы автоматически принимаете будущие поступления" } diff --git a/packages/screens/src/locales/zh.json b/packages/screens/src/locales/zh.json index bd6e6f6c7..517fed78e 100644 --- a/packages/screens/src/locales/zh.json +++ b/packages/screens/src/locales/zh.json @@ -154,8 +154,6 @@ }, "sent": "已发送", "received": "已接收", - "today": "今天", - "yesterday": "昨天", "detail": { "appInteraction": "应用交互", "youSent": "您发送了", @@ -173,7 +171,8 @@ "networkEvm": "Flow EVM", "nftsFrom": "来自{{collection}}的NFTS", "nftCount": "{{count}} NFTS" - } + }, + "loadMore": "加载更多" }, "buttons": { "retry": "重试", @@ -535,11 +534,10 @@ }, "drawer": { "title": "领取资产", - "token": "代币", - "from": "来自", "cta": "领取", "to": "接收方" - } + }, + "title": "领取" }, "addTokens": { "claimBannerTitle": "领取已收到的代币", @@ -554,7 +552,6 @@ "website": "网站", "items": "数量", "about": "关于", - "claimButton": "领取资产", "empty": "暂无 NFT", "warningText": "接受此 NFT 收藏后,将自动接受未来的存入" } diff --git a/packages/screens/src/tokens/ClaimTokenDetailScreen.query.tsx b/packages/screens/src/tokens/ClaimTokenDetailScreen.query.tsx index a712709f0..e9c8f2ace 100644 --- a/packages/screens/src/tokens/ClaimTokenDetailScreen.query.tsx +++ b/packages/screens/src/tokens/ClaimTokenDetailScreen.query.tsx @@ -547,7 +547,7 @@ function NFTCollectionDetailView({ {item.description && ( - {t('claim.nft.about', 'About')} + {t('claimNFTDetail.about')} {item.description} @@ -560,7 +560,7 @@ function NFTCollectionDetailView({ @@ -576,7 +576,7 @@ function NFTCollectionDetailView({ ListEmptyComponent={ - {t('claim.nft.empty', 'No NFTs found')} + {t('claimNFTDetail.empty')} } diff --git a/packages/ui/src/components/ActivityCard.tsx b/packages/ui/src/components/ActivityCard.tsx index 9e6245de6..299b6fb28 100644 --- a/packages/ui/src/components/ActivityCard.tsx +++ b/packages/ui/src/components/ActivityCard.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { Stack, Text, XStack, YStack, useTheme } from 'tamagui'; import { Avatar } from '../foundation/Avatar'; -import type { ActivityCardProps } from '../types'; +import type { ActivityCardProps, ActivityCardLabels } from '../types'; import { ChainBadge } from './ChainBadge'; /** @@ -61,26 +61,11 @@ function getStatusColor(statusType: StatusType): string { } /** - * Gets the status display text + * Gets the status display text, using the provided label if available. */ -function getStatusText(item: ActivityItem): string { - if (item.error) { - return 'Failed'; - } - switch (item.status) { - case 'pending': - return 'Pending'; - case 'sealed': - case 'finalized': - case 'executed': - return 'Success'; - case 'expired': - return 'Expired'; - case 'failed': - return 'Failed'; - default: - return item.status; - } +function getStatusText(item: ActivityItem, labels?: ActivityCardLabels): string { + if (labels?.status) return labels.status; + return item.status; } /** @@ -118,12 +103,13 @@ function DirectionBadge({ * Designed to be used within an ActivityCardGroup - no individual card background * The group container provides the shared background and separators */ -export function ActivityCard({ item, onPress }: ActivityCardProps): React.ReactElement { +export function ActivityCard({ item, onPress, labels }: ActivityCardProps): React.ReactElement { const { title, token, image, amount, sender, receiver, transferType, type } = item; const theme = useTheme(); // Get status type for color theming const statusType = getStatusType(item); + const statusText = getStatusText(item, labels); // Check if this is an EVM wallet transaction (show chain badge) const isEvm = item.walletType === 'evm'; @@ -134,7 +120,7 @@ export function ActivityCard({ item, onPress }: ActivityCardProps): React.ReactE // Determine the address to show based on transfer direction // For 'sent' transfers, show the receiver (destination) // For 'received' transfers, show the sender (source) - const addressLabel = transferType === 'sent' ? 'To' : 'From'; + const addressLabel = transferType === 'sent' ? (labels?.to ?? 'To') : (labels?.from ?? 'From'); const addressValue = transferType === 'sent' ? receiver : sender; // Format amount with sign (token name is already shown in title, so no need to repeat) @@ -191,8 +177,8 @@ export function ActivityCard({ item, onPress }: ActivityCardProps): React.ReactE {isInteraction ? title || 'Flow' : transferType === 'sent' - ? `Sent ${truncateToken(token)}` - : `Received ${truncateToken(token)}`} + ? `${labels?.sent ?? 'Sent'} ${truncateToken(token)}` + : `${labels?.received ?? 'Received'} ${truncateToken(token)}`} @@ -242,7 +228,7 @@ export function ActivityCard({ item, onPress }: ActivityCardProps): React.ReactE color={getStatusColor(statusType) as any} lineHeight={20} > - {getStatusText(item)} + {statusText} diff --git a/packages/ui/src/components/ActivityDetailSheet.tsx b/packages/ui/src/components/ActivityDetailSheet.tsx deleted file mode 100644 index 76c81f872..000000000 --- a/packages/ui/src/components/ActivityDetailSheet.tsx +++ /dev/null @@ -1,334 +0,0 @@ -import { Close } from '@onflow/frw-icons'; -import type { ActivityItem } from '@onflow/frw-types'; -import React, { useMemo } from 'react'; -import { Sheet, Stack, View, XStack, YStack, useTheme } from 'tamagui'; - -import { ActivityDetailRow } from './ActivityDetailRow'; -import { ChainBadge } from './ChainBadge'; -import { Avatar } from '../foundation/Avatar'; -import { Separator } from '../foundation/Separator'; -import { Text } from '../foundation/Text'; - -export interface ActivityDetailSheetProps { - /** Whether the sheet is visible */ - visible: boolean; - /** The activity item to display */ - item: ActivityItem | null; - /** Callback when the sheet is closed */ - onClose: () => void; - /** Callback when "View on block explorer" is pressed */ - onViewExplorer?: (item: ActivityItem) => void; - /** Whether running in extension mode */ - isExtension?: boolean; - // Translation props - sentTitle?: string; - receivedTitle?: string; - interactionTitle?: string; - youSentLabel?: string; - fromLabel?: string; - toLabel?: string; - dateLabel?: string; - statusLabel?: string; - networkLabel?: string; - transactionFeeLabel?: string; - networkFeeLabel?: string; - coveredByFlowWallet?: string; - viewOnExplorerText?: string; - statusPending?: string; - statusSuccess?: string; - statusFailed?: string; - statusExpired?: string; - networkFlow?: string; - networkEvm?: string; -} - -/** - * Truncates an address for display - */ -function truncateAddress(address: string, startLength = 6, endLength = 4): string { - if (!address || address.length <= startLength + endLength + 3) { - return address; - } - return `${address.slice(0, startLength)}...${address.slice(-endLength)}`; -} - -/** - * Formats a timestamp to a readable date string - */ -function formatDate(timestamp: number): string { - const date = new Date(timestamp); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); -} - -/** - * ActivityDetailSheet - A modal sheet for displaying activity/transaction details - * - * Shows transaction information including amount, status, addresses, fees, etc. - * Opens as an overlay modal from the bottom of the screen. - */ -export function ActivityDetailSheet({ - visible, - item, - onClose, - onViewExplorer, - isExtension = false, - sentTitle = 'Sent', - receivedTitle = 'Received', - interactionTitle = 'App Interaction', - youSentLabel = 'You Sent', - fromLabel = 'From', - toLabel = 'To', - dateLabel = 'Date', - statusLabel = 'Status', - networkLabel = 'Network', - transactionFeeLabel = 'Transaction Fee', - networkFeeLabel = 'Network Fee', - coveredByFlowWallet = 'Covered by Flow Wallet', - viewOnExplorerText = 'View on block explorer', - statusPending = 'Pending', - statusSuccess = 'Success', - statusFailed = 'Failed', - statusExpired = 'Expired', - networkFlow = 'Flow', - networkEvm = 'Flow EVM', -}: ActivityDetailSheetProps): React.ReactElement | null { - const theme = useTheme(); - - // Derive display values from item - const { title, statusColor, statusText, amountDisplay, amountColor, isInteraction, isEvm } = - useMemo(() => { - if (!item) { - return { - title: '', - statusColor: '$text2', - statusText: '', - amountDisplay: '', - amountColor: '$text1', - isInteraction: false, - isEvm: false, - }; - } - - const isInteractionType = item.type === 'interaction'; - const isEvmWallet = item.walletType === 'evm'; - - // Determine title based on type - let headerTitle = ''; - if (isInteractionType) { - headerTitle = interactionTitle; - } else if (item.transferType === 'sent') { - headerTitle = sentTitle; - } else { - headerTitle = receivedTitle; - } - - // Status color and text - let sColor = '$text2'; - let sText = statusPending; - if (item.error || item.status === 'failed') { - sColor = '$error'; - sText = statusFailed; - } else if (item.status === 'expired') { - sColor = '$error'; - sText = statusExpired; - } else if (item.status === 'pending') { - sColor = '$text2'; - sText = statusPending; - } else { - sColor = '$primary'; - sText = statusSuccess; - } - - // Amount display and color - // Note: Large amount display uses neutral color for sent, green for received - // The "You Sent" row below uses red for sent amounts - let aDisplay = ''; - let aColor = '$text1'; - if (item.amount && item.token) { - if (item.transferType === 'sent') { - aDisplay = `-${item.amount} ${item.token}`; - aColor = '$text1'; // Neutral color for large display (red shown in "You Sent" row) - } else { - aDisplay = `+${item.amount} ${item.token}`; - aColor = '$primary'; // Green for received - } - } - - return { - title: headerTitle, - statusColor: sColor, - statusText: sText, - amountDisplay: aDisplay, - amountColor: aColor, - isInteraction: isInteractionType, - isEvm: isEvmWallet, - }; - }, [ - item, - sentTitle, - receivedTitle, - interactionTitle, - statusPending, - statusSuccess, - statusFailed, - statusExpired, - ]); - - if (!item) return null; - - return ( - !open && onClose()} - snapPointsMode={!isExtension ? 'fit' : undefined} - dismissOnSnapToBottom - snapPoints={isExtension ? [85] : undefined} - animation={isExtension ? 'quick' : 'lazy'} - > - - {!isExtension && } - - - {/* Header */} - - - - - {title} - - - - - - - - {/* Token Icon */} - - - - {isEvm && } - - - {/* Amount or Title for interactions */} - {isInteraction ? ( - - {item.title || 'Flow'} - - ) : ( - amountDisplay && ( - - {amountDisplay} - - ) - )} - - - {/* Amount row for sent transactions */} - {!isInteraction && item.transferType === 'sent' && amountDisplay && ( - - - {youSentLabel} - - - {amountDisplay} - - - )} - - {/* Address row */} - {!isInteraction && ( - - - {item.transferType === 'sent' ? toLabel : fromLabel} - - - {truncateAddress(item.transferType === 'sent' ? item.receiver : item.sender)} - - - )} - - {/* Details Card */} - - {/* Date - only shown for transfers, not for app interactions */} - {!isInteraction && ( - <> - - - - )} - - {/* Status */} - - - - {/* Network */} - - - - {/* Transaction Fee */} - - - - {/* Network Fee */} - - - - {/* View on block explorer button */} - onViewExplorer?.(item)} - cursor="pointer" - mb="$6" - > - - {viewOnExplorerText} - - - - - - ); -} diff --git a/packages/ui/src/components/ClaimSenderRow.tsx b/packages/ui/src/components/ClaimSenderRow.tsx deleted file mode 100644 index e66d517d0..000000000 --- a/packages/ui/src/components/ClaimSenderRow.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { ChevronDown, ChevronUp } from '@onflow/frw-icons'; -import React from 'react'; -import { Text, XStack, YStack, useTheme } from 'tamagui'; - -import { EVMBadge } from './EVMBadge'; -import { Avatar } from '../foundation/Avatar'; -import type { ClaimSenderRowProps } from '../types'; - -function truncateAddress(address: string): string { - if (address.length <= 12) return address; - return `${address.slice(0, 6)}...${address.slice(-4)}`; -} - -export function ClaimSenderRow({ - name, - address, - avatar, - emojiInfo, - parentEmoji, - type, - isCollapsed, - onPress, -}: ClaimSenderRowProps): React.ReactElement { - const theme = useTheme(); - const initial = name !== '—' ? name[0].toUpperCase() : '?'; - - return ( - - {/* Avatar with optional parent emoji bubble */} - - - {parentEmoji && ( - - - {parentEmoji.emoji} - - - )} - - - - - {name} - - {(type === 'evm' || type === 'eoa') && ( - - )} - - {truncateAddress(address)} - - - - {isCollapsed ? ( - - ) : ( - - )} - - ); -} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 674735a0f..810479fef 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -53,7 +53,6 @@ export * from './components/TokenSectionHeader'; export * from './components/AccountSelector'; export * from './components/ActivityCard'; export * from './components/ActivityDetailRow'; -export * from './components/ActivityDetailSheet'; export * from './components/ActivityGroupHeader'; export * from './components/ActivitySkeleton'; export * from './components/AddContactDialog'; diff --git a/packages/ui/src/types/index.ts b/packages/ui/src/types/index.ts index 07faf9163..ed34a7f57 100644 --- a/packages/ui/src/types/index.ts +++ b/packages/ui/src/types/index.ts @@ -233,9 +233,24 @@ export interface ClaimBannerProps { } // Activity components props +export interface ActivityCardLabels { + /** e.g. t('activity.status.*') */ + status?: string; + /** e.g. t('activity.sent') */ + sent?: string; + /** e.g. t('activity.received') */ + received?: string; + /** e.g. t('activity.detail.to') */ + to?: string; + /** e.g. t('activity.detail.from') */ + from?: string; +} + export interface ActivityCardProps { item: ActivityItem; onPress?: () => void; + /** Translated labels for user-visible strings. Falls back to English if omitted. */ + labels?: ActivityCardLabels; } export interface ActivityGroupHeaderProps {