diff --git a/.eslintrc b/.eslintrc index c547f93e..26f348aa 100644 --- a/.eslintrc +++ b/.eslintrc @@ -57,6 +57,7 @@ } } ], + "react/jsx-key": [2, { "checkFragmentShorthand": true}], "arrow-body-style": "off", "no-else-return": "off", "no-plusplus": "off", diff --git a/changelogs/1.20.19.txt b/changelogs/1.20.19.txt new file mode 100644 index 00000000..619f4cd5 --- /dev/null +++ b/changelogs/1.20.19.txt @@ -0,0 +1 @@ +Bug fixes and performance improvements diff --git a/changelogs/2.0.0.txt b/changelogs/2.0.0.txt new file mode 100644 index 00000000..619f4cd5 --- /dev/null +++ b/changelogs/2.0.0.txt @@ -0,0 +1 @@ +Bug fixes and performance improvements diff --git a/changelogs/3.0.0.txt b/changelogs/3.0.0.txt new file mode 100644 index 00000000..05df7b87 --- /dev/null +++ b/changelogs/3.0.0.txt @@ -0,0 +1,8 @@ +- Significantly Improved In-App Browser Interface. +- Manual NFT Hiding. +- Enhanced Scam Detection. +- Ledger v2.1 Support. +- W5 Wallet Version Support. +- Boosted Connection Speed & App Performance. +- Dapp Connection Fixes. +- Multiple Fixes and Improvements. diff --git a/package-lock.json b/package-lock.json index 4f645072..872c24dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mytonwallet", - "version": "1.20.18", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mytonwallet", - "version": "1.20.18", + "version": "3.0.0", "license": "GPL-3.0-or-later", "dependencies": { "@awesome-cordova-plugins/core": "^6.6.0", diff --git a/package.json b/package.json index 821c6796..ad772232 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mytonwallet", - "version": "1.20.18", + "version": "3.0.0", "description": "The most feature-rich web wallet and browser extension for TON – with support of multi-accounts, tokens (jettons), NFT, TON DNS, TON Sites, TON Proxy, and TON Magic.", "main": "index.js", "scripts": { diff --git a/public/version.txt b/public/version.txt index 41450c3d..4a36342f 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -1.20.18 +3.0.0 diff --git a/src/api/blockchains/ton/transactions.ts b/src/api/blockchains/ton/transactions.ts index 1fd82f66..afb09814 100644 --- a/src/api/blockchains/ton/transactions.ts +++ b/src/api/blockchains/ton/transactions.ts @@ -626,18 +626,18 @@ async function populateTransactions(network: ApiNetwork, transactions: ApiTransa if (parsedPayload?.type === 'nft:ownership-assigned') { const nft = nftsByAddress[parsedPayload.nftAddress]; + transaction.nft = nft; if (nft?.isScam) { transaction.metadata = { ...transaction.metadata, isScam: true }; } else { - transaction.nft = nft; transaction.fromAddress = addressBook[parsedPayload.prevOwner].user_friendly; } } else if (parsedPayload?.type === 'nft:transfer') { const nft = nftsByAddress[parsedPayload.nftAddress]; + transaction.nft = nft; if (nft?.isScam) { transaction.metadata = { ...transaction.metadata, isScam: true }; } else { - transaction.nft = nft; transaction.toAddress = addressBook[parsedPayload.newOwner].user_friendly; } } diff --git a/src/api/blockchains/ton/util/tonCore.ts b/src/api/blockchains/ton/util/tonCore.ts index 1846e27b..9de2fafa 100644 --- a/src/api/blockchains/ton/util/tonCore.ts +++ b/src/api/blockchains/ton/util/tonCore.ts @@ -303,10 +303,7 @@ export function buildLiquidStakingWithdrawBody(options: { queryId, amount, responseAddress, waitTillRoundEnd, fillOrKill, } = options; - const customPayload = new Builder() - .storeUint(Number(waitTillRoundEnd), 1) - .storeUint(Number(fillOrKill), 1) - .asCell(); + const customPayload = buildLiquidStakingWithdrawCustomPayload(waitTillRoundEnd, fillOrKill); return new Builder() .storeUint(JettonOpCode.Burn, 32) @@ -318,6 +315,13 @@ export function buildLiquidStakingWithdrawBody(options: { .asCell(); } +export function buildLiquidStakingWithdrawCustomPayload(waitTillRoundEnd?: boolean, fillOrKill?: boolean) { + return new Builder() + .storeUint(Number(waitTillRoundEnd), 1) + .storeUint(Number(fillOrKill), 1) + .asCell(); +} + export function getTokenBalance(network: ApiNetwork, walletAddress: string) { const tokenWallet = getTonClient(network).open(new JettonWallet(Address.parse(walletAddress))); return tokenWallet.getJettonBalance(); diff --git a/src/api/common/addresses.ts b/src/api/common/addresses.ts index 6c4f6828..904ac8d5 100644 --- a/src/api/common/addresses.ts +++ b/src/api/common/addresses.ts @@ -1,9 +1,7 @@ import type { ApiAddressInfo, ApiKnownAddresses } from '../types'; -import { - RE_EMPTY_CHARS, - RE_FAKE_DOTS, RE_LINK_TEMPLATE, RE_SPACE_CHARS, RE_TG_BOT_MENTION, -} from '../../config'; +import { RE_LINK_TEMPLATE, RE_TG_BOT_MENTION } from '../../config'; +import { cleanText } from '../../lib/confusables'; import { logDebugError } from '../../util/logs'; import { callBackendGet } from './backend'; @@ -51,11 +49,7 @@ export function checkIsTrustedCollection(address: string) { } export function checkHasScamLink(text: string) { - const matches = text - .replace(RE_EMPTY_CHARS, '') - .replace(RE_SPACE_CHARS, ' ') - .replace(RE_FAKE_DOTS, '.') - .matchAll(RE_LINK_TEMPLATE); + const matches = cleanText(text).matchAll(RE_LINK_TEMPLATE); for (const match of matches) { const host = match.groups?.host; @@ -68,5 +62,5 @@ export function checkHasScamLink(text: string) { } export function checkHasTelegramBotMention(text: string) { - return RE_TG_BOT_MENTION.test(text.replace(RE_EMPTY_CHARS, '').replace(RE_SPACE_CHARS, ' ')); + return RE_TG_BOT_MENTION.test(cleanText(text)); } diff --git a/src/api/common/helpers.ts b/src/api/common/helpers.ts index bdc04e31..67984960 100644 --- a/src/api/common/helpers.ts +++ b/src/api/common/helpers.ts @@ -70,11 +70,11 @@ export function buildLocalTransaction( export function updateTransactionMetadata(transaction: ApiTransactionExtra): ApiTransactionExtra { const { - normalizedAddress, comment, type, isIncoming, + normalizedAddress, comment, type, isIncoming, nft, } = transaction; let { metadata = {} } = transaction; - const isNftTransfer = type === 'nftTransferred' || type === 'nftReceived'; + const isNftTransfer = type === 'nftTransferred' || type === 'nftReceived' || Boolean(nft); const knownAddresses = getKnownAddresses(); const hasScamMarkers = comment ? getScamMarkers().some((sm) => sm.test(comment)) : false; const shouldCheckComment = !hasScamMarkers && comment && isIncoming diff --git a/src/api/methods/accounts.ts b/src/api/methods/accounts.ts index 7ae4e9ca..39f17930 100644 --- a/src/api/methods/accounts.ts +++ b/src/api/methods/accounts.ts @@ -11,7 +11,6 @@ import { sendUpdateTokens, setupBalanceBasedPolling, setupStakingPolling, - setupSwapPolling, setupVestingPolling, setupWalletVersionsPolling, } from './polling'; @@ -33,7 +32,7 @@ export async function activateAccount(accountId: string, newestTxIds?: ApiTxIdBy deactivateAllDapps(); } - callHook('onFirstLogin'); + void callHook('onFirstLogin'); onActiveDappAccountUpdated(accountId); } @@ -44,7 +43,6 @@ export async function activateAccount(accountId: string, newestTxIds?: ApiTxIdBy void setupBalanceBasedPolling(accountId, newestTxIds); void setupStakingPolling(accountId); - void setupSwapPolling(accountId); void setupWalletVersionsPolling(accountId); void setupVestingPolling(accountId); } @@ -55,7 +53,7 @@ export function deactivateAllAccounts() { if (IS_EXTENSION) { deactivateAllDapps(); - callHook('onFullLogout'); + void callHook('onFullLogout'); } } diff --git a/src/api/methods/dapps.ts b/src/api/methods/dapps.ts index dab1666d..01666635 100644 --- a/src/api/methods/dapps.ts +++ b/src/api/methods/dapps.ts @@ -165,7 +165,7 @@ export async function deleteAllDapps(accountId: string) { accountId, origin, }); - callHook('onDappDisconnected', accountId, origin); + void callHook('onDappDisconnected', accountId, origin); }); await callHook('onDappsChanged'); @@ -208,7 +208,7 @@ export function getDappsState(): Promise { export async function removeAccountDapps(accountId: string) { await removeAccountValue(accountId, 'dapps'); - callHook('onDappsChanged'); + void callHook('onDappsChanged'); } export async function removeAllDapps() { diff --git a/src/api/methods/init.ts b/src/api/methods/init.ts index c88f7e18..556175ab 100644 --- a/src/api/methods/init.ts +++ b/src/api/methods/init.ts @@ -13,7 +13,6 @@ import * as methods from '.'; addHooks({ onDappDisconnected: tonConnectSse.sendSseDisconnect, onDappsChanged: tonConnectSse.resetupSseConnection, - onSwapCreated: methods.setupSwapPolling, }); // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/src/api/methods/polling.ts b/src/api/methods/polling.ts index 911c3389..a829de14 100644 --- a/src/api/methods/polling.ts +++ b/src/api/methods/polling.ts @@ -9,7 +9,6 @@ import type { ApiStakingCommonData, ApiStakingState, ApiSwapAsset, - ApiSwapHistoryItem, ApiTokenPrice, ApiTransactionActivity, ApiTxIdBySlug, @@ -39,9 +38,7 @@ import { processNftUpdates, updateAccountNfts } from './nfts'; import { resolveDataPreloadPromise } from './preload'; import { getBaseCurrency } from './prices'; import { getBackendStakingState, tryUpdateStakingCommonData } from './staking'; -import { - swapGetAssets, swapGetHistory, swapItemToActivity, swapReplaceTransactionsByRanges, -} from './swap'; +import { swapGetAssets, swapReplaceTransactionsByRanges } from './swap'; import { fetchVestings } from './vesting'; type IsAccountActiveFn = (accountId: string) => boolean; @@ -59,9 +56,6 @@ const STAKING_INTERVAL_WHEN_NOT_FOCUSED = 10 * SEC; const BACKEND_INTERVAL = 30 * SEC; const LONG_BACKEND_INTERVAL = 60 * SEC; const NFT_FULL_INTERVAL = 60 * SEC; -const SWAP_POLLING_INTERVAL = 3 * SEC; -const SWAP_POLLING_INTERVAL_WHEN_NOT_FOCUSED = 10 * SEC; -const SWAP_FINISHED_STATUSES = new Set(['failed', 'completed', 'expired']); const VERSIONS_INTERVAL = 5 * 60 * SEC; const VERSIONS_INTERVAL_WHEN_NOT_FOCUSED = 15 * 60 * SEC; const VESTING_INTERVAL = 10 * SEC; @@ -81,7 +75,7 @@ const prices: { baseCurrency: DEFAULT_PRICE_CURRENCY, bySlug: {}, }; -let swapPollingAccountId: string | undefined; + const lastBalanceCache: Record = {}; export async function initPolling(_onUpdate: OnApiUpdate, _isAccountActive: IsAccountActiveFn) { @@ -563,100 +557,10 @@ export function sendUpdateTokens() { }); } -export async function setupSwapPolling(accountId: string) { - if (swapPollingAccountId === accountId) return; // Double launch is not allowed - swapPollingAccountId = accountId; - - const { address, lastFinishedSwapTimestamp } = await fetchStoredAccount(accountId); - - let fromTimestamp = lastFinishedSwapTimestamp ?? await getActualLastFinishedSwapTimestamp(accountId, address); - - const localOnUpdate = onUpdate; - const swapById: Record = {}; - - while (isAlive(localOnUpdate, accountId)) { - try { - const swaps = await swapGetHistory(address, { - fromTimestamp, - }); - if (!isAlive(localOnUpdate, accountId)) break; - if (!swaps.length) break; - - swaps.reverse(); - - let isLastFinishedSwapUpdated = false; - let isPrevFinished = true; - - for (const swap of swaps) { - if (swap.cex) { - if (swap.cex.status === swapById[swap.id]?.cex!.status) { - continue; - } - } else if (swap.status === swapById[swap.id]?.status) { - continue; - } - - swapById[swap.id] = swap; - - const isFinished = SWAP_FINISHED_STATUSES.has(swap.status); - if (isFinished && isPrevFinished) { - fromTimestamp = swap.timestamp; - isLastFinishedSwapUpdated = true; - } - isPrevFinished = isFinished; - - if (swap.cex || swap.status !== 'completed') { - // Completed onchain swaps are processed in swapReplaceTransactions - onUpdate({ - type: 'newActivities', - accountId, - activities: [swapItemToActivity(swap)], - }); - } - } - - if (isLastFinishedSwapUpdated) { - await updateStoredAccount(accountId, { - lastFinishedSwapTimestamp: fromTimestamp, - }); - } - } catch (err) { - logDebugError('setupSwapPolling', err); - } - - await pauseOrFocus(SWAP_POLLING_INTERVAL, SWAP_POLLING_INTERVAL_WHEN_NOT_FOCUSED); - } - - if (accountId === swapPollingAccountId) { - swapPollingAccountId = undefined; - } -} - function isAlive(localOnUpdate: OnApiUpdate, accountId: string) { return isUpdaterAlive(localOnUpdate) && isAccountActive(accountId); } -async function getActualLastFinishedSwapTimestamp(accountId: string, address: string) { - const swaps = await swapGetHistory(address, {}); - - swaps.reverse(); - - let timestamp = Date.now(); - for (const swap of swaps) { - if (SWAP_FINISHED_STATUSES.has(swap.status)) { - timestamp = swap.timestamp; - } else { - break; - } - } - - await updateStoredAccount(accountId, { - lastFinishedSwapTimestamp: timestamp, - }); - - return timestamp; -} - function logAndRescue(err: Error) { logDebugError('Polling error', err); diff --git a/src/api/methods/staking.ts b/src/api/methods/staking.ts index a834e639..12170af2 100644 --- a/src/api/methods/staking.ts +++ b/src/api/methods/staking.ts @@ -111,8 +111,8 @@ export async function submitUnstake( } export async function getBackendStakingState(accountId: string): Promise { - const { address, ledger } = await fetchStoredAccount(accountId); - const state = await fetchBackendStakingState(address, Boolean(ledger)); + const { address } = await fetchStoredAccount(accountId); + const state = await fetchBackendStakingState(address); return { ...state, nominatorsPool: { @@ -123,7 +123,7 @@ export async function getBackendStakingState(accountId: string): Promise { +export async function fetchBackendStakingState(address: string): Promise { const cacheItem = backendStakingStateByAddress[address]; if (cacheItem && cacheItem[0] > Date.now()) { return cacheItem[1]; @@ -136,9 +136,7 @@ export async function fetchBackendStakingState(address: string, isLedger: boolea 'X-App-Env': APP_ENV, }; - const stakingState = await callBackendGet(`/staking/state/${address}`, { - isLedger, - }, headers); + const stakingState = await callBackendGet(`/staking/state/${address}`, headers); stakingState.balance = fromDecimal(stakingState.balance); stakingState.totalProfit = fromDecimal(stakingState.totalProfit); @@ -178,3 +176,11 @@ export async function tryUpdateStakingCommonData() { logDebugError('tryUpdateLiquidStakingState', err); } } + +export async function getStakingState(accountId: string) { + const blockchain = blockchains[resolveBlockchainKey(accountId)!]; + const backendState = await getBackendStakingState(accountId); + const state = await blockchain.getStakingState(accountId, backendState); + + return { backendState, state }; +} diff --git a/src/api/methods/swap.ts b/src/api/methods/swap.ts index f1fe781e..af0ef6e6 100644 --- a/src/api/methods/swap.ts +++ b/src/api/methods/swap.ts @@ -302,6 +302,18 @@ function buildSwapHistoryRange(transactions: ApiTransaction[]): SwapHistoryRange }; } +export async function fetchSwaps(accountId: string, ids: string[]) { + const address = await fetchStoredAddress(accountId); + const results = await Promise.allSettled( + ids.map((id) => swapGetHistoryItem(address, id.replace('swap:', ''))), + ); + + return results + .map((result) => (result.status === 'fulfilled' ? result.value : undefined)) + .filter(Boolean) + .map(swapItemToActivity); +} + export function swapItemToActivity(swap: ApiSwapHistoryItem): ApiSwapActivity { return { ...swap, @@ -355,7 +367,7 @@ export function swapGetHistoryByRanges(address: string, ranges: SwapHistoryRange return callBackendPost(`/swap/history-ranges/${address}`, { ranges }); } -export function swapGetHistoryItem(address: string, id: number): Promise { +export function swapGetHistoryItem(address: string, id: string): Promise { return callBackendGet(`/swap/history/${address}/${id}`); } diff --git a/src/api/tonConnect/index.ts b/src/api/tonConnect/index.ts index f72a6846..9993c7b9 100644 --- a/src/api/tonConnect/index.ts +++ b/src/api/tonConnect/index.ts @@ -71,6 +71,8 @@ import * as errors from './errors'; import { UnknownAppError } from './errors'; import { isValidString, isValidUrl } from './utils'; +const BLANK_GIF_DATA_URL = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; + const ton = blockchains.ton; let resolveInit: AnyFunction; @@ -613,7 +615,8 @@ export async function fetchDappMetadata(manifestUrl: string, origin?: string): P const data = await fetchJsonMetadata(manifestUrl); const { url, name, iconUrl } = await data; - if (!isValidUrl(url) || !isValidString(name) || !isValidUrl(iconUrl)) { + const safeIconUrl = iconUrl.startsWith('data:') ? BLANK_GIF_DATA_URL : iconUrl; + if (!isValidUrl(url) || !isValidString(name) || !isValidUrl(safeIconUrl)) { throw new Error('Invalid data'); } @@ -621,7 +624,7 @@ export async function fetchDappMetadata(manifestUrl: string, origin?: string): P origin: origin ?? new URL(url).origin, url, name, - iconUrl, + iconUrl: safeIconUrl, manifestUrl, }; } catch (err) { diff --git a/src/api/tonConnect/sse.ts b/src/api/tonConnect/sse.ts index a0c73483..e8380c1d 100644 --- a/src/api/tonConnect/sse.ts +++ b/src/api/tonConnect/sse.ts @@ -148,7 +148,7 @@ export async function resetupSseConnection() { return result; }, [] as SseDapp[]); - const clientIds = extractKey(sseDapps, 'clientId'); + const clientIds = extractKey(sseDapps, 'clientId').filter(Boolean); if (!clientIds.length) { return; } diff --git a/src/api/tonConnect/utils.ts b/src/api/tonConnect/utils.ts index 1eff120a..02bffc2b 100644 --- a/src/api/tonConnect/utils.ts +++ b/src/api/tonConnect/utils.ts @@ -3,7 +3,7 @@ export function isValidString(value: any, maxLength = 100) { } export function isValidUrl(url: string) { - const isString = isValidString(url, 150); + const isString = isValidString(url, 255); if (!isString) return false; try { diff --git a/src/api/types/storage.ts b/src/api/types/storage.ts index 13037767..6dde2639 100644 --- a/src/api/types/storage.ts +++ b/src/api/types/storage.ts @@ -10,7 +10,6 @@ export interface ApiAccount { deviceId?: string; deviceName?: string; }; - lastFinishedSwapTimestamp?: number; authToken?: string; isInitialized?: boolean; } diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index d57981a8..fbaa1dbb 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -27,6 +27,7 @@ export type ApiUpdateNewActivities = { type: 'newActivities'; accountId: string; activities: ApiActivity[]; + noForward?: boolean; // Forbid cyclic update redirection to/from NBS }; export type ApiUpdateNewLocalTransaction = { diff --git a/src/components/App.tsx b/src/components/App.tsx index 470d8eb7..7494f66e 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -40,7 +40,9 @@ import QrScannerModal from './main/modals/QrScannerModal'; import SignatureModal from './main/modals/SignatureModal'; import SwapActivityModal from './main/modals/SwapActivityModal'; import TransactionModal from './main/modals/TransactionModal'; +import UnhideNftModal from './main/modals/UnhideNftModal'; import Notifications from './main/Notifications'; +import MediaViewer from './mediaViewer/MediaViewer'; import Settings from './settings/Settings'; import SettingsModal from './settings/SettingsModal'; import SwapModal from './swap/SwapModal'; @@ -60,6 +62,7 @@ interface StateProps { isQrScannerOpen?: boolean; isHardwareModalOpen?: boolean; areSettingsOpen?: boolean; + isMediaViewerOpen?: boolean; } const APP_UPDATE_INTERVAL = (IS_ELECTRON && !IS_LINUX) || IS_ANDROID_DIRECT @@ -75,6 +78,7 @@ function App({ isHardwareModalOpen, isQrScannerOpen, areSettingsOpen, + isMediaViewerOpen, }: StateProps) { // return ; const { @@ -176,7 +180,7 @@ function App({ @@ -191,6 +195,7 @@ function App({ )} + {!isInactive && ( <> @@ -207,6 +212,7 @@ function App({ + {!IS_DELEGATED_BOTTOM_SHEET && } {IS_CAPACITOR && ( { isBackupWalletModalOpen: global.isBackupWalletModalOpen, isHardwareModalOpen: global.isHardwareModalOpen, areSettingsOpen: global.areSettingsOpen, + isMediaViewerOpen: Boolean(global.mediaViewer.mediaId), isQrScannerOpen: global.isQrScannerOpen, }; })(App)); diff --git a/src/components/dapps/Dapp.module.scss b/src/components/dapps/Dapp.module.scss index 3ff252b4..5635e00a 100644 --- a/src/components/dapps/Dapp.module.scss +++ b/src/components/dapps/Dapp.module.scss @@ -269,10 +269,15 @@ line-height: 0.5rem; } +.iconLedger, .accountAddress { color: var(--color-card-second-text); } +.iconLedger { + margin-inline-end: 0.25rem; +} + .description { font-size: 0.9375rem; } diff --git a/src/components/dapps/DappConnectModal.tsx b/src/components/dapps/DappConnectModal.tsx index 958a88cb..6b5b1752 100644 --- a/src/components/dapps/DappConnectModal.tsx +++ b/src/components/dapps/DappConnectModal.tsx @@ -44,6 +44,7 @@ interface StateProps { isTonAppConnected?: boolean; } +const HARDWARE_ACCOUNT_ADDRESS_SHIFT = 3; const ACCOUNT_ADDRESS_SHIFT = 4; const ACCOUNT_ADDRESS_SHIFT_END = 4; @@ -91,16 +92,18 @@ function DappConnectModal({ const handleSubmit = useLastCallback(() => { closeConfirm(); + const { isHardware } = accounts![selectedAccount]; + const { isPasswordRequired, isAddressRequired } = requiredPermissions || {}; - if (!requiredProof) { + if (!requiredProof || (!isHardware && isAddressRequired && !isPasswordRequired)) { submitDappConnectRequestConfirm({ accountId: selectedAccount, }); cancelDappConnectRequestConfirm(); - } else if (accounts![currentAccountId].isHardware && requiredProof) { + } else if (isHardware) { setDappConnectRequestState({ state: DappConnectState.ConnectHardware }); - } else if (requiredPermissions?.isPasswordRequired) { + } else { // The confirmation window must be closed before the password screen is displayed requestAnimationFrame(() => { setDappConnectRequestState({ state: DappConnectState.Password }); @@ -127,7 +130,7 @@ function DappConnectModal({ function renderAccount(accountId: string, address: string, title?: string, isHardware?: boolean) { const isActive = accountId === selectedAccount; - const onClick = isActive || isLoading || isHardware ? undefined : () => setSelectedAccount(accountId); + const onClick = isActive || isLoading ? undefined : () => setSelectedAccount(accountId); const fullClassName = buildClassName( styles.account, isActive && styles.account_current, @@ -143,8 +146,13 @@ function DappConnectModal({ > {title && {title}}
+ {isHardware && } - {shortenAddress(address, ACCOUNT_ADDRESS_SHIFT, ACCOUNT_ADDRESS_SHIFT_END)} + {shortenAddress( + address, + isHardware ? HARDWARE_ACCOUNT_ADDRESS_SHIFT : ACCOUNT_ADDRESS_SHIFT, + ACCOUNT_ADDRESS_SHIFT_END, + )}
diff --git a/src/components/dapps/DappTransferModal.tsx b/src/components/dapps/DappTransferModal.tsx index a418e1e2..984ef360 100644 --- a/src/components/dapps/DappTransferModal.tsx +++ b/src/components/dapps/DappTransferModal.tsx @@ -38,6 +38,7 @@ interface StateProps { hardwareState?: HardwareConnectState; isLedgerConnected?: boolean; isTonAppConnected?: boolean; + isMediaViewerOpen?: boolean; } function DappTransferModal({ @@ -53,6 +54,7 @@ function DappTransferModal({ hardwareState, isLedgerConnected, isTonAppConnected, + isMediaViewerOpen, }: StateProps) { const { setDappTransferScreen, @@ -133,7 +135,7 @@ function DappTransferModal({ function renderPassword(isActive: boolean) { return ( <> - {!IS_CAPACITOR && } + {!IS_CAPACITOR && } { hardwareState, isLedgerConnected, isTonAppConnected, + isMediaViewerOpen: Boolean(global.mediaViewer.mediaId), }; })(DappTransferModal)); diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index d26831be..9a293d07 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -14,14 +14,16 @@ import { setStatusBarStyle } from '../../util/switchTheme'; import { IS_DELEGATED_BOTTOM_SHEET, IS_TOUCH_ENV, REM } from '../../util/windowEnvironment'; import windowSize from '../../util/windowSize'; +import useBackgroundMode, { isBackgroundModeActive } from '../../hooks/useBackgroundMode'; import { useOpenFromMainBottomSheet } from '../../hooks/useDelegatedBottomSheet'; import { useDeviceScreen } from '../../hooks/useDeviceScreen'; import useEffectOnce from '../../hooks/useEffectOnce'; +import useFlag from '../../hooks/useFlag'; +import useInterval from '../../hooks/useInterval'; import useLastCallback from '../../hooks/useLastCallback'; import usePreventPinchZoomGesture from '../../hooks/usePreventPinchZoomGesture'; import useShowTransition from '../../hooks/useShowTransition'; -import MediaViewer from '../mediaViewer/MediaViewer'; import ReceiveModal from '../receive/ReceiveModal'; import StakeModal from '../staking/StakeModal'; import StakingInfoModal from '../staking/StakingInfoModal'; @@ -54,6 +56,8 @@ type StateProps = { }; const STICKY_CARD_INTERSECTION_THRESHOLD = -3.75 * REM; +const UPDATE_SWAPS_INTERVAL_NOT_FOCUSED = 15000; // 15 sec +const UPDATE_SWAPS_INTERVAL = 3000; // 3 sec function Main({ isActive, @@ -77,6 +81,7 @@ function Main({ setLandscapeActionsActiveTabIndex, loadExploreSites, openReceiveModal, + updatePendingSwaps, } = getActions(); // eslint-disable-next-line no-null/no-null @@ -86,6 +91,9 @@ function Main({ const [canRenderStickyCard, setCanRenderStickyCard] = useState(false); const [shouldRenderDarkStatusBar, setShouldRenderDarkStatusBar] = useState(false); const safeAreaTop = IS_CAPACITOR ? getStatusBarHeight() : windowSize.get().safeAreaTop; + const [isFocused, markIsFocused, unmarkIsFocused] = useFlag(!isBackgroundModeActive()); + + useBackgroundMode(unmarkIsFocused, markIsFocused); useOpenFromMainBottomSheet('receive', openReceiveModal); usePreventPinchZoomGesture(isMediaViewerOpen); @@ -102,6 +110,8 @@ function Main({ setStatusBarStyle(shouldRenderDarkStatusBar); }, [shouldRenderDarkStatusBar]); + useInterval(updatePendingSwaps, isFocused ? UPDATE_SWAPS_INTERVAL : UPDATE_SWAPS_INTERVAL_NOT_FOCUSED); + useEffect(() => { if (!isPortrait || !isActive) { setCanRenderStickyCard(false); @@ -229,7 +239,6 @@ function Main({ - {IS_ANDROID_DIRECT && } diff --git a/src/components/main/modals/TransactionModal.tsx b/src/components/main/modals/TransactionModal.tsx index 4ad85486..a1fe4fdc 100644 --- a/src/components/main/modals/TransactionModal.tsx +++ b/src/components/main/modals/TransactionModal.tsx @@ -60,6 +60,7 @@ type StateProps = { isUnstakeRequested?: boolean; isLongUnstakeRequested?: boolean; stakingStatus?: StakingStatus; + isMediaViewerOpen?: boolean; }; const enum SLIDES { initial, @@ -76,6 +77,7 @@ function TransactionModal({ isUnstakeRequested, isLongUnstakeRequested, stakingStatus, + isMediaViewerOpen, }: StateProps) { const { startTransfer, @@ -113,7 +115,11 @@ function TransactionModal({ const [, transactionHash] = (id || '').split(':'); const isStaking = renderedTransaction?.type === 'stake' || renderedTransaction?.type === 'unstake'; const isUnstaking = renderedTransaction?.type === 'unstake'; - const isNftTransfer = renderedTransaction?.type === 'nftTransferred' || renderedTransaction?.type === 'nftReceived'; + const isNftTransfer = ( + renderedTransaction?.type === 'nftTransferred' + || renderedTransaction?.type === 'nftReceived' + || Boolean(renderedTransaction?.nft) + ); const token = slug ? tokensBySlug?.[slug] : undefined; const address = isIncoming ? fromAddress : toAddress; @@ -443,7 +449,6 @@ function TransactionModal({ isActive={isActive} error={passwordError} withCloseButton={IS_CAPACITOR} - operationType="transfer" containerClassName={IS_CAPACITOR ? styles.passwordFormContent : styles.passwordFormContentInModal} submitLabel={lang('Send')} onSubmit={handlePasswordSubmit} @@ -457,7 +462,7 @@ function TransactionModal({ return ( { addNftsToWhitelist({ addresses: [nftAddress!] }); - onClose(); + closeUnhideNftModal(); }); return (

{lang('$unhide_nft_warning', { name: {nftName} })}

- + @@ -51,4 +51,15 @@ function UnhideNftModal({ ); } -export default memo(UnhideNftModal); +export default memo(withGlobal((global): StateProps => { + const { + isUnhideNftModalOpen, + selectedNftToUnhide, + } = selectCurrentAccountState(global) ?? {}; + + return { + isOpen: isUnhideNftModalOpen, + nftAddress: selectedNftToUnhide?.address, + nftName: selectedNftToUnhide?.name, + }; +})(UnhideNftModal)); diff --git a/src/components/main/sections/Content/Activities.tsx b/src/components/main/sections/Content/Activities.tsx index 7da0a42c..81f3a9a3 100644 --- a/src/components/main/sections/Content/Activities.tsx +++ b/src/components/main/sections/Content/Activities.tsx @@ -8,7 +8,7 @@ import type { ApiActivity, ApiSwapAsset, ApiToken } from '../../../../api/types' import { ContentTab } from '../../../../global/types'; import { ANIMATED_STICKER_BIG_SIZE_PX, MIN_ASSETS_TAB_VIEW, TONCOIN_SLUG } from '../../../../config'; -import { getIsSwapId, getIsTinyTransaction, getIsTxIdLocal } from '../../../../global/helpers'; +import { getIsSwapId, getIsTinyOrScamTransaction, getIsTxIdLocal } from '../../../../global/helpers'; import { selectAccountSettings, selectCurrentAccountState, @@ -161,7 +161,7 @@ function Activities({ && (!slug || activity.slug === slug) && ( !areTinyTransfersHidden - || !getIsTinyTransaction(activity, tokensBySlug![activity.slug]) + || !getIsTinyOrScamTransaction(activity, tokensBySlug![activity.slug]) || exceptionSlugs?.includes(activity.slug) ), ); @@ -236,14 +236,18 @@ function Activities({ const activityDayStart = getDayStartAt(activity.timestamp); const isNewDay = lastActivityDayStart !== activityDayStart; const isNftTransfer = activity.kind === 'transaction' - && (activity.type === 'nftTransferred' || activity.type === 'nftReceived'); + && ( + activity.type === 'nftTransferred' + || activity.type === 'nftReceived' + || Boolean(activity.nft) + ); const canCountComment = activity.kind === 'transaction' && (!activity.type || isNftTransfer); if (isNewDay) { lastActivityDayStart = activityDayStart; dateCount += 1; } - if (canCountComment && (activity.comment || activity.encryptedComment)) { + if (canCountComment && (activity.comment || activity.encryptedComment) && !activity.metadata?.isScam) { commentCount += 1; } diff --git a/src/components/main/sections/Content/Content.module.scss b/src/components/main/sections/Content/Content.module.scss index 28833311..46f1a6f4 100644 --- a/src/components/main/sections/Content/Content.module.scss +++ b/src/components/main/sections/Content/Content.module.scss @@ -41,7 +41,22 @@ top: 3.75rem; width: 100%; + max-width: 27rem; margin: 0 auto; + + :global(html.with-safe-area-top) & { + top: 2.625rem; + } + + // Fix for opera, dead zone of 37 pixels in extension window on windows + :global(html.is-windows.is-opera.is-extension) & { + top: 4.75rem; + } + + // Sticky header height - electron header height + :global(html.is-electron) & { + top: 0.75rem; + } } } @@ -49,51 +64,27 @@ height: 2.75rem; padding: 0 1.5rem; - .landscapeContainer & { - justify-content: flex-start; + &::after { + content: ''; - padding: 0 0.75rem; + position: absolute; + bottom: 0; + left: 50%; + transform: translate(-50%, -0.03125rem); + + width: 100vw; + height: 0.0625rem; - border-radius: var(--border-radius-default) var(--border-radius-default) 0 0; /* stylelint-disable-next-line plugin/whole-pixel */ box-shadow: 0 0.025rem 0 0 var(--color-separator); } - .portraitContainer & { - position: sticky; - top: 3.75rem; - - width: 100%; - max-width: 27rem; - - &::after { - content: ''; - - position: absolute; - bottom: 0; - left: 50%; - transform: translate(-50%, -0.015625rem); - - width: 100vw; - height: 0.0625rem; - - /* stylelint-disable-next-line plugin/whole-pixel */ - box-shadow: 0 0.025rem 0 0 var(--color-separator); - } - - :global(html.with-safe-area-top) & { - top: 2.625rem; - } + .landscapeContainer & { + justify-content: flex-start; - // Fix for opera, dead zone of 37 pixels in extension window on windows - :global(html.is-windows.is-opera.is-extension) & { - top: 4.75rem; - } + padding: 0 0.75rem; - // Sticky header height - electron header height - :global(html.is-electron) & { - top: 0.75rem; - } + border-radius: var(--border-radius-default) var(--border-radius-default) 0 0; } } diff --git a/src/components/main/sections/Content/NftCollectionHeader.module.scss b/src/components/main/sections/Content/NftCollectionHeader.module.scss index 2b35bbb0..31dc817c 100644 --- a/src/components/main/sections/Content/NftCollectionHeader.module.scss +++ b/src/components/main/sections/Content/NftCollectionHeader.module.scss @@ -7,8 +7,21 @@ background: var(--color-background-first); border-radius: var(--border-radius-default) var(--border-radius-default) 0 0; - /* stylelint-disable-next-line plugin/whole-pixel */ - box-shadow: 0 0.025rem 0 0 var(--color-separator); + + &::after { + content: ''; + + position: absolute; + bottom: 0; + left: 50%; + transform: translate(-50%, -0.03125rem); + + width: 100vw; + height: 0.0625rem; + + /* stylelint-disable-next-line plugin/whole-pixel */ + box-shadow: 0 0.025rem 0 0 var(--color-separator); + } } .backButton { diff --git a/src/components/main/sections/Content/NftMenu.tsx b/src/components/main/sections/Content/NftMenu.tsx index 2361398e..702afe1a 100644 --- a/src/components/main/sections/Content/NftMenu.tsx +++ b/src/components/main/sections/Content/NftMenu.tsx @@ -1,8 +1,10 @@ -import React, { memo, useRef } from '../../../../lib/teact/teact'; +import React, { memo, useMemo, useRef } from '../../../../lib/teact/teact'; +import { withGlobal } from '../../../../global'; import type { ApiNft } from '../../../../api/types'; import type { IAnchorPosition } from '../../../../global/types'; +import { selectCurrentAccountState } from '../../../../global/selectors'; import buildClassName from '../../../../util/buildClassName'; import stopEvent from '../../../../util/stopEvent'; @@ -21,10 +23,22 @@ interface OwnProps { onClose: NoneToVoidFunction; } +interface StateProps { + blacklistedNftAddresses?: string[]; + whitelistedNftAddresses?: string[]; +} + function NftMenu({ - nft, menuPosition, onOpen, onClose, -}: OwnProps) { - const { menuItems, handleMenuItemSelect } = useNftMenu(nft); + nft, menuPosition, onOpen, onClose, blacklistedNftAddresses, whitelistedNftAddresses, +}: OwnProps & StateProps) { + const isNftBlackListed = useMemo(() => { + return blacklistedNftAddresses?.includes(nft.address); + }, [nft, blacklistedNftAddresses]); + const isNftWhiteListed = useMemo(() => { + return whitelistedNftAddresses?.includes(nft.address); + }, [nft, whitelistedNftAddresses]); + + const { menuItems, handleMenuItemSelect } = useNftMenu(nft, isNftBlackListed, isNftWhiteListed); // eslint-disable-next-line no-null/no-null const ref = useRef(null); const isOpen = Boolean(menuPosition); @@ -78,4 +92,11 @@ function NftMenu({ ); } -export default memo(NftMenu); +export default memo(withGlobal((global): StateProps => { + const accountState = selectCurrentAccountState(global) || {}; + const { blacklistedNftAddresses, whitelistedNftAddresses } = accountState; + return { + blacklistedNftAddresses, + whitelistedNftAddresses, + }; +})(NftMenu)); diff --git a/src/components/main/sections/Content/Transaction.tsx b/src/components/main/sections/Content/Transaction.tsx index 7ff66154..254c67a9 100644 --- a/src/components/main/sections/Content/Transaction.tsx +++ b/src/components/main/sections/Content/Transaction.tsx @@ -1,7 +1,9 @@ import type { Ref, RefObject } from 'react'; import React, { memo } from '../../../../lib/teact/teact'; +import { getActions } from '../../../../global'; import type { ApiToken, ApiTransactionActivity } from '../../../../api/types'; +import { MediaType } from '../../../../global/types'; import { TON_SYMBOL } from '../../../../config'; import { getIsTxIdLocal } from '../../../../global/helpers'; @@ -42,6 +44,7 @@ function Transaction({ isLast, onClick, }: OwnProps) { + const { openMediaViewer } = getActions(); const lang = useLang(); const { @@ -63,7 +66,7 @@ function Transaction({ const isUnstake = type === 'unstake'; const isUnstakeRequest = type === 'unstakeRequest'; const isStaking = isStake || isUnstake || isUnstakeRequest; - const isNftTransfer = type === 'nftTransferred' || type === 'nftReceived'; + const isNftTransfer = type === 'nftTransferred' || type === 'nftReceived' || Boolean(nft); const token = tokensBySlug?.[slug]; const address = isIncoming ? fromAddress : toAddress; @@ -75,6 +78,11 @@ function Transaction({ onClick(txId); }); + const handleNftClick = useLastCallback((event: React.MouseEvent) => { + event.stopPropagation(); + openMediaViewer({ mediaId: nft!.address, mediaType: MediaType.Nft, txId }); + }); + function getOperationName() { if (isStake) { return 'Staked'; @@ -93,7 +101,17 @@ function Transaction({ function renderNft() { return ( -
+
{nft!.name}
{nft!.name}
diff --git a/src/components/mediaViewer/Actions.tsx b/src/components/mediaViewer/Actions.tsx index ca228d6c..6d86d4d5 100644 --- a/src/components/mediaViewer/Actions.tsx +++ b/src/components/mediaViewer/Actions.tsx @@ -1,4 +1,4 @@ -import React, { memo } from '../../lib/teact/teact'; +import React, { memo, useMemo } from '../../lib/teact/teact'; import { withGlobal } from '../../global'; import type { ApiNft } from '../../api/types'; @@ -24,13 +24,24 @@ type OwnProps = { type StateProps = { nft?: ApiNft; + blacklistedNftAddresses?: string[]; + whitelistedNftAddresses?: string[]; }; -function Actions({ onClose, nft }: StateProps & OwnProps) { +function Actions({ + onClose, nft, blacklistedNftAddresses, whitelistedNftAddresses, +}: StateProps & OwnProps) { const lang = useLang(); const [isMenuOpen, openMenu, closeMenu] = useFlag(); - const { menuItems, handleMenuItemSelect } = useNftMenu(nft); + const isNftBlackListed = useMemo(() => { + return blacklistedNftAddresses?.includes(nft!.address); + }, [nft, blacklistedNftAddresses]); + const isNftWhiteListed = useMemo(() => { + return whitelistedNftAddresses?.includes(nft!.address); + }, [nft, whitelistedNftAddresses]); + + const { menuItems, handleMenuItemSelect } = useNftMenu(nft, isNftBlackListed, isNftWhiteListed); const handleSelect = useLastCallback((value: string) => { if (value === 'send') { @@ -83,7 +94,9 @@ export default memo(withGlobal((global, { mediaId }): StateProps => { const nft = byAddress?.[mediaId]; if (!nft) return {}; + const { blacklistedNftAddresses, whitelistedNftAddresses } = selectCurrentAccountState(global) || {}; + return { - nft, + nft, blacklistedNftAddresses, whitelistedNftAddresses, }; })(Actions)); diff --git a/src/components/mediaViewer/Media.tsx b/src/components/mediaViewer/Media.tsx index 069b9429..de3e644b 100644 --- a/src/components/mediaViewer/Media.tsx +++ b/src/components/mediaViewer/Media.tsx @@ -1,4 +1,6 @@ -import React, { memo, useEffect, useRef } from '../../lib/teact/teact'; +import React, { + memo, useEffect, useMemo, useRef, +} from '../../lib/teact/teact'; import { withGlobal } from '../../global'; import { MediaType } from '../../global/types'; @@ -8,9 +10,12 @@ import { selectCurrentAccountState } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import { useDeviceScreen } from '../../hooks/useDeviceScreen'; +import useLang from '../../hooks/useLang'; import styles from './MediaViewer.module.scss'; +import scamImg from '../../assets/scam.svg'; + interface OwnProps { // eslint-disable-next-line react/no-unused-prop-types mediaId: string; @@ -21,11 +26,14 @@ interface StateProps { thumbnail?: string; image?: string; description?: string; + isScam?: boolean; + whitelistedMediaIds?: string[]; } function Media({ - alt, thumbnail, image, description, + mediaId, alt, thumbnail, image, description, isScam, whitelistedMediaIds, }: OwnProps & StateProps) { + const lang = useLang(); const src = image || thumbnail; // eslint-disable-next-line no-null/no-null const ref = useRef(null); @@ -40,6 +48,10 @@ function Media({ }); }, [isPortrait]); + const isNftWhiteListed = useMemo(() => { + return whitelistedMediaIds?.includes(mediaId); + }, [mediaId, whitelistedMediaIds]); + return (
{alt} @@ -47,6 +59,7 @@ function Media({ {description && (
+ {isScam && !isNftWhiteListed && {lang('Scam')}} {description}
@@ -65,10 +78,14 @@ export default memo(withGlobal((global, { mediaId }): StateProps => { const nft = byAddress?.[mediaId]; if (!nft) return {}; + const { whitelistedNftAddresses } = selectCurrentAccountState(global) || {}; + return { alt: nft.name, thumbnail: nft.thumbnail, image: nft.image, description: nft.description, + isScam: nft.isScam, + whitelistedMediaIds: whitelistedNftAddresses, }; })(Media)); diff --git a/src/components/mediaViewer/MediaViewer.module.scss b/src/components/mediaViewer/MediaViewer.module.scss index 69e9b13d..dbeb2f4c 100644 --- a/src/components/mediaViewer/MediaViewer.module.scss +++ b/src/components/mediaViewer/MediaViewer.module.scss @@ -480,3 +480,8 @@ object-fit: cover; } + +.scamImage { + width: 2.375rem; + margin-right: 0.25rem; +} diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index 03396947..ee23a955 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -28,6 +28,9 @@ import styles from './MediaViewer.module.scss'; interface StateProps { mediaId?: string; + txId?: string; + hiddenNfts?: 'user' | 'scam'; + noGhostAnimation?: boolean; mediaIds: string[]; mediaUrl?: string; mediaType: MediaType; @@ -37,16 +40,19 @@ interface StateProps { } function MediaViewer({ - mediaId, mediaIds, mediaType, mediaUrl, withAnimation, mediaByIds, blacklistedIds, + mediaId, mediaIds, mediaType, mediaUrl, withAnimation, mediaByIds, blacklistedIds, txId, hiddenNfts, noGhostAnimation, }: StateProps) { const { closeMediaViewer, openMediaViewer } = getActions(); const isOpen = Boolean(mediaId); const lang = useLang(); const prevMediaId = usePrevious(mediaId); + const prevTxId = usePrevious(txId); + const prevHiddenNfts = usePrevious(hiddenNfts); + const prevNoGhostAnimation = usePrevious(noGhostAnimation); const headerAnimation = withAnimation ? 'slideFade' : 'none'; - const shouldAnimateOpening = withAnimation && isOpen && !prevMediaId; - const shouldAnimateClosing = withAnimation && !isOpen && !!prevMediaId; + const shouldAnimateOpening = withAnimation && isOpen && !prevMediaId && !noGhostAnimation; + const shouldAnimateClosing = withAnimation && !isOpen && !!prevMediaId && !prevNoGhostAnimation; const handleClose = useLastCallback(() => closeMediaViewer()); @@ -80,13 +86,24 @@ function MediaViewer({ useEffect(() => { if (shouldAnimateOpening) { dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY); - animateOpening(mediaType, mediaId, mediaUrl); + animateOpening(mediaType, mediaId, mediaUrl, txId, hiddenNfts); } if (shouldAnimateClosing) { dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY); - animateClosing(mediaType, prevMediaId); + animateClosing(mediaType, prevMediaId, prevTxId, prevHiddenNfts); } - }, [shouldAnimateOpening, shouldAnimateClosing, mediaId, mediaType, mediaUrl, prevMediaId]); + }, [ + shouldAnimateOpening, + shouldAnimateClosing, + mediaId, + mediaType, + mediaUrl, + prevMediaId, + txId, + prevTxId, + hiddenNfts, + prevHiddenNfts, + ]); return ( { - const { mediaId, mediaType = MediaType.Nft } = global.mediaViewer || {}; + const { + mediaId, mediaType = MediaType.Nft, txId, hiddenNfts, noGhostAnimation, + } = global.mediaViewer || {}; const animationLevel = global.settings?.animationLevel; const accountState = selectCurrentAccountState(global); @@ -137,6 +156,9 @@ export default memo(withGlobal((global): StateProps => { return { mediaId, + txId, + hiddenNfts, + noGhostAnimation, mediaIds, mediaType, mediaUrl, diff --git a/src/components/mediaViewer/helpers/ghostAnimation.ts b/src/components/mediaViewer/helpers/ghostAnimation.ts index f02614f9..718196cd 100644 --- a/src/components/mediaViewer/helpers/ghostAnimation.ts +++ b/src/components/mediaViewer/helpers/ghostAnimation.ts @@ -15,8 +15,10 @@ export const ANIMATION_DURATION = 200; // Header height + bottom padding, keep in sync with styles.image max-height const OCCUPIED_HEIGHT = 14 * REM; -export function animateOpening(type: MediaType, mediaId: string, mediaUrl?: string) { - const { image: fromImage } = getNode(type, mediaId); +export function animateOpening( + type: MediaType, mediaId: string, mediaUrl?: string, txId?: string, hiddenNfts?: 'user' | 'scam', +) { + const { image: fromImage } = getNode(type, mediaId, txId, hiddenNfts); if (!fromImage || !mediaUrl) { return; } @@ -79,8 +81,8 @@ export function animateOpening(type: MediaType, mediaId: string, mediaUrl?: stri }); } -export function animateClosing(type: MediaType, mediaId: string) { - const { container, image: toImage } = getNode(type, mediaId); +export function animateClosing(type: MediaType, mediaId: string, txId?: string, hiddenNfts?: 'user' | 'scam') { + const { container, image: toImage } = getNode(type, mediaId, txId, hiddenNfts); const fromImage = document.querySelector(`.${styles.slide_active} img`); if (!fromImage || !toImage) { return; @@ -165,12 +167,16 @@ export function animateClosing(type: MediaType, mediaId: string) { }); } -function getNode(type: MediaType, mediaId: string) { +function getNode(type: MediaType, mediaId: string, txId?: string, hiddenNfts?: 'user' | 'scam') { let image: HTMLImageElement | undefined; let container: HTMLElement | undefined; if (type === MediaType.Nft) { container = document.querySelector( - `.nfts-container > .Transition_slide-active [data-nft-address="${mediaId}"]`, + txId + ? `.transaction-nft[data-tx-id="${txId}"][data-nft-address="${mediaId}"]` + : hiddenNfts + ? `.hidden-nfts-${hiddenNfts} [data-nft-address="${mediaId}"]` + : `.nfts-container > .Transition_slide-active [data-nft-address="${mediaId}"]`, ) as HTMLElement; image = container?.querySelector('img') as HTMLImageElement; } diff --git a/src/components/mediaViewer/hooks/useNftMenu.ts b/src/components/mediaViewer/hooks/useNftMenu.ts index 84e0fae1..af658548 100644 --- a/src/components/mediaViewer/hooks/useNftMenu.ts +++ b/src/components/mediaViewer/hooks/useNftMenu.ts @@ -54,6 +54,14 @@ const HIDE_ITEM: DropdownItem = { name: 'Hide', value: 'hide', }; +const NOT_A_SCAM: DropdownItem = { + name: 'Not a Scam', + value: 'not_a_scam', +}; +const UNHIDE: DropdownItem = { + name: 'Unhide', + value: 'unhide', +}; const BURN_ITEM: DropdownItem = { name: 'Burn', value: 'burn', @@ -65,9 +73,16 @@ const SELECT_ITEM: DropdownItem = { withSeparator: true, }; -export default function useNftMenu(nft?: ApiNft) { +export default function useNftMenu(nft?: ApiNft, isNftBlacklisted?: boolean, isNftWhitelisted?: boolean) { const { - startTransfer, selectNfts, openNftCollection, burnNfts, addNftsToBlacklist, + startTransfer, + selectNfts, + openNftCollection, + burnNfts, + addNftsToBlacklist, + addNftsToWhitelist, + closeMediaViewer, + openUnhideNftModal, } = getActions(); const handleMenuItemSelect = useLastCallback((value: string) => { @@ -78,6 +93,8 @@ export default function useNftMenu(nft?: ApiNft) { isPortrait: getIsPortrait(), nfts: [nft!], }); + closeMediaViewer(); + break; } @@ -125,12 +142,27 @@ export default function useNftMenu(nft?: ApiNft) { case 'hide': { addNftsToBlacklist({ addresses: [nft!.address] }); + closeMediaViewer(); + + break; + } + + case 'not_a_scam': { + openUnhideNftModal({ address: nft!.address, name: nft!.name }); + + break; + } + + case 'unhide': { + addNftsToWhitelist({ addresses: [nft!.address] }); + closeMediaViewer(); break; } case 'burn': { burnNfts({ nfts: [nft!] }); + closeMediaViewer(); break; } @@ -152,13 +184,15 @@ export default function useNftMenu(nft?: ApiNft) { GETGEMS_ITEM, TON_EXPLORER_ITEM, ...(nft.collectionAddress ? [COLLECTION_ITEM] : []), - HIDE_ITEM, + ...((!nft.isScam && !isNftBlacklisted) || isNftWhitelisted ? [HIDE_ITEM] : []), + ...(nft.isScam && !isNftWhitelisted ? [NOT_A_SCAM] : []), + ...(!nft.isScam && isNftBlacklisted ? [UNHIDE] : []), ...(!nft.isOnSale ? [ BURN_ITEM, SELECT_ITEM, ] : []), ]; - }, [nft]); + }, [nft, isNftBlacklisted, isNftWhitelisted]); return { menuItems, handleMenuItemSelect }; } diff --git a/src/components/settings/SettingsAssets.tsx b/src/components/settings/SettingsAssets.tsx index d2c8832c..51323b29 100644 --- a/src/components/settings/SettingsAssets.tsx +++ b/src/components/settings/SettingsAssets.tsx @@ -118,12 +118,11 @@ function SettingsAssets({ } = useMemo(() => { const nfts = Object.values(nftsByAddress || {}); const blacklistedNftAddressesSet = new Set(blacklistedNftAddresses); - const hiddenByUserNfts = nfts.filter((nft) => blacklistedNftAddressesSet.has(nft.address)); - const probablyScamNfts = nfts.filter((nft) => nft.isHidden); + const hiddenNfts = new Set(nfts.filter((nft) => blacklistedNftAddressesSet.has(nft.address) || nft.isHidden)); return { - shouldRenderHiddenNftsSection: hiddenByUserNfts.length > 0 || probablyScamNfts.length > 0, - hiddenNftsCount: hiddenByUserNfts.length, + shouldRenderHiddenNftsSection: Boolean(hiddenNfts.size), + hiddenNftsCount: hiddenNfts.size, }; }, [nftsByAddress, blacklistedNftAddresses]); diff --git a/src/components/settings/SettingsHiddenNfts.tsx b/src/components/settings/SettingsHiddenNfts.tsx index 62b0dc33..347fbd11 100644 --- a/src/components/settings/SettingsHiddenNfts.tsx +++ b/src/components/settings/SettingsHiddenNfts.tsx @@ -1,20 +1,21 @@ -import React, { memo, useMemo, useState } from '../../lib/teact/teact'; -import { getActions, withGlobal } from '../../global'; +import React, { memo, useMemo } from '../../lib/teact/teact'; +import { withGlobal } from '../../global'; import type { ApiNft } from '../../api/types'; import { selectCurrentAccountState } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; +import { IS_DELEGATED_BOTTOM_SHEET, IS_DELEGATING_BOTTOM_SHEET, IS_ELECTRON } from '../../util/windowEnvironment'; +import { useDeviceScreen } from '../../hooks/useDeviceScreen'; import useHistoryBack from '../../hooks/useHistoryBack'; import useLang from '../../hooks/useLang'; -import useLastCallback from '../../hooks/useLastCallback'; import useScrolledState from '../../hooks/useScrolledState'; -import UnhideNftModal from '../main/modals/UnhideNftModal'; import Button from '../ui/Button'; import ModalHeader from '../ui/ModalHeader'; -import Switcher from '../ui/Switcher'; +import HiddenByUserNft from './nfts/HiddenByUserNft'; +import ProbablyScamNft from './nfts/ProbablyScamNft'; import styles from './Settings.module.scss'; @@ -29,7 +30,6 @@ interface StateProps { whitelistedNftAddresses?: string[]; orderedAddresses?: string[]; byAddress?: Record; - isUnhideNftModalOpen?: boolean; } function SettingsHiddenNfts({ @@ -40,14 +40,7 @@ function SettingsHiddenNfts({ whitelistedNftAddresses, orderedAddresses, byAddress, - isUnhideNftModalOpen, }: OwnProps & StateProps) { - const { - openUnhideNftModal, - removeNftSpecialStatus, - closeUnhideNftModal, - } = getActions(); - const lang = useLang(); useHistoryBack({ @@ -85,44 +78,15 @@ function SettingsHiddenNfts({ return new Set(whitelistedNftAddresses); }, [whitelistedNftAddresses]); - const [selectedNft, setSelectedNft] = useState<{ address: string; name: string } | undefined>(); - - const handleProbablyScamNftClick = useLastCallback((nft: ApiNft) => { - const isWhitelisted = whitelistedNftAddressesSet?.has(nft.address); - if (isWhitelisted) { - removeNftSpecialStatus({ address: nft.address }); - } else { - setSelectedNft({ address: nft.address, name: nft.name! }); - openUnhideNftModal(); - } - }); + const { isPortrait } = useDeviceScreen(); + const areSettingsInModal = !isPortrait || IS_ELECTRON || IS_DELEGATING_BOTTOM_SHEET || IS_DELEGATED_BOTTOM_SHEET; function renderHiddenByUserNfts() { return ( <>

{lang('Hidden By Me')}

-
- { - hiddenByUserNfts!.map((nft) => { - return ( -
removeNftSpecialStatus({ address: nft.address })} - key={nft.address} - > - {nft.name} -
- {nft.name} - { - nft.collectionName && {nft.collectionName} - } -
- - -
- ); - }) - } +
+ {hiddenByUserNfts!.map((nft) => )}
); @@ -132,32 +96,20 @@ function SettingsHiddenNfts({ return ( <>

{lang('Probably Scam')}

-
+
{ - probablyScamNfts!.map((nft) => { - const isWhitelisted = whitelistedNftAddressesSet?.has(nft.address); - return ( -
handleProbablyScamNftClick(nft)} + probablyScamNfts!.map( + (nft) => ( + - {nft.name} -
- {nft.name} - { - nft.collectionName && {nft.collectionName} - } -
- - -
- ); - }) + nft={nft} + isWhitelisted={whitelistedNftAddressesSet.has(nft.address)} + /> + ), + ) }

@@ -193,13 +145,6 @@ function SettingsHiddenNfts({ {Boolean(hiddenByUserNfts?.length) && renderHiddenByUserNfts()} {Boolean(probablyScamNfts?.length) && renderProbablyScamNfts()}

- -
); } @@ -208,7 +153,6 @@ export default memo(withGlobal((global): StateProps => { const { blacklistedNftAddresses, whitelistedNftAddresses, - isUnhideNftModalOpen, } = selectCurrentAccountState(global) ?? {}; const { orderedAddresses, @@ -219,6 +163,5 @@ export default memo(withGlobal((global): StateProps => { whitelistedNftAddresses, orderedAddresses, byAddress, - isUnhideNftModalOpen, }; })(SettingsHiddenNfts)); diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 65367ae8..d0167989 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -1,4 +1,5 @@ import React, { memo } from '../../lib/teact/teact'; +import { withGlobal } from '../../global'; import { IS_EXTENSION } from '../../config'; import buildClassName from '../../util/buildClassName'; @@ -8,13 +9,19 @@ import Modal from '../ui/Modal'; import styles from './Settings.module.scss'; -type OwnProps = { +interface OwnProps { children: React.ReactNode; isOpen?: boolean; onClose: () => void; -}; +} + +interface StateProps { + isMediaViewerOpen?: boolean; +} -function SettingsModal({ children, isOpen, onClose }: OwnProps) { +function SettingsModal({ + children, isOpen, onClose, isMediaViewerOpen, +}: OwnProps & StateProps) { const fullDialogClassName = buildClassName( styles.modalDialog, !(IS_ELECTRON || IS_EXTENSION) && styles.modalDialogWeb, @@ -23,7 +30,7 @@ function SettingsModal({ children, isOpen, onClose }: OwnProps) { return ( { + return { + isMediaViewerOpen: Boolean(global.mediaViewer.mediaId), + }; +})(SettingsModal)); diff --git a/src/components/settings/nfts/HiddenByUserNft.tsx b/src/components/settings/nfts/HiddenByUserNft.tsx new file mode 100644 index 00000000..525a9bfe --- /dev/null +++ b/src/components/settings/nfts/HiddenByUserNft.tsx @@ -0,0 +1,79 @@ +import { memo } from '../../../lib/teact/teact'; +import React from '../../../lib/teact/teactn'; +import { getActions } from '../../../global'; + +import { type ApiNft } from '../../../api/types'; +import { MediaType } from '../../../global/types'; + +import buildClassName from '../../../util/buildClassName'; +import { IS_DELEGATED_BOTTOM_SHEET, IS_DELEGATING_BOTTOM_SHEET, IS_ELECTRON } from '../../../util/windowEnvironment'; + +import { useDeviceScreen } from '../../../hooks/useDeviceScreen'; +import useFlag from '../../../hooks/useFlag'; +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useShowTransition from '../../../hooks/useShowTransition'; + +import Button from '../../ui/Button'; + +import styles from '../Settings.module.scss'; + +interface OwnProps { + nft: ApiNft; +} + +function HiddenByUserNft({ nft }: OwnProps) { + const { openMediaViewer, removeNftSpecialStatus } = getActions(); + const lang = useLang(); + + const [isNftHidden, , unmarkNftHidden] = useFlag(true); + + const handleUnhide = useLastCallback(() => { + removeNftSpecialStatus({ address: nft.address }); + }); + + const { + transitionClassNames, + } = useShowTransition(isNftHidden, handleUnhide); + + const { isPortrait } = useDeviceScreen(); + const areSettingsInModal = !isPortrait || IS_ELECTRON || IS_DELEGATING_BOTTOM_SHEET || IS_DELEGATED_BOTTOM_SHEET; + + function handleNftClick() { + openMediaViewer({ + mediaId: nft.address, mediaType: MediaType.Nft, noGhostAnimation: areSettingsInModal, hiddenNfts: 'user', + }); + } + + return ( +
+ {nft.name} +
+ {nft.name || lang('Untitled')} + { + nft.collectionName && {nft.collectionName} + } +
+ + +
+ ); +} + +export default memo(HiddenByUserNft); diff --git a/src/components/settings/nfts/ProbablyScamNft.tsx b/src/components/settings/nfts/ProbablyScamNft.tsx new file mode 100644 index 00000000..142c267e --- /dev/null +++ b/src/components/settings/nfts/ProbablyScamNft.tsx @@ -0,0 +1,73 @@ +import { memo } from '../../../lib/teact/teact'; +import React from '../../../lib/teact/teactn'; +import { getActions } from '../../../global'; + +import { type ApiNft } from '../../../api/types'; +import { MediaType } from '../../../global/types'; + +import { IS_DELEGATED_BOTTOM_SHEET, IS_DELEGATING_BOTTOM_SHEET, IS_ELECTRON } from '../../../util/windowEnvironment'; + +import { useDeviceScreen } from '../../../hooks/useDeviceScreen'; +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; + +import Switcher from '../../ui/Switcher'; + +import styles from '../Settings.module.scss'; + +interface OwnProps { + nft: ApiNft; + isWhitelisted?: boolean; +} + +function ProbablyScamNft({ nft, isWhitelisted }: OwnProps) { + const { openMediaViewer, removeNftSpecialStatus, openUnhideNftModal } = getActions(); + const lang = useLang(); + + const { isPortrait } = useDeviceScreen(); + const areSettingsInModal = !isPortrait || IS_ELECTRON || IS_DELEGATING_BOTTOM_SHEET || IS_DELEGATED_BOTTOM_SHEET; + + const handleNftClick = useLastCallback(() => { + openMediaViewer({ + mediaId: nft.address, mediaType: MediaType.Nft, noGhostAnimation: areSettingsInModal, hiddenNfts: 'scam', + }); + }); + + const handleSwitcherClick = useLastCallback((e: React.ChangeEvent) => { + e.stopPropagation(); + if (isWhitelisted) { + removeNftSpecialStatus({ address: nft.address }); + } else { + openUnhideNftModal({ address: nft.address, name: nft.name! }); + } + }); + + return ( +
+ {nft.name} +
+ {nft.name} + { + nft.collectionName && {nft.collectionName} + } +
+ + +
+ ); +} + +export default memo(ProbablyScamNft); diff --git a/src/components/staking/StakeModal.tsx b/src/components/staking/StakeModal.tsx index e8453656..c9f37047 100644 --- a/src/components/staking/StakeModal.tsx +++ b/src/components/staking/StakeModal.tsx @@ -79,6 +79,7 @@ function StakeModal({ }); const handleLedgerConnect = useLastCallback(() => { + setRenderedStakingAmount(amount); submitStakingHardware(); }); diff --git a/src/components/staking/UnstakeModal.tsx b/src/components/staking/UnstakeModal.tsx index 0159491a..70a52697 100644 --- a/src/components/staking/UnstakeModal.tsx +++ b/src/components/staking/UnstakeModal.tsx @@ -97,6 +97,7 @@ function UnstakeModal({ hardwareState, isLedgerConnected, isTonAppConnected, + amount, }: StateProps) { const { setStakingScreen, @@ -122,6 +123,7 @@ function UnstakeModal({ const [isInsufficientBalance, setIsInsufficientBalance] = useState(false); const [unstakeAmount, setUnstakeAmount] = useState(shouldUseNominators ? stakingBalance : undefined); + const [successUnstakeAmount, setSuccessUnstakeAmount] = useState(undefined); const shortBaseSymbol = getShortCurrencySymbol(baseCurrency); @@ -184,12 +186,14 @@ function UnstakeModal({ }); const handleTransferSubmit = useLastCallback((password: string) => { + setSuccessUnstakeAmount(amount); setRenderedBalance(tonToken?.amount); submitStakingPassword({ password, isUnstaking: true }); }); const handleLedgerConnect = useLastCallback(() => { + setSuccessUnstakeAmount(amount); submitStakingHardware({ isUnstaking: true }); }); @@ -481,10 +485,10 @@ function UnstakeModal({ {isLongUnstake && renderUnstakeTimer()} diff --git a/src/components/swap/Swap.module.scss b/src/components/swap/Swap.module.scss index 1c883bf2..7fa495c2 100644 --- a/src/components/swap/Swap.module.scss +++ b/src/components/swap/Swap.module.scss @@ -31,6 +31,10 @@ margin-bottom: 0.8125rem; } +.amountInputBuy { + margin-bottom: 0; +} + .inputLabel { margin-bottom: 0.3125rem; } @@ -193,7 +197,7 @@ z-index: 1; top: 50%; left: 50%; - transform: translate(-50%, -45%); + transform: translate(-50%, -31%); display: flex; align-items: center; diff --git a/src/components/swap/SwapInitial.tsx b/src/components/swap/SwapInitial.tsx index 6af2349f..cf29316d 100644 --- a/src/components/swap/SwapInitial.tsx +++ b/src/components/swap/SwapInitial.tsx @@ -534,7 +534,7 @@ function SwapInitial({ { @@ -32,12 +36,17 @@ function NftInfo({ nft, isStatic, withTonExplorer }: OwnProps) { ).join(''); }, [lang]); - const handleClick = () => { + const handleClickInfo = (event: React.MouseEvent) => { + event.stopPropagation(); const url = getTonExplorerNftUrl(nft!.address, getGlobal().settings.isTestnet)!; openUrl(url); }; + const handleClick = useLastCallback(() => { + openMediaViewer({ mediaId: nft!.address, mediaType: MediaType.Nft }); + }); + if (!nft) { return (
@@ -71,7 +80,18 @@ function NftInfo({ nft, isStatic, withTonExplorer }: OwnProps) {
{name} - {withTonExplorer && } + { + withTonExplorer && ( + + ) + }
{nft!.collectionName}
@@ -79,21 +99,14 @@ function NftInfo({ nft, isStatic, withTonExplorer }: OwnProps) { ); } - if (withTonExplorer) { - return ( - - ); - } - return ( -
+
{renderContent()}
); diff --git a/src/components/transfer/TransferModal.tsx b/src/components/transfer/TransferModal.tsx index 122e8127..ca8883ba 100644 --- a/src/components/transfer/TransferModal.tsx +++ b/src/components/transfer/TransferModal.tsx @@ -44,6 +44,7 @@ interface StateProps { hardwareState?: HardwareConnectState; isLedgerConnected?: boolean; isTonAppConnected?: boolean; + isMediaViewerOpen?: boolean; } const SCREEN_HEIGHT_FOR_FORCE_FULLSIZE_NBS = 762; // Computed empirically @@ -61,7 +62,7 @@ function TransferModal({ tokenSlug, nfts, sentNftsCount, - }, tokens, savedAddresses, hardwareState, isLedgerConnected, isTonAppConnected, + }, tokens, savedAddresses, hardwareState, isLedgerConnected, isTonAppConnected, isMediaViewerOpen, }: StateProps) { const { submitTransferConfirm, @@ -243,7 +244,7 @@ function TransferModal({ return ( { hardwareState, isLedgerConnected, isTonAppConnected, + isMediaViewerOpen: Boolean(global.mediaViewer.mediaId), }; })(TransferModal)); diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index e66f89d1..6a8f8ff4 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -28,6 +28,7 @@ type OwnProps = { isSmall?: boolean; isDestructive?: boolean; onClick?: NoneToVoidFunction; + shouldStopPropagation?: boolean; }; // Longest animation duration @@ -52,11 +53,15 @@ function Button({ isSmall, isDestructive, onClick, + shouldStopPropagation, }: OwnProps) { const [isClicked, setIsClicked] = useState(false); - const handleClick = useLastCallback(() => { + const handleClick = useLastCallback((event: React.MouseEvent) => { if (!isDisabled && onClick) { + if (shouldStopPropagation) { + event.stopPropagation(); + } onClick(); } diff --git a/src/components/ui/Modal.module.scss b/src/components/ui/Modal.module.scss index 45d97bb0..b3fafb4c 100644 --- a/src/components/ui/Modal.module.scss +++ b/src/components/ui/Modal.module.scss @@ -336,7 +336,7 @@ font-size: 1.0625rem; font-weight: 700; - line-height: 1; + line-height: 1.875rem; text-align: center; text-overflow: ellipsis; white-space: nowrap; diff --git a/src/components/ui/PasswordForm.tsx b/src/components/ui/PasswordForm.tsx index b3c028c4..6e7275fb 100644 --- a/src/components/ui/PasswordForm.tsx +++ b/src/components/ui/PasswordForm.tsx @@ -165,7 +165,7 @@ function PasswordForm({ case 'swap': return 'Confirm Swap'; default: - return 'Confirm Operation'; + return 'Confirm Action'; } } diff --git a/src/components/ui/Switcher.tsx b/src/components/ui/Switcher.tsx index c6768123..069d6f63 100644 --- a/src/components/ui/Switcher.tsx +++ b/src/components/ui/Switcher.tsx @@ -14,6 +14,7 @@ type OwnProps = { className?: string; onChange?: (e: ChangeEvent) => void; onCheck?: (isChecked: boolean) => void; + shouldStopPropagation?: boolean; }; function Switcher({ @@ -25,6 +26,7 @@ function Switcher({ className, onChange, onCheck, + shouldStopPropagation, }: OwnProps) { function handleChange(e: ChangeEvent) { if (onChange) { @@ -37,7 +39,13 @@ function Switcher({ } return ( -