diff --git a/changelogs/3.2.12.txt b/changelogs/3.2.12.txt new file mode 100644 index 00000000..619f4cd5 --- /dev/null +++ b/changelogs/3.2.12.txt @@ -0,0 +1 @@ +Bug fixes and performance improvements diff --git a/changelogs/3.3.0.txt b/changelogs/3.3.0.txt new file mode 100644 index 00000000..fb3f8d26 --- /dev/null +++ b/changelogs/3.3.0.txt @@ -0,0 +1,9 @@ +What’s New in v3.3? + +• Refined fees for full transparency on every transaction. + +• Explore 2.0: a dedicated tab with brand-new design, categories, and new apps. + +• Handy navigation with a new bottom tab bar for easy access to essential features. + +• Telegram Gifts in stunning HD quality at the highest frame rate. diff --git a/package-lock.json b/package-lock.json index d7ea6024..0fce8204 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mytonwallet", - "version": "3.2.11", + "version": "3.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mytonwallet", - "version": "3.2.11", + "version": "3.3.0", "license": "GPL-3.0-or-later", "dependencies": { "@awesome-cordova-plugins/core": "6.9.0", diff --git a/package.json b/package.json index edc6cd3a..63b7162f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mytonwallet", - "version": "3.2.11", + "version": "3.3.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 17ce9180..15a27998 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -3.2.11 +3.3.0 diff --git a/src/api/chains/ton/constants.ts b/src/api/chains/ton/constants.ts index 73060ca6..7f100bb8 100644 --- a/src/api/chains/ton/constants.ts +++ b/src/api/chains/ton/constants.ts @@ -7,13 +7,14 @@ export const TON_BIP39_PATH = "m/44'/607'/0'"; export const ONE_TON = 1_000_000_000n; export const TOKEN_TRANSFER_AMOUNT = 50000000n; // 0.05 TON export const TINY_TOKEN_TRANSFER_AMOUNT = 18000000n; // 0.018 TON -export const TOKEN_REAL_TRANSFER_AMOUNT = 32100000n; // 0.0321 TON -export const TINY_TOKEN_REAL_TRANSFER_AMOUNT = 8000000n; // 0.008 TON -export const TINIEST_TOKEN_REAL_TRANSFER_AMOUNT = 3000000n; // 0.003 TON +export const TOKEN_TRANSFER_REAL_AMOUNT = 32100000n; // 0.0321 TON +export const TINY_TOKEN_TRANSFER_REAL_AMOUNT = 8000000n; // 0.008 TON +export const TINIEST_TOKEN_TRANSFER_REAL_AMOUNT = 3000000n; // 0.003 TON export const TOKEN_TRANSFER_FORWARD_AMOUNT = 1n; // 0.000000001 TON export const CLAIM_MINTLESS_AMOUNT = 20000000n; // 0.02 TON export const NFT_TRANSFER_AMOUNT = 100000000n; // 0.1 TON +export const NFT_TRANSFER_REAL_AMOUNT = 5000000n; // 0.005 TON export const NFT_TRANSFER_FORWARD_AMOUNT = 1n; // 0.000000001 TON /** * When the NFT contract handles the payload we send, it simply adds its data to the payload. If the resulting payload diff --git a/src/api/chains/ton/nfts.test.ts b/src/api/chains/ton/nfts.test.ts new file mode 100644 index 00000000..9de07a9e --- /dev/null +++ b/src/api/chains/ton/nfts.test.ts @@ -0,0 +1,19 @@ +import { calculateNftTransferFee } from './nfts'; + +describe('calculateNftTransferFee', () => { + it('calculates for 1 NFT', () => { + expect(calculateNftTransferFee(1, 1, 2939195n, 10000000n)).toBe(12939195n); + }); + + it('calculates for batch', () => { + expect(calculateNftTransferFee(3, 3, 6001837n, 100000000n)).toBe(306001837n); + }); + + it('calculates for multiple complete and 1 incomplete batch', () => { + expect(calculateNftTransferFee(9, 4, 7533158n, 1000000000n)).toBe(9018832895n); + }); + + it('calculates for multiple complete batchs', () => { + expect(calculateNftTransferFee(12, 4, 7533158n, 10000000000n)).toBe(120022599474n); + }); +}); diff --git a/src/api/chains/ton/nfts.ts b/src/api/chains/ton/nfts.ts index 5e88544b..f6e68125 100644 --- a/src/api/chains/ton/nfts.ts +++ b/src/api/chains/ton/nfts.ts @@ -13,6 +13,7 @@ import { NOTCOIN_VOUCHERS_ADDRESS, } from '../../../config'; import { parseAccountId } from '../../../util/account'; +import { bigintMultiplyToNumber } from '../../../util/bigint'; import { compact } from '../../../util/iteratees'; import { generateQueryId } from './util'; import { buildNft } from './util/metadata'; @@ -25,6 +26,7 @@ import { NFT_PAYLOAD_SAFE_MARGIN, NFT_TRANSFER_AMOUNT, NFT_TRANSFER_FORWARD_AMOUNT, + NFT_TRANSFER_REAL_AMOUNT, NftOpCode, } from './constants'; import { checkMultiTransactionDraft, checkToAddress, submitMultiTransfer } from './transactions'; @@ -120,75 +122,69 @@ export async function getNftUpdates(accountId: string, fromSec: number) { export async function checkNftTransferDraft(options: { accountId: string; - nftAddresses: string[]; + nfts: ApiNft[]; toAddress: string; comment?: string; }): Promise { - const { accountId, nftAddresses, comment } = options; + const { accountId, nfts, comment } = options; let { toAddress } = options; const { network } = parseAccountId(accountId); const { address: fromAddress } = await fetchStoredTonWallet(accountId); - const checkAddressResult = await checkToAddress(network, toAddress); - - if ('error' in checkAddressResult) { - return checkAddressResult; + const result: ApiCheckTransactionDraftResult = await checkToAddress(network, toAddress); + if ('error' in result) { + return result; } - toAddress = checkAddressResult.resolvedAddress!; + toAddress = result.resolvedAddress!; - // We only need to check the first batch of a multi-transaction - const messages = nftAddresses.slice(0, NFT_BATCH_SIZE).map((nftAddress) => { - return { - payload: buildNftTransferPayload(fromAddress, toAddress, comment), - amount: NFT_TRANSFER_AMOUNT, - toAddress: nftAddress, - }; - }); + const messages = nfts + .slice(0, NFT_BATCH_SIZE) // We only need to check the first batch of a multi-transaction + .map((nft) => buildNftTransferMessage(nft, fromAddress, toAddress, comment)); - const result = await checkMultiTransactionDraft(accountId, messages); + const transactionResult = await checkMultiTransactionDraft(accountId, messages); - if ('error' in result) { - return result; + if (transactionResult.fee !== undefined) { + const batchFee = transactionResult.fee; + result.fee = calculateNftTransferFee(nfts.length, messages.length, batchFee, NFT_TRANSFER_AMOUNT); + result.realFee = calculateNftTransferFee(nfts.length, messages.length, batchFee, NFT_TRANSFER_REAL_AMOUNT); } - return { - ...result, - ...checkAddressResult, - }; + if ('error' in transactionResult) { + result.error = transactionResult.error; + } + + return result; } export async function submitNftTransfers(options: { accountId: string; password: string; - nftAddresses: string[]; + nfts: ApiNft[]; toAddress: string; comment?: string; - nfts?: ApiNft[]; }) { const { - accountId, password, nftAddresses, toAddress, comment, nfts, + accountId, password, nfts, toAddress, comment, } = options; - const { address: fromAddress } = await fetchStoredTonWallet(accountId); + const messages = nfts.map((nft) => buildNftTransferMessage(nft, fromAddress, toAddress, comment)); + return submitMultiTransfer({ accountId, password, messages }); +} - const messages = nftAddresses.map((nftAddress, index) => { - const nft = nfts?.[index]; - const isNotcoinBurn = nft?.collectionAddress === NOTCOIN_VOUCHERS_ADDRESS - && (toAddress === BURN_ADDRESS || NOTCOIN_EXCHANGERS.includes(toAddress as any)); - const payload = isNotcoinBurn - ? buildNotcoinVoucherExchange(fromAddress, nftAddress, nft!.index) - : buildNftTransferPayload(fromAddress, toAddress, comment); - - return { - payload, - amount: NFT_TRANSFER_AMOUNT, - toAddress: nftAddress, - }; - }); +function buildNftTransferMessage(nft: ApiNft, fromAddress: string, toAddress: string, comment?: string) { + const isNotcoinBurn = nft.collectionAddress === NOTCOIN_VOUCHERS_ADDRESS + && (toAddress === BURN_ADDRESS || NOTCOIN_EXCHANGERS.includes(toAddress as any)); + const payload = isNotcoinBurn + ? buildNotcoinVoucherExchange(fromAddress, nft.address, nft.index) + : buildNftTransferPayload(fromAddress, toAddress, comment); - return submitMultiTransfer({ accountId, password, messages }); + return { + payload, + amount: NFT_TRANSFER_AMOUNT, + toAddress: nft.address, + }; } function buildNotcoinVoucherExchange(fromAddress: string, nftAddress: string, nftIndex: number) { @@ -239,3 +235,35 @@ function buildNftTransferPayload( return builder.endCell(); } + +export function calculateNftTransferFee( + totalNftCount: number, + // How many NFTs were added to the multi-transaction before estimating it + estimatedBatchSize: number, + // The blockchain fee of the estimated multi-transaction + estimatedBatchBlockchainFee: bigint, + // How much TON is attached to each NFT during the transfer + amountPerNft: bigint, +) { + const fullBatchCount = Math.floor(totalNftCount / estimatedBatchSize); + let remainingBatchSize = totalNftCount % estimatedBatchSize; + + // The blockchain fee for the first NFT in a batch is almost twice higher than the fee for the other NFTs. Therefore, + // simply using the average NFT fee to calculate the last incomplete batch fee gives an insufficient number. To fix + // that, we increase the last batch size. + // + // A real life example: + // 1 NFT in the batch: 0.002939195 TON + // 2 NFTs in the batch: 0.004470516 TON + // 3 NFTs in the batch: 0.006001837 TON + // 4 NFTs in the batch: 0.007533158 TON + if (remainingBatchSize > 0 && remainingBatchSize < estimatedBatchSize) { + remainingBatchSize += 1; + } + + const totalBlockchainFee = bigintMultiplyToNumber( + estimatedBatchBlockchainFee, + (fullBatchCount * estimatedBatchSize + remainingBatchSize) / estimatedBatchSize, + ); + return totalBlockchainFee + BigInt(totalNftCount) * amountPerNft; +} diff --git a/src/api/chains/ton/tokens.ts b/src/api/chains/ton/tokens.ts index bfec817d..b55f8ec7 100644 --- a/src/api/chains/ton/tokens.ts +++ b/src/api/chains/ton/tokens.ts @@ -31,12 +31,12 @@ import { buildTokenSlug, getTokenByAddress } from '../../common/tokens'; import { CLAIM_MINTLESS_AMOUNT, DEFAULT_DECIMALS, - TINIEST_TOKEN_REAL_TRANSFER_AMOUNT, - TINY_TOKEN_REAL_TRANSFER_AMOUNT, + TINIEST_TOKEN_TRANSFER_REAL_AMOUNT, TINY_TOKEN_TRANSFER_AMOUNT, - TOKEN_REAL_TRANSFER_AMOUNT, + TINY_TOKEN_TRANSFER_REAL_AMOUNT, TOKEN_TRANSFER_AMOUNT, TOKEN_TRANSFER_FORWARD_AMOUNT, + TOKEN_TRANSFER_REAL_AMOUNT, } from './constants'; import { isActiveSmartContract } from './wallet'; @@ -389,13 +389,13 @@ export function getToncoinAmountForTransfer(token: ApiToken, willClaimMintless: amount += TINY_TOKEN_TRANSFER_AMOUNT; if (token.slug === TON_USDT_SLUG) { - realAmount += TINIEST_TOKEN_REAL_TRANSFER_AMOUNT; + realAmount += TINIEST_TOKEN_TRANSFER_REAL_AMOUNT; } else { - realAmount += TINY_TOKEN_REAL_TRANSFER_AMOUNT; + realAmount += TINY_TOKEN_TRANSFER_REAL_AMOUNT; } } else { amount += TOKEN_TRANSFER_AMOUNT; - realAmount += TOKEN_REAL_TRANSFER_AMOUNT; + realAmount += TOKEN_TRANSFER_REAL_AMOUNT; } if (willClaimMintless) { diff --git a/src/api/chains/ton/transactions.ts b/src/api/chains/ton/transactions.ts index e5043369..0c4772b3 100644 --- a/src/api/chains/ton/transactions.ts +++ b/src/api/chains/ton/transactions.ts @@ -30,7 +30,7 @@ import type { TonTransferParams, } from './types'; import type { TonWallet } from './util/tonCore'; -import { ApiCommonError, ApiTransactionDraftError, ApiTransactionError } from '../../types'; +import { ApiTransactionDraftError, ApiTransactionError } from '../../types'; import { DEFAULT_FEE, DIESEL_ADDRESS, TONCOIN } from '../../../config'; import { parseAccountId } from '../../../util/account'; @@ -126,6 +126,7 @@ export async function checkTransactionDraft( isBase64Data, stateInit: stateInitString, forwardAmount, + allowGasless, } = options; let { toAddress, data } = options; @@ -135,7 +136,6 @@ export async function checkTransactionDraft( try { result = await checkToAddress(network, toAddress); - if ('error' in result) { return result; } @@ -259,23 +259,26 @@ export async function checkTransactionDraft( realFee += safeBlockchainFee; result.fee = fee; result.realFee = realFee; + result.diesel = DIESEL_NOT_AVAILABLE; let isEnoughBalance: boolean; if (!tokenAddress) { - result.diesel = DIESEL_NOT_AVAILABLE; isEnoughBalance = isFullTonTransfer ? toncoinBalance > blockchainFee : toncoinBalance >= fee + amount; } else { const canTransferGasfully = toncoinBalance >= fee; - result.diesel = await getDiesel({ - accountId, - tokenAddress, - canTransferGasfully, - toncoinBalance, - tokenBalance: balance, - }); + + if (allowGasless) { + result.diesel = await getDiesel({ + accountId, + tokenAddress, + canTransferGasfully, + toncoinBalance, + tokenBalance: balance, + }); + } if (isDieselAvailable(result.diesel)) { isEnoughBalance = amount + getDieselTokenAmount(result.diesel) <= balance; @@ -865,16 +868,11 @@ export async function checkMultiTransactionDraft( accountId: string, messages: TonTransferParams[], withDiesel = false, -) { - const { network } = parseAccountId(accountId); - - const result: { - fee?: bigint; - totalAmount?: bigint; - } = {}; - +): Promise<{ fee?: bigint } & ({ error: ApiAnyDisplayError } | {})> { + const result: { fee?: bigint } = {}; let totalAmount: bigint = 0n; + const { network } = parseAccountId(accountId); const { isInitialized, version } = await fetchStoredTonWallet(accountId); try { @@ -893,28 +891,20 @@ export async function checkMultiTransactionDraft( } const wallet = await getTonWallet(accountId); - - if (!wallet) { - return { ...result, error: ApiCommonError.Unexpected }; - } - const { balance } = await getWalletInfo(network, wallet); const { transaction } = await signMultiTransaction({ network, wallet, messages, version, }); + const blockchainFee = await calculateFee(network, wallet, transaction, isInitialized); + result.fee = bigintMultiplyToNumber(blockchainFee, FEE_FACTOR); - const realFee = await calculateFee(network, wallet, transaction, isInitialized); - - // TODO Should be `0` for `withDiesel`? - result.totalAmount = totalAmount; - result.fee = bigintMultiplyToNumber(realFee, FEE_FACTOR); - - if (!withDiesel && balance < totalAmount + realFee) { + // TODO Should `totalAmount` be `0` for `withDiesel`? + if (!withDiesel && balance < totalAmount + result.fee) { return { ...result, error: ApiTransactionDraftError.InsufficientBalance }; } - return result as { fee: bigint; totalAmount: bigint }; + return result; } catch (err: any) { return handleServerError(err); } diff --git a/src/api/extensionMethods/legacy.ts b/src/api/extensionMethods/legacy.ts index 2e0ceb70..cf7b1ad1 100644 --- a/src/api/extensionMethods/legacy.ts +++ b/src/api/extensionMethods/legacy.ts @@ -131,7 +131,7 @@ export async function sendTransaction(params: { const { type } = await fetchStoredAccount(accountId); if (type === 'ledger') { - return sendLedgerTransaction(accountId, promiseId, promise, checkResult.fee!, params); + return sendLedgerTransaction(accountId, promiseId, promise, checkResult.fee, checkResult.realFee, params); } onPopupUpdate({ @@ -165,7 +165,7 @@ export async function sendTransaction(params: { amount, fromAddress, toAddress, - fee: checkResult.fee!, + fee: checkResult.realFee ?? checkResult.fee!, slug: TONCOIN.slug, inMsgHash: result.msgHash, ...(dataType === 'text' && { @@ -180,7 +180,8 @@ async function sendLedgerTransaction( accountId: string, promiseId: string, promise: Promise, - fee: bigint, + fee: bigint | undefined, + realFee: bigint | undefined, params: { to: string; value: string; @@ -225,6 +226,7 @@ async function sendLedgerTransaction( toAddress, amount: BigInt(amount), fee, + realFee, ...(dataType === 'text' && { comment: data, }), @@ -248,7 +250,7 @@ async function sendLedgerTransaction( amount, fromAddress, toAddress, - fee, + fee: realFee ?? fee ?? 0n, slug: TONCOIN.slug, inMsgHash: msgHash, ...(dataType === 'text' && { diff --git a/src/api/methods/dapps.ts b/src/api/methods/dapps.ts index aa4f59b5..514d2de6 100644 --- a/src/api/methods/dapps.ts +++ b/src/api/methods/dapps.ts @@ -1,6 +1,5 @@ -import type { LangCode } from '../../global/types'; import type { - ApiDapp, ApiDappsState, ApiNetwork, ApiSite, OnApiUpdate, + ApiDapp, ApiDappsState, ApiNetwork, ApiSite, ApiSiteCategory, OnApiUpdate, } from '../types'; import { parseAccountId } from '../../util/account'; @@ -227,10 +226,8 @@ export function setSseLastEventId(lastEventId: string) { return storage.setItem('sseLastEventId', lastEventId); } -export function loadExploreSites({ - isLandscape, langCode, -}: { - isLandscape: boolean; langCode: LangCode; -}): Promise { - return callBackendGet('/dapp/catalog', { isLandscape, langCode }); +export function loadExploreSites( + { isLandscape }: { isLandscape: boolean }, +): Promise<{ categories: ApiSiteCategory[]; sites: ApiSite[] }> { + return callBackendGet('/v2/dapp/catalog', { isLandscape }); } diff --git a/src/api/methods/nfts.ts b/src/api/methods/nfts.ts index 7c784e66..da140578 100644 --- a/src/api/methods/nfts.ts +++ b/src/api/methods/nfts.ts @@ -1,6 +1,7 @@ import type { ApiNft } from '../types'; import { TONCOIN } from '../../config'; +import { bigintDivideToNumber } from '../../util/bigint'; import chains from '../chains'; import { fetchStoredTonWallet } from '../common/accounts'; import { createLocalTransaction } from './transactions'; @@ -13,7 +14,7 @@ export function fetchNfts(accountId: string) { export function checkNftTransferDraft(options: { accountId: string; - nftAddresses: string[]; + nfts: ApiNft[]; toAddress: string; comment?: string; }) { @@ -23,29 +24,30 @@ export function checkNftTransferDraft(options: { export async function submitNftTransfers( accountId: string, password: string, - nftAddresses: string[], + nfts: ApiNft[], toAddress: string, comment?: string, - nfts?: ApiNft[], - fee = 0n, + totalRealFee = 0n, ) { const { address: fromAddress } = await fetchStoredTonWallet(accountId); const result = await ton.submitNftTransfers({ - accountId, password, nftAddresses, toAddress, comment, nfts, + accountId, password, nfts, toAddress, comment, }); if ('error' in result) { return result; } + const realFeePerNft = bigintDivideToNumber(totalRealFee, Object.keys(result.messages).length); + for (const [i, message] of result.messages.entries()) { createLocalTransaction(accountId, 'ton', { amount: message.amount, fromAddress, toAddress, comment, - fee, + fee: realFeePerNft, normalizedAddress: message.toAddress, slug: TONCOIN.slug, inMsgHash: result.msgHash, diff --git a/src/api/methods/staking.ts b/src/api/methods/staking.ts index ba3d756d..ff08e808 100644 --- a/src/api/methods/staking.ts +++ b/src/api/methods/staking.ts @@ -33,7 +33,7 @@ export async function submitStake( password: string, amount: bigint, state: ApiStakingState, - fee?: bigint, + realFee?: bigint, ) { const { address: fromAddress } = await fetchStoredTonWallet(accountId); @@ -52,7 +52,7 @@ export async function submitStake( amount: result.amount, fromAddress, toAddress: result.toAddress, - fee: fee || 0n, + fee: realFee ?? 0n, type: 'stake', slug: state.tokenSlug, inMsgHash: result.msgHash, @@ -62,7 +62,7 @@ export async function submitStake( amount, fromAddress, toAddress: result.toAddress, - fee: fee || 0n, + fee: realFee ?? 0n, type: 'stake', slug: state.tokenSlug, }); @@ -79,7 +79,7 @@ export async function submitUnstake( password: string, amount: bigint, state: ApiStakingState, - fee?: bigint, + realFee?: bigint, ) { const { address: fromAddress } = await fetchStoredTonWallet(accountId); @@ -92,7 +92,7 @@ export async function submitUnstake( amount: result.amount, fromAddress, toAddress: result.toAddress, - fee: fee || 0n, + fee: realFee ?? 0n, type: 'unstakeRequest', slug: TONCOIN.slug, inMsgHash: result.msgHash, @@ -138,7 +138,7 @@ export async function submitStakingClaim( accountId: string, password: string, state: ApiJettonStakingState, - fee?: bigint, + realFee?: bigint, ) { const { address: fromAddress } = await fetchStoredTonWallet(accountId); @@ -152,7 +152,7 @@ export async function submitStakingClaim( amount: result.amount, fromAddress, toAddress: result.toAddress, - fee: fee ?? 0n, + fee: realFee ?? 0n, slug: TONCOIN.slug, inMsgHash: result.msgHash, }); diff --git a/src/api/methods/transactions.ts b/src/api/methods/transactions.ts index ac9c8d5b..642cdd8b 100644 --- a/src/api/methods/transactions.ts +++ b/src/api/methods/transactions.ts @@ -120,6 +120,7 @@ export async function submitTransfer( tokenAddress, comment, fee, + realFee, shouldEncrypt, isBase64Data, withDiesel, @@ -181,7 +182,7 @@ export async function submitTransfer( toAddress, comment: shouldEncrypt ? undefined : comment, encryptedComment, - fee: fee || 0n, + fee: realFee ?? 0n, slug, inMsgHash: msgHash, }); @@ -196,7 +197,7 @@ export async function submitTransfer( fromAddress, toAddress, comment, - fee: fee || 0n, + fee: realFee ?? 0n, slug, }); } diff --git a/src/api/methods/types.ts b/src/api/methods/types.ts index 34e6e2fa..6b8ca07a 100644 --- a/src/api/methods/types.ts +++ b/src/api/methods/types.ts @@ -23,8 +23,8 @@ export type CheckTransactionDraftOptions = { stateInit?: string; shouldEncrypt?: boolean; isBase64Data?: boolean; - isGaslessWithStars?: boolean; forwardAmount?: bigint; + allowGasless?: boolean; }; export interface ApiSubmitTransferOptions { @@ -34,7 +34,10 @@ export interface ApiSubmitTransferOptions { amount: bigint; comment?: string; tokenAddress?: string; + /** To cap the fee in TRON transfers */ fee?: bigint; + /** To show in the created local transaction */ + realFee?: bigint; shouldEncrypt?: boolean; isBase64Data?: boolean; withDiesel?: boolean; diff --git a/src/api/types/backend.ts b/src/api/types/backend.ts index 516cc03b..1eb0dab8 100644 --- a/src/api/types/backend.ts +++ b/src/api/types/backend.ts @@ -118,6 +118,7 @@ export type ApiSwapHistoryItem = { toAmount: string; networkFee: string; swapFee: string; + ourFee?: string; status: 'pending' | 'completed' | 'failed' | 'expired'; txIds: string[]; isCanceled?: boolean; @@ -210,11 +211,19 @@ export type ApiSite = { description: string; canBeRestricted: boolean; isExternal: boolean; + isFeatured?: boolean; + categoryId?: number; + extendedIcon?: string; badgeText?: string; withBorder?: boolean; }; +export type ApiSiteCategory = { + id: number; + name: string; +}; + // Prices export type ApiPriceHistoryPeriod = '1D' | '7D' | '1M' | '3M' | '1Y' | 'ALL'; diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 845b341b..4e5b03fd 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -209,6 +209,9 @@ export interface ApiSignedTransfer { params: Omit; } +/** + * The `fee` field should contain the final (real) fee, because we want to show the real fee in local transactions + */ export type ApiLocalTransactionParams = Omit< ApiTransaction, 'txId' | 'timestamp' | 'isIncoming' | 'normalizedAddress' > & { diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index e63c0b7b..233ca00b 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -54,7 +54,8 @@ export type ApiUpdateCreateTransaction = { promiseId: string; toAddress: string; amount: bigint; - fee: bigint; + fee?: bigint; + realFee?: bigint; comment?: string; rawPayload?: string; parsedPayload?: ApiParsedPayload; diff --git a/src/assets/font-icons/explore.svg b/src/assets/font-icons/explore.svg new file mode 100644 index 00000000..287f5659 --- /dev/null +++ b/src/assets/font-icons/explore.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/settings.svg b/src/assets/font-icons/settings.svg new file mode 100644 index 00000000..4ec09332 --- /dev/null +++ b/src/assets/font-icons/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/wallet.svg b/src/assets/font-icons/wallet.svg new file mode 100644 index 00000000..d163dd70 --- /dev/null +++ b/src/assets/font-icons/wallet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/App.module.scss b/src/components/App.module.scss index cd3bb107..8cafa55b 100644 --- a/src/components/App.module.scss +++ b/src/components/App.module.scss @@ -10,6 +10,8 @@ .appSlide { --background-color: var(--color-background-second); + z-index: 0; + background: var(--color-background-second); /* These styles need to be applied via regular CSS and not as conditional class, since Transition does not work well when `slideClassName` updates */ @@ -22,6 +24,10 @@ overflow: auto; overflow-x: hidden; } + + &_fastTransition { + animation-duration: 200ms !important; + } } .appSlide.appSlideTransparent { diff --git a/src/components/App.tsx b/src/components/App.tsx index fdfd4107..6b885331 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -2,22 +2,17 @@ import React, { memo, useEffect, useLayoutEffect } from '../lib/teact/teact'; import { getActions, withGlobal } from '../global'; import type { Theme } from '../global/types'; -import { AppState } from '../global/types'; +import { AppState, ContentTab } from '../global/types'; import { INACTIVE_MARKER, IS_ANDROID_DIRECT, IS_CAPACITOR } from '../config'; -import { selectCurrentAccountSettings } from '../global/selectors'; +import { selectCurrentAccountSettings, selectCurrentAccountState } from '../global/selectors'; import { useAccentColor } from '../util/accentColor'; import { setActiveTabChangeListener } from '../util/activeTabMonitor'; import buildClassName from '../util/buildClassName'; import { MINUTE } from '../util/dateFormat'; import { resolveRender } from '../util/renderPromise'; import { - IS_ANDROID, - IS_DELEGATED_BOTTOM_SHEET, - IS_DELEGATING_BOTTOM_SHEET, - IS_ELECTRON, - IS_IOS, - IS_LINUX, + IS_ANDROID, IS_DELEGATED_BOTTOM_SHEET, IS_ELECTRON, IS_IOS, IS_LINUX, } from '../util/windowEnvironment'; import { updateSizes } from '../util/windowSize'; import { callApi } from '../api'; @@ -37,6 +32,7 @@ import DappConnectModal from './dapps/DappConnectModal'; import DappTransferModal from './dapps/DappTransferModal'; import Dialogs from './Dialogs'; import ElectronHeader from './electron/ElectronHeader'; +import Explore from './explore/Explore'; import LedgerModal from './ledger/LedgerModal'; import Main from './main/Main'; import AddAccountModal from './main/modals/AddAccountModal'; @@ -48,6 +44,7 @@ import SwapActivityModal from './main/modals/SwapActivityModal'; import TransactionModal from './main/modals/TransactionModal'; import UnhideNftModal from './main/modals/UnhideNftModal'; import Notifications from './main/Notifications'; +import BottomBar from './main/sections/Actions/BottomBar'; import MediaViewer from './mediaViewer/MediaViewer'; import Settings from './settings/Settings'; import SettingsModal from './settings/SettingsModal'; @@ -67,26 +64,29 @@ interface StateProps { isBackupWalletModalOpen?: boolean; isQrScannerOpen?: boolean; isHardwareModalOpen?: boolean; + isExploreOpen?: boolean; areSettingsOpen?: boolean; - isMediaViewerOpen?: boolean; theme: Theme; accentColorIndex?: number; } +const APP_STATES_WITH_BOTTOM_BAR = new Set([AppState.Main, AppState.Settings, AppState.Explore]); const APP_UPDATE_INTERVAL = (IS_ELECTRON && !IS_LINUX) || IS_ANDROID_DIRECT ? 5 * MINUTE : undefined; const PRERENDER_MAIN_DELAY = 1200; let mainKey = 0; +const APP_STATE_RENDER_COUNT = Object.keys(AppState).length / 2; + function App({ appState, accountId, isBackupWalletModalOpen, isHardwareModalOpen, isQrScannerOpen, + isExploreOpen, areSettingsOpen, - isMediaViewerOpen, theme, accentColorIndex, }: StateProps) { @@ -100,7 +100,7 @@ function App({ } = getActions(); const { isPortrait } = useDeviceScreen(); - const areSettingsInModal = !isPortrait || IS_ELECTRON || IS_DELEGATING_BOTTOM_SHEET || IS_DELEGATED_BOTTOM_SHEET; + const areSettingsInModal = !isPortrait; const [isInactive, markInactive] = useFlag(false); const [canPrerenderMain, prerenderMain] = useFlag(); @@ -109,7 +109,14 @@ function App({ ? AppState.Inactive : areSettingsOpen && !areSettingsInModal ? AppState.Settings - : appState; + : isExploreOpen && isPortrait + ? AppState.Explore : appState; + const withBottomBar = isPortrait && APP_STATES_WITH_BOTTOM_BAR.has(renderingKey); + const transitionName = withBottomBar + ? 'semiFade' + : isPortrait + ? (IS_ANDROID ? 'slideFadeAndroid' : IS_IOS ? 'slideLayers' : 'slideFade') + : 'semiFade'; useTimeout( prerenderMain, @@ -118,6 +125,10 @@ function App({ useInterval(checkAppVersion, APP_UPDATE_INTERVAL); + useEffect(() => { + document.documentElement.classList.toggle('with-bottombar', withBottomBar); + }, [withBottomBar]); + useEffect(() => { updateSizes(); setActiveTabChangeListener(() => { @@ -176,6 +187,8 @@ function App({ ); } + case AppState.Explore: + return ; case AppState.Settings: return ; case AppState.Ledger: @@ -190,11 +203,14 @@ function App({ {IS_ELECTRON && !IS_LINUX && } {renderContent} @@ -239,18 +255,21 @@ function App({ {!IS_DELEGATED_BOTTOM_SHEET && } )} + {withBottomBar && } ); } export default memo(withGlobal((global): StateProps => { + const { activeContentTab } = selectCurrentAccountState(global) ?? {}; + return { appState: global.appState, accountId: global.currentAccountId, isBackupWalletModalOpen: global.isBackupWalletModalOpen, isHardwareModalOpen: global.isHardwareModalOpen, + isExploreOpen: !global.areSettingsOpen && activeContentTab === ContentTab.Explore, areSettingsOpen: global.areSettingsOpen, - isMediaViewerOpen: Boolean(global.mediaViewer.mediaId), isQrScannerOpen: global.isQrScannerOpen, theme: global.settings.theme, accentColorIndex: selectCurrentAccountSettings(global)?.accentColorIndex, diff --git a/src/components/auth/Auth.module.scss b/src/components/auth/Auth.module.scss index 70dd4f4b..3a0afeb7 100644 --- a/src/components/auth/Auth.module.scss +++ b/src/components/auth/Auth.module.scss @@ -613,6 +613,12 @@ margin-top: auto; } +.headerBack { + position: absolute; + top: 1.5rem; + left: 0.5rem; +} + .headerBackBlock, .headerBack { cursor: var(--custom-cursor, pointer); @@ -629,12 +635,6 @@ } } -.headerBack { - position: absolute; - top: 1.5rem; - left: 0.5rem; -} - .iconChevron { font-size: 1.5rem; } diff --git a/src/components/common/FeeDetailsModal.module.scss b/src/components/common/FeeDetailsModal.module.scss new file mode 100644 index 00000000..85e47f1c --- /dev/null +++ b/src/components/common/FeeDetailsModal.module.scss @@ -0,0 +1,87 @@ +$chartPadding: 0.75rem; +$chartLineInnerPadding: 0.375rem; +$chartLineInnerRadius: 0.1875rem; + +.chart { + margin-bottom: 1.875rem; +} + +.chartLabels { + display: flex; + gap: 1rem; + align-items: flex-end; + justify-content: space-between; + + margin: 0 $chartPadding; + margin-bottom: 0.1875rem; + + font-size: 0.75rem; + font-weight: 700; +} + +.chartLabel { + &.realFee { + color: var(--color-accent); + } + + &.excessFee { + color: var(--color-activity-green-text); + } +} + +.chartLines { + display: flex; + gap: 0.1875rem; + + font-weight: 600; +} + +.chartLine { + padding: 0.6875rem $chartPadding 0.625rem; + + border-radius: 0.5rem; + + &.realFee { + flex: 1 1 auto; + + padding-inline-end: $chartLineInnerPadding; + + color: var(--color-accent-button-text); + + background: var(--color-accent-button-background); + border-start-end-radius: $chartLineInnerRadius; + border-end-end-radius: $chartLineInnerRadius; + } + + &.excessFee { + flex: 10 1 auto; + + padding-inline-start: $chartLineInnerPadding; + + color: var(--color-white); + + background: var(--color-activity-green-text); + border-start-start-radius: $chartLineInnerRadius; + border-end-start-radius: $chartLineInnerRadius; + } +} + +.realFee { + text-align: start; +} + +.excessFee { + text-align: end; +} + +.explanation { + display: flex; + flex-direction: column; + gap: 1.267em; + + margin: 0 0.5rem 1.875rem; +} + +.explanationBlock { + margin: 0; +} diff --git a/src/components/common/FeeDetailsModal.tsx b/src/components/common/FeeDetailsModal.tsx new file mode 100644 index 00000000..ab503104 --- /dev/null +++ b/src/components/common/FeeDetailsModal.tsx @@ -0,0 +1,113 @@ +import React, { memo } from '../../lib/teact/teact'; + +import type { FeePrecision, FeeTerms, FeeValue } from '../../util/fee/types'; +import type { FeeToken } from '../ui/Fee'; + +import renderText from '../../global/helpers/renderText'; +import buildClassName from '../../util/buildClassName'; +import { getChainConfig } from '../../util/chain'; +import { getChainBySlug } from '../../util/tokens'; + +import useLang from '../../hooks/useLang'; + +import Button from '../ui/Button'; +import Fee from '../ui/Fee'; +import Modal from '../ui/Modal'; + +import modalStyles from '../ui/Modal.module.scss'; +import styles from './FeeDetailsModal.module.scss'; + +type OwnProps = { + isOpen: boolean; + onClose: NoneToVoidFunction; + fullFee: FeeTerms | undefined; + realFee: FeeTerms | undefined; + realFeePrecision: FeePrecision | undefined; + /** The excess fee is always measured in the native token */ + excessFee: FeeValue | undefined; + excessFeePrecision: FeePrecision | undefined; + /** The token denoting the `token` fields of the `FeeTerms` objects. */ + token: FeeToken | undefined; +}; + +function FeeDetailsModal({ isOpen, onClose, ...restProps }: OwnProps) { + const lang = useLang(); + + return ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + ); +} + +export default memo(FeeDetailsModal); + +function FeeDetailsContent({ + onClose, + fullFee, + realFee, + realFeePrecision = 'exact', + excessFee, + excessFeePrecision = 'exact', + token, +}: Omit) { + const chain = token && getChainBySlug(token.slug); + const nativeToken = chain && getChainConfig(chain).nativeToken; + const lang = useLang(); + + return ( + <> +
+
+
+ {lang('Final Fee')} +
+
+ {lang('Excess')} +
+
+
+
+ {realFee && token && ( + + )} +
+
+ {excessFee !== undefined && token && ( + + )} +
+
+
+
+

+ {renderText(lang('$fee_details', { + full_fee: fullFee && token && , + excess_symbol: {nativeToken?.symbol}, + }))} +

+

+ {lang('This is how the %chain_name% Blockchain works.', { + chain_name: chain?.toUpperCase(), + })} +

+
+
+ +
+ + ); +} diff --git a/src/components/common/SwapTokensInfo.module.scss b/src/components/common/SwapTokensInfo.module.scss index 200eff54..34f65509 100644 --- a/src/components/common/SwapTokensInfo.module.scss +++ b/src/components/common/SwapTokensInfo.module.scss @@ -138,7 +138,7 @@ width: 2.5rem; height: 2.5rem; - background-color: var(--color-background-second); + background-color: var(--color-background-window); border-radius: 50%; } diff --git a/src/components/dapps/Dapp.module.scss b/src/components/dapps/Dapp.module.scss index 3593e44e..c3989a29 100644 --- a/src/components/dapps/Dapp.module.scss +++ b/src/components/dapps/Dapp.module.scss @@ -581,206 +581,3 @@ background-color: var(--color-transaction-amount-red-bg); border-radius: var(--border-radius-normal); } - -.feed { - overflow-x: auto; - display: flex; - - padding: 0.25rem 0; - - scrollbar-width: none; - - &::-webkit-scrollbar { - display: none !important; - } -} - -.feedTiles { - gap: 0.125rem; // Remaining 0.375rem of the gap are added to the width of the tiles - - height: calc(4rem + 2 * 0.25rem); -} - -.feedMini { - gap: 0.5rem; - - height: calc(1.75rem + 2 * 0.25rem); -} - -.feedEmpty { - height: 0 !important; - padding: 0 !important; -} - -.feedItem { - display: flex; - align-items: center; - - padding: 0 !important; - - border: none; - border-radius: var(--border-radius-tiny); - - &:hover { - cursor: pointer; - } - - &_tile { - flex-direction: column; - gap: 0.3125rem; - - min-width: calc(3.75rem + (0.5rem - 0.125rem) / 2); // (width + (padding - gap) / 2) - max-width: calc(3.75rem + (0.5rem - 0.125rem) / 2); // (width + (padding - gap) / 2) - - background-color: unset; - } - - &_mini { - padding: 0; - - white-space: nowrap; - - background-color: var(--color-activity-gray-background); - } - - &:first-child { - margin-left: 0.75rem; - } - - @media (hover: hover) { - &:hover, - &:focus-visible { - .feedItemLogoImg { - transform: scale(1.1); - } - .feedItemAppNameMini, .feedItemAppNameTile { - color: var(--color-accent); - } - } - } -} - -.feedItemAppNameMini, .feedItemAppNameTile, .feedSettingsLabel, .feedSettingsIconWrapper_mini { - transition: color 150ms; - :global(html.animation-level-0) & { - transition: none !important; - } -} - -.feedItemAppNameMini { - padding: 0 0.375rem; - - font-size: 0.8125rem; - font-weight: 650; - line-height: 1; - color: var(--color-activity-gray-text); -} - -.feedItemAppNameTile { - overflow: hidden; - - width: 100%; - - font-size: 0.6875rem; - font-weight: 600; - line-height: 1; - color: var(--color-black); - text-overflow: ellipsis; - white-space: nowrap; -} - -.feedItemLogoMini { - width: 1.75rem; - height: 1.75rem; - - border-top-left-radius: var(--border-radius-tiny); - border-bottom-left-radius: var(--border-radius-tiny); -} - -.feedItemLogoTile { - width: 3rem; - height: 3rem; - - border-radius: 0.75rem; -} - -.feedItemLogoMini, .feedItemLogoTile { - overflow: hidden; -} - -.feedItemLogoImg { - transform-origin: center; - - width: 100%; - height: 100%; - - object-fit: cover; - - transition: transform 300ms; - :global(html.animation-level-0) & { - transition: none !important; - } -} - -.feedSettingsIconContainer { - display: flex; - flex-direction: column; - gap: 0.3125rem; - align-items: center; - - padding-right: 0.75rem; - - .feedTiles > & { - margin-left: calc(0.5rem - 0.125rem); - } - - &:hover { - cursor: pointer; - } - - @media (hover: hover) { - &:hover, - &:focus-visible { - .feedSettingsLabel, .feedSettingsIconWrapper_mini { - color: var(--color-accent); - } - } - } -} - -.feedSettingsLabel { - font-size: 0.6875rem; - font-weight: 600; - line-height: 1; - color: var(--color-black); -} - -.feedSettingsIconWrapper { - display: flex; - align-items: center; - justify-content: center; - - color: var(--color-gray-3); - - background-color: var(--color-activity-gray-background); - - &_mini { - width: 1.75rem; - height: 1.75rem; - - font-size: 0.75rem; - line-height: 1rem; - - border-radius: 0.5rem; - } - - &_tile { - width: 3rem; - height: 3rem; - - font-size: 1.0625rem; - line-height: 1.5rem; - - border-radius: 0.75rem; - } -} diff --git a/src/components/dapps/DappConnectModal.tsx b/src/components/dapps/DappConnectModal.tsx index d79f704f..05836afd 100644 --- a/src/components/dapps/DappConnectModal.tsx +++ b/src/components/dapps/DappConnectModal.tsx @@ -10,7 +10,7 @@ import { DappConnectState } from '../../global/types'; import { selectNetworkAccounts } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; -import resolveModalTransitionName from '../../util/resolveModalTransitionName'; +import resolveSlideTransitionName from '../../util/resolveSlideTransitionName'; import useFlag from '../../hooks/useFlag'; import useLang from '../../hooks/useLang'; @@ -269,7 +269,7 @@ function DappConnectModal({ onCloseAnimationEnd={cancelDappConnectRequestConfirm} > { + if (sites.length <= 4) { + return [sites, []]; + } + + return [ + sites.slice(0, 3), + sites.slice(3, 7), + ]; + }, [sites]); + + function handleCategoryClick() { + openSiteCategory({ id: category.id }); + } + + return ( +
+

{lang(category.name)}

+ +
+ {bigSites.map((site) => ( + + ))} + {smallSites.length > 0 && ( +
+ {smallSites.map((site) => ( + {site.name} + ))} +
+ )} +
+
+ ); +} + +export default memo(Category); diff --git a/src/components/explore/CategoryHeader.module.scss b/src/components/explore/CategoryHeader.module.scss new file mode 100644 index 00000000..7759947f --- /dev/null +++ b/src/components/explore/CategoryHeader.module.scss @@ -0,0 +1,109 @@ +@import "../../styles/mixins"; + +.root { + display: grid; + grid-auto-columns: 1fr; + grid-template-columns: 1fr max-content 1fr; + flex: 1 1 auto; + align-items: center; + justify-content: space-between; + + max-width: 100%; + + text-align: center; + + background: var(--color-background-first); + border-radius: var(--border-radius-default) var(--border-radius-default) 0 0; + + transition: background-color 300ms; + + :global(html.animation-level-0) & { + transition: none !important; + } + + @include respond-below(xs) { + position: sticky !important; + z-index: 2; + + // On mobile devices with a retina display, the underlying content is visible from above + /* stylelint-disable-next-line plugin/whole-pixel */ + top: -0.25px; + + padding: calc(var(--header-padding-top) + var(--safe-area-top)) 0.125rem var(--header-padding-bottom); + + background-color: var(--color-background-second); + border-radius: 0; + + &:global(.is-scrolled) { + @supports (backdrop-filter: saturate(180%) blur(20px)) { + background-color: var(--color-background-tab-bar); + backdrop-filter: saturate(180%) blur(20px); + } + } + + &::after { + /* stylelint-disable-next-line plugin/whole-pixel */ + bottom: -0.25px !important; + } + } + + @include respond-above(xs) { + position: relative; + + &::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 { + cursor: var(--custom-cursor, pointer); + + display: flex; + align-items: center; + + height: 2.75rem; + padding: 0.125rem 0.5rem 0; + + font-size: 0.9375rem; + color: var(--color-accent); + + @include respond-below(xs) { + cursor: var(--custom-cursor, pointer); + + display: flex; + align-items: center; + + height: 1.3125rem; + padding: 0.0625rem 0.375rem; + + font-size: 1.0625rem; + } +} + +.backIcon { + font-size: 1.5rem; +} + +.title { + overflow: hidden; + + margin: 0; + + font-size: 1.0625rem; + font-weight: 700; + color: var(--color-black); + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/components/explore/CategoryHeader.tsx b/src/components/explore/CategoryHeader.tsx new file mode 100644 index 00000000..e140b381 --- /dev/null +++ b/src/components/explore/CategoryHeader.tsx @@ -0,0 +1,57 @@ +import React, { memo, useMemo } from '../../lib/teact/teact'; +import { getActions, withGlobal } from '../../global'; + +import type { ApiSiteCategory } from '../../api/types'; + +import buildClassName from '../../util/buildClassName'; + +import { useDeviceScreen } from '../../hooks/useDeviceScreen'; +import useLang from '../../hooks/useLang'; + +import Button from '../ui/Button'; + +import styles from './CategoryHeader.module.scss'; + +interface OwnProps { + id: number; + withNotch?: boolean; +} + +interface StateProps { + categories?: ApiSiteCategory[]; +} + +function CategoryHeader({ id, withNotch, categories }: OwnProps & StateProps) { + const { closeSiteCategory } = getActions(); + + const lang = useLang(); + const { isPortrait } = useDeviceScreen(); + + const category = useMemo(() => { + return (categories || [])?.find((item) => id === item.id); + }, [categories, id]); + + return ( +
+ + +

+ {lang(category?.name || '')} +

+
+ ); +} + +export default memo(withGlobal((global): StateProps => { + return { + categories: global.exploreData?.categories, + }; +})(CategoryHeader)); diff --git a/src/components/explore/DappFeed.module.scss b/src/components/explore/DappFeed.module.scss new file mode 100644 index 00000000..f59437a6 --- /dev/null +++ b/src/components/explore/DappFeed.module.scss @@ -0,0 +1,198 @@ +.root { + margin-bottom: 1.75rem; +} + +.feed { + overflow-x: auto; + display: flex; + + padding: 0.25rem 0; + padding-inline-end: max(0px, 1rem - var(--scrollbar-width, 0px)); + + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none !important; + } +} + +.feedEmpty { + height: 0 !important; + padding: 0 !important; +} + +.tiles { + gap: 0.125rem; // Remaining 0.375rem of the gap are added to the width of the tiles + + height: calc(4rem + 2 * 0.25rem); +} + +.pills { + gap: 0.75rem; + + height: calc(2rem + 2 * 0.25rem); +} + +.dapp { + display: flex; + align-items: center; + + padding: 0 !important; + + color: var(--color-black); + + border: none; + border-radius: var(--border-radius-tiny); + + &:hover { + cursor: var(--custom-cursor, pointer); + } + + &:first-child { + margin-left: 0.75rem; + } + + @media (hover: hover) { + &:hover, + &:focus-visible { + .feedItemLogoImg { + transform: scale(1.1); + } + + .dappName { + color: var(--color-accent); + } + } + } +} + +.dappTile { + flex-direction: column; + gap: 0.3125rem; + + min-width: calc(3.75rem + (0.5rem - 0.125rem) / 2); // (width + (padding - gap) / 2) + max-width: calc(3.75rem + (0.5rem - 0.125rem) / 2); // (width + (padding - gap) / 2) + + background-color: unset; + + & > .dappName { + overflow: hidden; + + width: 100%; + + font-size: 0.75rem; + font-weight: 650; + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.dappPill { + padding: 0; + + white-space: nowrap; + + background-color: var(--color-gray-button-background-light); + + & > .dappName { + padding: 0 0.5rem; + + font-size: 0.875rem; + font-weight: 700; + line-height: 1; + } +} + +.dappName, +.settingsLabel, +.iconPill { + transition: color 150ms; + + :global(html.animation-level-0) & { + transition: none !important; + } +} + +.iconPill { + overflow: hidden; + + width: 2rem; + height: 2rem; + + font-size: 0.875rem; + line-height: 1rem; + + border-radius: var(--border-radius-small); +} + +.iconTile { + overflow: hidden; + + width: 3rem; + height: 3rem; + + font-size: 1.0625rem; + line-height: 1.5rem; + + border-radius: var(--border-radius-normal); +} + +.icon { + transform-origin: center; + + width: 100%; + height: 100%; + + object-fit: cover; + + transition: transform 300ms; + + :global(html.animation-level-0) & { + transition: none !important; + } +} + +.settingsButton { + display: flex; + flex-direction: column; + gap: 0.3125rem; + align-items: center; + + .tiles > & { + margin-left: calc(0.5rem - 0.125rem); + } + + &:hover { + cursor: pointer; + } + + @media (hover: hover) { + &:hover, + &:focus-visible { + .settingsLabel, + .iconPill { + color: var(--color-accent); + } + } + } +} + +.settingsLabel { + font-size: 0.75rem; + font-weight: 650; + line-height: 1; + color: var(--color-black); + text-overflow: ellipsis; + white-space: nowrap; +} + +.settingsIconContainer { + display: flex; + align-items: center; + justify-content: center; + + color: var(--color-gray-2); + + background-color: var(--color-gray-button-background-light); +} diff --git a/src/components/dapps/DappFeed.tsx b/src/components/explore/DappFeed.tsx similarity index 50% rename from src/components/dapps/DappFeed.tsx rename to src/components/explore/DappFeed.tsx index 2da3ba83..9b0863b6 100644 --- a/src/components/dapps/DappFeed.tsx +++ b/src/components/explore/DappFeed.tsx @@ -7,13 +7,15 @@ import { SettingsState } from '../../global/types'; import { selectCurrentAccountState } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import { MEMO_EMPTY_ARRAY } from '../../util/memo'; +import { IS_TOUCH_ENV } from '../../util/windowEnvironment'; import useHorizontalScroll from '../../hooks/useHorizontalScroll'; import useLang from '../../hooks/useLang'; import DappFeedItem from './DappFeedItem'; -import styles from './Dapp.module.scss'; +import styles from './DappFeed.module.scss'; +import exploreStyles from './Explore.module.scss'; interface StateProps { dapps: ApiDapp[]; @@ -22,88 +24,71 @@ interface StateProps { type DappWithLastOpenedDate = ApiDapp & { lastOpenedAt?: number }; -function compareDapps(a: DappWithLastOpenedDate, b: DappWithLastOpenedDate) { - const aLastOpened = a.lastOpenedAt || 0; - const bLastOpened = b.lastOpenedAt || 0; - - if (aLastOpened !== bLastOpened) { - return bLastOpened - aLastOpened; - } - - return b.connectedAt - a.connectedAt; -} - -const MAX_DAPPS_FOR_MINI_MODE = 3; - +const MAX_DAPPS_FOR_PILL_MODE = 3; const HIDDEN_FROM_FEED_DAPP_ORIGINS = new Set(['https://checkin.mytonwallet.org']); function DappFeed({ dapps: dappsFromState, dappLastOpenedDatesByOrigin = {} }: StateProps) { const { openSettingsWithState } = getActions(); + const lang = useLang(); + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); const dapps: DappWithLastOpenedDate[] = useMemo(() => { - return dappsFromState.slice().filter((dapp) => !HIDDEN_FROM_FEED_DAPP_ORIGINS.has(dapp.origin)).map( - (dapp) => ({ ...dapp, lastOpenedAt: dappLastOpenedDatesByOrigin[dapp.origin] }), - ).sort(compareDapps); + return dappsFromState + .slice() + .filter((dapp) => !HIDDEN_FROM_FEED_DAPP_ORIGINS.has(dapp.origin)) + .map((dapp) => ({ ...dapp, lastOpenedAt: dappLastOpenedDatesByOrigin[dapp.origin] })) + .sort(compareDapps); }, [dappLastOpenedDatesByOrigin, dappsFromState]); - const mode = dapps.length > MAX_DAPPS_FOR_MINI_MODE ? 'tile' : 'mini'; - - const lang = useLang(); + const mode = dapps.length > MAX_DAPPS_FOR_PILL_MODE ? 'tile' : 'pill'; + const isPillMode = mode === 'pill'; + const iconWrapperClassName = isPillMode ? styles.iconPill : styles.iconTile; + const fullClassName = buildClassName( + styles.feed, + isPillMode ? styles.pills : styles.tiles, + !dapps.length && styles.feedEmpty, + 'dapps-feed', + ); function openSettings() { openSettingsWithState({ state: SettingsState.Dapps }); } - // eslint-disable-next-line no-null/no-null - const containerRef = useRef(null); useHorizontalScroll({ containerRef, - isDisabled: dapps.length === 0, + isDisabled: IS_TOUCH_ENV || dapps.length === 0, shouldPreventDefault: true, contentSelector: '.dapps-feed', }); - const isMiniMode = mode === 'mini'; - const iconWrapperClassName = isMiniMode ? styles.feedSettingsIconWrapperMini : styles.feedSettingsIconWrapperTile; - - function renderDapp(dapp: DappWithLastOpenedDate) { - const { - iconUrl, name, url, origin, - } = dapp; - - return ( - - ); + if (!dapps.length) { + return undefined; } return ( -
- {dapps?.map(renderDapp)} - {!!dapps.length && ( -
-
- +
+

{lang('Connected')}

+ +
+ {dapps?.map((dapp) => renderDapp(dapp, mode))} + + {!!dapps.length && ( +
+
+ +
+ {!isPillMode && {lang('Settings')}}
- {!isMiniMode && {lang('Settings')}} -
- )} + )} +
); } @@ -111,5 +96,34 @@ function DappFeed({ dapps: dappsFromState, dappLastOpenedDatesByOrigin = {} }: S export default memo(withGlobal((global): StateProps => { const { dapps = MEMO_EMPTY_ARRAY } = selectCurrentAccountState(global) || {}; const { dappLastOpenedDatesByOrigin } = selectCurrentAccountState(global) || {}; + return { dapps, dappLastOpenedDatesByOrigin }; })(DappFeed)); + +function compareDapps(a: DappWithLastOpenedDate, b: DappWithLastOpenedDate) { + const aLastOpened = a.lastOpenedAt || 0; + const bLastOpened = b.lastOpenedAt || 0; + + if (aLastOpened !== bLastOpened) { + return bLastOpened - aLastOpened; + } + + return b.connectedAt - a.connectedAt; +} + +function renderDapp(dapp: DappWithLastOpenedDate, mode: 'pill' | 'tile') { + const { + iconUrl, name, url, origin, + } = dapp; + + return ( + + ); +} diff --git a/src/components/dapps/DappFeedItem.tsx b/src/components/explore/DappFeedItem.tsx similarity index 54% rename from src/components/dapps/DappFeedItem.tsx rename to src/components/explore/DappFeedItem.tsx index 7d0caa35..dcf20958 100644 --- a/src/components/dapps/DappFeedItem.tsx +++ b/src/components/explore/DappFeedItem.tsx @@ -10,56 +10,49 @@ import useLastCallback from '../../hooks/useLastCallback'; import Image from '../ui/Image'; -import styles from './Dapp.module.scss'; +import dappStyles from '../dapps/Dapp.module.scss'; +import styles from './DappFeed.module.scss'; interface OwnProps { iconUrl: string; name: string; url: string; - mode: 'mini' | 'tile'; + mode: 'pill' | 'tile'; origin: string; } const RERENDER_DAPPS_FEED_DELAY_MS = SECOND; -const POPULAR_DAPP_ORIGIN_REPLACEMENTS = [ - { - name: 'Fanzee Battles', - manifestUrl: 'https://battles-tg-app.fanz.ee/tc-manifest.json', - originalUrl: 'https://t.me/fanzeebattlesbot', - replacementUrl: 'https://t.me/battlescryptobot?start=myTonWallet', - }, - { - name: 'Hamster Kombat', - manifestUrl: 'https://hamsterkombatgame.io/tonconnect-manifest.json', - originalUrl: 'https://hamsterkombatgame.io/', - replacementUrl: 'https://t.me/hamster_kombat_bot/start', - }, - { - name: 'Dogs', - manifestUrl: 'https://cdn.onetime.dog/manifest.json', - originalUrl: 'https://onetime.dog', - replacementUrl: 'https://t.me/dogshouse_bot/join', - }, - { - name: 'Earn', - manifestUrl: 'https://cdn.joincommunity.xyz/earn/manifest.json', - originalUrl: 'https://earncommunity.xyz', - replacementUrl: 'https://t.me/earn?startapp', - }, -]; +const POPULAR_DAPP_ORIGIN_REPLACEMENTS = [{ + name: 'Fanzee Battles', + manifestUrl: 'https://battles-tg-app.fanz.ee/tc-manifest.json', + originalUrl: 'https://t.me/fanzeebattlesbot', + replacementUrl: 'https://t.me/battlescryptobot?start=myTonWallet', +}, { + name: 'Hamster Kombat', + manifestUrl: 'https://hamsterkombatgame.io/tonconnect-manifest.json', + originalUrl: 'https://hamsterkombatgame.io/', + replacementUrl: 'https://t.me/hamster_kombat_bot/start', +}, { + name: 'Dogs', + manifestUrl: 'https://cdn.onetime.dog/manifest.json', + originalUrl: 'https://onetime.dog', + replacementUrl: 'https://t.me/dogshouse_bot/join', +}, { + name: 'Earn', + manifestUrl: 'https://cdn.joincommunity.xyz/earn/manifest.json', + originalUrl: 'https://earncommunity.xyz', + replacementUrl: 'https://t.me/earn?startapp', +}]; const ORIGIN_REPLACEMENTS_BY_ORIGIN = POPULAR_DAPP_ORIGIN_REPLACEMENTS.reduce( (acc: Record, { originalUrl, replacementUrl }) => { acc[originalUrl] = replacementUrl; + return acc; }, {}, ); -function isTelegramUrl(url: string) { - return url.startsWith('https://t.me/'); -} - function DappFeedItem({ iconUrl, name, @@ -68,14 +61,16 @@ function DappFeedItem({ origin, }: OwnProps) { const { updateDappLastOpenedAt } = getActions(); + const lang = useLang(); function renderIcon() { - const iconClassName = mode === 'mini' ? styles.feedItemLogoMini : styles.feedItemLogoTile; + const iconClassName = mode === 'pill' ? styles.iconPill : styles.iconTile; + if (!iconUrl) { return ( -
- +
+
); } @@ -85,7 +80,7 @@ function DappFeedItem({ {lang('Icon')}
@@ -94,24 +89,30 @@ function DappFeedItem({ const openDapp = useLastCallback(async () => { const matchedUrl = ORIGIN_REPLACEMENTS_BY_ORIGIN[url]; + if (matchedUrl || isTelegramUrl(url)) { await openUrl(matchedUrl, true); } else { await openUrl(url); } + setTimeout(() => void updateDappLastOpenedAt({ origin }), RERENDER_DAPPS_FEED_DELAY_MS); }); return ( ); } export default memo(DappFeedItem); + +function isTelegramUrl(url: string) { + return url.startsWith('https://t.me/'); +} diff --git a/src/components/explore/Explore.module.scss b/src/components/explore/Explore.module.scss new file mode 100644 index 00000000..3cfd35f1 --- /dev/null +++ b/src/components/explore/Explore.module.scss @@ -0,0 +1,456 @@ +@import "../../styles/mixins/index"; + +$suggestionPaddingSize: 0.375rem; +$paddingSize: 0.875rem; +$imageSize: 3rem; +$imageGapSize: 0.75rem; + +.rootSlide { + overflow: visible !important; +} + +.slide { + overflow: auto; + overflow-y: scroll; + + height: 100%; +} + +.list { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25rem 0.75rem; + align-content: start; + + padding: 0 1rem calc(max(var(--safe-area-bottom), 1rem) + var(--bottombar-height) + 1rem); + + @media (min-width: 470px) { + grid-template-columns: 1fr 1fr 1fr; + } + + &.landscapeList { + grid-template-columns: 1fr 1fr; + + padding-bottom: 1rem; + + @media (min-width: 800px) { + grid-template-columns: 1fr 1fr 1fr; + } + } + + @include adapt-padding-to-scrollbar(1rem); +} + +@include respond-below(xs) { + .suggestionsMenu { + max-height: 15rem; + } +} + +.suggestions { + --offset-y-value: 0.5rem; + + z-index: 3; + + margin: 0 0.5rem; + + @include adapt-margin-to-scrollbar(0.5rem); +} + +.suggestion { + align-items: center; + + padding: 0.5rem 2.25rem 0.5rem 0.5rem !important; +} + +.suggestionIcon { + margin-inline-end: 0.5rem; + + font-size: 1.25rem !important; + color: var(--color-gray-3); +} + +.suggestionAddress { + overflow: hidden; + + font-size: 1rem; + text-overflow: ellipsis; +} + +.clearSuggestion { + cursor: var(--custom-cursor, pointer); + + position: absolute; + top: 0; + right: 0; + + display: flex; + align-items: center; + justify-content: center; + + width: 2.25rem; + height: 2.25rem; + padding: 0; + + font-size: 1.25rem; + line-height: 1rem; + color: var(--color-gray-4); + + background: none; + border: none; + + transition: color 150ms; + + &:hover, + &:focus-visible { + color: var(--color-gray-3); + } +} + +.item { + cursor: var(--custom-cursor, pointer); + + position: relative; + + display: flex; + flex-grow: 0; + flex-shrink: 0; + + padding: $paddingSize; + + font-size: 0.9375rem; + font-weight: 650; + + transition: background-color 150ms, color 150ms; + + @media (hover: hover) { + &:hover, + &:focus-visible { + --color-background-first: var(--color-interactive-item-hover); + + background-color: var(--color-interactive-item-hover); + + .imageWrapperScaleable { + transform: scale(1.05); + } + } + } + + @media (pointer: coarse) { + &:active { + .imageWrapperScaleable { + transform: scale(1.05); + } + } + } + + &:not(:first-child)::before { + content: ''; + + position: absolute; + top: 0; + right: 0; + left: calc($imageSize + $imageGapSize + $paddingSize); + + height: 0.0625rem; + + /* stylelint-disable-next-line plugin/whole-pixel */ + box-shadow: inset 0 -0.025rem 0 0 var(--color-separator) !important; + } + + &.suggestion:not(:first-child)::before { + left: calc($imageSize + $imageGapSize + $paddingSize - $suggestionPaddingSize); + } +} + +.trending { + flex-direction: column; + + width: 11.6875rem; + height: 11.6875rem; + padding: 0; + + border-radius: var(--border-radius-normal); + + .imageWrapper { + position: absolute; + z-index: 0; + + width: 100%; + height: 100%; + } + + .infoWrapper { + --color-black: var(--color-card-text); + --color-gray-2: var(--color-card-second-text); + + position: relative; + z-index: 1; + + overflow: hidden; + + margin-top: auto; + padding: 0.75rem; + + text-overflow: ellipsis; + + border-radius: 0 0 var(--border-radius-normal) var(--border-radius-normal); + + &::before { + content: ''; + + position: absolute; + z-index: -1; + top: 0; + right: 0; + bottom: 0; + left: 0; + + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.3) 10%, rgba(0, 0, 0, 0.6) 20%, rgba(0, 0, 0, 0.85) 30%, rgba(0, 0, 0, 0.98) 40%, #000000 50%); + backdrop-filter: blur(1px); + border-radius: 0 0 var(--border-radius-normal) var(--border-radius-normal); + } + } + + &.extended { + width: 23.375rem; + } +} + +.withBorder::after { + content: ''; + + position: absolute; + z-index: 1; + top: 0; + left: 0; + + width: 100%; + height: 100%; + + border: 0.125rem solid var(--color-accent); + border-radius: var(--border-radius-normal); +} + +.badge { + position: absolute; + z-index: 2; + top: -0.25rem; + right: 0; + + padding: 0 0.25rem; + + font-size: 0.75rem; + font-weight: 700; + line-height: 1.125rem; + color: var(--color-accent-button-text); + + background: var(--color-accent); + border-radius: 0.3125rem; + + .trending & { + right: -0.25rem; + } +} + +.imageWrapper { + /* Fix for `border-radius` missing during transform on Safari. See https://stackoverflow.com/a/58283449 */ + isolation: isolate; + position: relative; + + overflow: hidden; + display: block !important; + flex: 0 0 $imageSize; + + width: $imageSize; + height: $imageSize; + margin-right: $imageGapSize; + + border-radius: var(--border-radius-normal); +} + +.imageWrapperScaleable { + transform-origin: center; + + transition: transform 300ms, opacity 0.15s ease !important; + + :global(html.animation-level-0) & { + transition: none !important; + } +} + +.image { + position: absolute; + top: 0; + left: 0; + + width: 100%; + height: 100%; + + object-fit: cover; + border-radius: var(--border-radius-normal); +} + +.infoWrapper { + line-height: 1.0625rem; +} + +.title { + color: var(--color-black); + word-break: break-word; +} + +.description { + overflow: hidden; + + font-size: 0.75rem; + font-weight: 500; + line-height: 0.875rem; + color: var(--color-gray-2); + text-overflow: ellipsis; +} + +.emptyList { + display: flex; + flex-direction: column; + align-items: center; + + height: 100%; + padding-top: 1.875rem; + padding-bottom: 2rem; + + color: var(--color-gray-2); + + @include respond-above(xs) { + justify-content: center; + } +} + +.emptyListLoading { + padding-top: 8rem; + + @include respond-above(xs) { + padding-bottom: 8rem; + } +} + +.searchWrapper { + width: 100%; + margin-bottom: 0.5rem; + padding-top: 0.5rem; + padding-bottom: 1rem; + + background-color: var(--color-background-first); + + @include respond-below(xs) { + position: sticky !important; + z-index: 3; + top: 0; + + padding-top: max(0.5rem, var(--safe-area-top)); + + background-color: var(--color-background-second); + + transition: background-color 300ms; + + :global(html.animation-level-0) & { + transition: none !important; + } + + &:global(.is-scrolled) { + @supports (backdrop-filter: saturate(180%) blur(20px)) { + background-color: var(--color-background-tab-bar); + backdrop-filter: saturate(180%) blur(20px); + } + } + } +} + +.searchContainer { + position: relative; + + display: flex; + flex-shrink: 0; + align-items: center; + + height: 2.5rem; + margin: 0 1rem; + + font-size: 1.25rem; + line-height: 1; + color: var(--color-gray-3); + + background-color: var(--color-background-second); + border-radius: var(--border-radius-normal); + + @include adapt-margin-to-scrollbar(1rem); + + @include respond-below(xs) { + margin-top: 0.5rem; + + background-color: var(--color-gray-button-background); + } +} + +.searchIcon { + margin-left: 0.75rem; +} + +.searchInput { + scroll-margin: 0.625rem 0.5rem; + + width: 100%; + padding: 0 0.25rem; + + font-size: 1rem; + font-weight: 600; + color: var(--color-black); + + background: transparent; + border: none; + outline: none; + + appearance: none; + + &::placeholder { + font-weight: 600; + color: var(--color-gray-2); + } + + &:hover, + &:focus { + &::placeholder { + color: var(--color-interactive-input-text-hover-active); + } + } +} + +.sectionHeader { + margin: 0 1rem 0.75rem; + + font-size: 1.25rem; + font-weight: 800; + line-height: 1.75rem; + color: var(--color-black); +} + +.trendingSection { + margin-bottom: 2rem; +} + +.trendingList { + overflow-x: auto; + display: flex; + gap: 0.75rem; + + margin-top: -0.25rem; + padding: 0.25rem 1rem 0; + + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none !important; + } + + @include adapt-padding-to-scrollbar(1rem); +} diff --git a/src/components/explore/Explore.tsx b/src/components/explore/Explore.tsx new file mode 100644 index 00000000..841da731 --- /dev/null +++ b/src/components/explore/Explore.tsx @@ -0,0 +1,414 @@ +import React, { + memo, useEffect, useMemo, useRef, useState, +} from '../../lib/teact/teact'; +import { getActions, withGlobal } from '../../global'; + +import type { ApiSite, ApiSiteCategory } from '../../api/types'; + +import { ANIMATED_STICKER_BIG_SIZE_PX } from '../../config'; +import { selectCurrentAccountState } from '../../global/selectors'; +import buildClassName from '../../util/buildClassName'; +import { vibrate } from '../../util/capacitor'; +import captureEscKeyListener from '../../util/captureEscKeyListener'; +import { openUrl } from '../../util/openUrl'; +import resolveSlideTransitionName from '../../util/resolveSlideTransitionName'; +import stopEvent from '../../util/stopEvent'; +import { captureControlledSwipe } from '../../util/swipeController'; +import { getHostnameFromUrl, isValidUrl } from '../../util/url'; +import { + IS_ANDROID, IS_ANDROID_APP, IS_IOS_APP, IS_TOUCH_ENV, +} from '../../util/windowEnvironment'; +import { ANIMATED_STICKERS_PATHS } from '../ui/helpers/animatedAssets'; + +import { useDeviceScreen } from '../../hooks/useDeviceScreen'; +import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps'; +import useFlag from '../../hooks/useFlag'; +import useHorizontalScroll from '../../hooks/useHorizontalScroll'; +import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; +import usePrevious2 from '../../hooks/usePrevious2'; +import useScrolledState from '../../hooks/useScrolledState'; +import { useStateRef } from '../../hooks/useStateRef'; + +import AnimatedIconWithPreview from '../ui/AnimatedIconWithPreview'; +import Menu from '../ui/Menu'; +import MenuItem from '../ui/MenuItem'; +import Spinner from '../ui/Spinner'; +import Transition from '../ui/Transition'; +import Category from './Category'; +import DappFeed from './DappFeed'; +import Site from './Site'; +import SiteList from './SiteList'; + +import styles from './Explore.module.scss'; + +interface OwnProps { + isActive?: boolean; +} + +interface StateProps { + categories?: ApiSiteCategory[]; + sites?: ApiSite[]; + shouldRestrict: boolean; + browserHistory?: string[]; + currentSiteCategoryId?: number; +} + +interface SearchSuggestions { + history?: string[]; + sites?: ApiSite[]; + isEmpty: boolean; +} + +const SUGGESTIONS_OPEN_DELAY = 300; +const GOOGLE_SEARCH_URL = 'https://www.google.com/search?q='; +const enum SLIDES { + main, + category, +} + +function Explore({ + isActive, categories, sites: originalSites, shouldRestrict, browserHistory, currentSiteCategoryId, +}: OwnProps & StateProps) { + const { + loadExploreSites, + getDapps, + addSiteToBrowserHistory, + removeSiteFromBrowserHistory, + openSiteCategory, + closeSiteCategory, + } = getActions(); + + // eslint-disable-next-line no-null/no-null + const inputRef = useRef(null); + const suggestionsTimeoutRef = useRef(undefined); + // eslint-disable-next-line no-null/no-null + const transitionRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const trendingContainerRef = useRef(null); + const lang = useLang(); + const { isLandscape, isPortrait } = useDeviceScreen(); + const [searchValue, setSearchValue] = useState(''); + const [isSearchFocused, markSearchFocused, unmarkSearchFocused] = useFlag(false); + const [isSuggestionsVisible, showSuggestions, hideSuggestions] = useFlag(false); + const prevSiteCategoryIdRef = useStateRef(usePrevious2(currentSiteCategoryId)); + + const { + handleScroll: handleContentScroll, + isScrolled, + } = useScrolledState(); + + useEffect( + () => (currentSiteCategoryId ? captureEscKeyListener(closeSiteCategory) : undefined), + [closeSiteCategory, currentSiteCategoryId], + ); + + const filteredSites = useMemo(() => { + return shouldRestrict + ? originalSites?.filter((site) => !site.canBeRestricted) + : originalSites; + }, [originalSites, shouldRestrict]); + + const searchSuggestions = useMemo(() => { + const search = searchValue.toLowerCase(); + const historyResult = browserHistory?.filter((url) => url.toLowerCase().includes(search)); + const sitesResult = search.length && filteredSites + ? filteredSites.filter(({ url, name, description }) => { + return url.toLowerCase().includes(search) + || name.toLowerCase().includes(search) + || description.toLowerCase().includes(search); + }) + : undefined; + + return { + history: historyResult, + sites: sitesResult, + isEmpty: (historyResult?.length || 0) + (sitesResult?.length || 0) === 0, + }; + }, [browserHistory, searchValue, filteredSites]); + + const { trendingSites, allSites } = useMemo(() => { + return (filteredSites || []).reduce((acc, site) => { + if (site.isFeatured) { + acc.trendingSites.push(site); + } + + if (!acc.allSites[site.categoryId!]) { + acc.allSites[site.categoryId!] = []; + } + acc.allSites[site.categoryId!].push(site); + + return acc; + }, { trendingSites: [] as ApiSite[], allSites: {} as Record }); + }, [filteredSites]); + + useEffect(() => { + if (!IS_TOUCH_ENV || !filteredSites?.length || !currentSiteCategoryId) { + return undefined; + } + + return captureControlledSwipe(transitionRef.current!, { + onSwipeRightStart: closeSiteCategory, + onCancel: () => { + openSiteCategory({ id: prevSiteCategoryIdRef.current! }); + }, + }); + }, [currentSiteCategoryId, filteredSites?.length, prevSiteCategoryIdRef]); + + useHorizontalScroll({ + containerRef: trendingContainerRef, + isDisabled: IS_TOUCH_ENV || trendingSites.length === 0, + shouldPreventDefault: true, + contentSelector: `.${styles.trendingList}`, + }); + + const filteredCategories = useMemo(() => { + return categories?.filter((category) => allSites[category.id]?.length > 0); + }, [categories, allSites]); + + useEffect(() => { + if (!isActive) return; + + getDapps(); + loadExploreSites({ isLandscape }); + }, [isActive, isLandscape]); + + const safeShowSuggestions = useLastCallback(() => { + if (searchSuggestions.isEmpty) return; + + // Simultaneous opening of the virtual keyboard and display of Saved Addresses causes animation degradation + if (IS_ANDROID) { + suggestionsTimeoutRef.current = window.setTimeout(showSuggestions, SUGGESTIONS_OPEN_DELAY); + } else { + showSuggestions(); + } + }); + + const safeHideSuggestions = useLastCallback(() => { + if (isSuggestionsVisible) { + hideSuggestions(); + } + window.clearTimeout(suggestionsTimeoutRef.current); + }); + + useEffectWithPrevDeps(([prevIsSearchFocused]) => { + if ((prevIsSearchFocused && !isSearchFocused) || searchSuggestions.isEmpty) { + safeHideSuggestions(); + } + if (isSearchFocused && !searchSuggestions.isEmpty) { + safeShowSuggestions(); + } + }, [isSearchFocused, searchSuggestions.isEmpty]); + + function openSite(originalUrl: string, isExternal?: boolean, title?: string) { + let url = originalUrl; + if (!url.startsWith('http:') && !url.startsWith('https:')) { + url = `https://${url}`; + } + if (!isValidUrl(url)) { + url = `${GOOGLE_SEARCH_URL}${encodeURIComponent(originalUrl)}`; + } else { + addSiteToBrowserHistory({ url }); + } + + void openUrl(url, isExternal, title, getHostnameFromUrl(url)); + } + + const handleSiteClick = useLastCallback(( + e: React.SyntheticEvent, + url: string, + ) => { + vibrate(); + hideSuggestions(); + const site = originalSites?.find(({ url: currentUrl }) => currentUrl === url); + openSite(url, site?.isExternal, site?.name); + }); + + function handleSiteClear(e: React.MouseEvent, url: string) { + stopEvent(e); + + removeSiteFromBrowserHistory({ url }); + } + + function handleSearchValueChange(e: React.ChangeEvent) { + setSearchValue(e.target.value); + } + + const handleMenuClose = useLastCallback(() => { + inputRef.current?.blur(); + }); + + function handleSearchSubmit(e: React.FormEvent) { + stopEvent(e); + + handleMenuClose(); + openSite(searchValue); + setSearchValue(''); + } + + function renderSearch() { + return ( +
+ + + + ); + } + + function renderSearchSuggestions() { + return ( + + {searchSuggestions?.history?.map((url) => ( + + + {getHostnameFromUrl(url)} + + + + ))} + {searchSuggestions?.sites?.map((site) => ( + + ))} + + ); + } + + function renderTrending() { + return ( +
+

{lang('Trending')}

+
+ {trendingSites.map((site) => ( + + ))} +
+
+ ); + } + + // eslint-disable-next-line consistent-return + function renderContent(isContentActive: boolean, isFrom: boolean, currentKey: number) { + switch (currentKey) { + case SLIDES.main: + return ( +
+
+ {renderSearch()} + {renderSearchSuggestions()} +
+ + + + {Boolean(trendingSites.length) && renderTrending()} + + {Boolean(filteredCategories?.length) && ( + <> +

{lang('All Dapps')}

+
+ {filteredCategories.map((category) => ( + + ))} +
+ + )} +
+ ); + + case SLIDES.category: { + const currentSiteCategory = allSites[currentSiteCategoryId!]; + if (!currentSiteCategory) return undefined; + + return ( + + ); + } + } + } + + if (filteredSites === undefined) { + return ( +
+ +
+ ); + } + + if (filteredSites.length === 0) { + return ( +
+ +

{lang('No partners yet')}

+
+ ); + } + + return ( + + {renderContent} + + ); +} + +export default memo(withGlobal((global): StateProps => { + const { browserHistory, currentSiteCategoryId } = selectCurrentAccountState(global) || {}; + const { categories, sites } = global.exploreData || {}; + + return { + sites, + categories, + shouldRestrict: global.restrictions.isLimitedRegion && (IS_IOS_APP || IS_ANDROID_APP), + browserHistory, + currentSiteCategoryId, + }; +})(Explore)); diff --git a/src/components/explore/Site.tsx b/src/components/explore/Site.tsx index e93a7871..770791ed 100644 --- a/src/components/explore/Site.tsx +++ b/src/components/explore/Site.tsx @@ -7,55 +7,51 @@ import { vibrate } from '../../util/capacitor'; import { openUrl } from '../../util/openUrl'; import { getHostnameFromUrl } from '../../util/url'; -import { useDeviceScreen } from '../../hooks/useDeviceScreen'; - import Image from '../ui/Image'; -import styles from '../main/sections/Content/Explore.module.scss'; +import styles from './Explore.module.scss'; interface OwnProps { site: ApiSite; - index: number; + isTrending?: boolean; + className?: string; } -const ROW_LENGTH_PORTRAIT = 2; -const ROW_LENGTH_LANDSCAPE = 3; - function Site({ site: { - url, icon, name, description, isExternal, extendedIcon, badgeText, withBorder, + url, icon, name, description, isExternal, extendedIcon, withBorder, badgeText, }, - index, + isTrending, + className, }: OwnProps) { - const { isLandscape } = useDeviceScreen(); - const canBeExtended = index % (isLandscape ? ROW_LENGTH_LANDSCAPE : ROW_LENGTH_PORTRAIT) === 0; - function handleClick() { - vibrate(); - openUrl(url, isExternal, name, getHostnameFromUrl(url)); + void vibrate(); + void openUrl(url, isExternal, name, getHostnameFromUrl(url)); } return (
- {badgeText &&
{badgeText}
}
{name} +
{description}
-
{description}
+ {badgeText &&
{badgeText}
}
); } diff --git a/src/components/explore/SiteList.module.scss b/src/components/explore/SiteList.module.scss new file mode 100644 index 00000000..bc3f3b4f --- /dev/null +++ b/src/components/explore/SiteList.module.scss @@ -0,0 +1,39 @@ +@import "../../styles/mixins/index"; + +.root { + --header-padding-top: 1.5rem; + --header-title-height: 1.1875rem; + --header-padding-bottom: 1.375rem; + + position: relative; + + overflow: hidden; + overflow-y: scroll; + + height: 100%; + + :global(html.with-safe-area-top) & { + --header-padding-top: 0.75rem; + } + + // Fix for opera, dead zone of 37 pixels in extension window on windows + :global(html.is-windows.is-opera.is-extension) & { + --header-padding-top: 2.3125rem; + } + + @include respond-below(xs) { + min-height: 100%; + padding-bottom: calc(max(var(--safe-area-bottom), 1rem) + var(--bottombar-height) + 1rem); + } +} + +.list { + background-color: var(--color-background-first); + border-radius: var(--border-radius-default); + + @include respond-below(xs) { + margin: 1rem 1rem 0; + + @include adapt-margin-to-scrollbar(1rem); + } +} diff --git a/src/components/explore/SiteList.tsx b/src/components/explore/SiteList.tsx new file mode 100644 index 00000000..e19dbf8f --- /dev/null +++ b/src/components/explore/SiteList.tsx @@ -0,0 +1,56 @@ +import React, { memo } from '../../lib/teact/teact'; +import { getActions } from '../../global'; + +import type { ApiSite } from '../../api/types'; + +import buildClassName from '../../util/buildClassName'; + +import { useDeviceScreen } from '../../hooks/useDeviceScreen'; +import useHistoryBack from '../../hooks/useHistoryBack'; +import useScrolledState from '../../hooks/useScrolledState'; + +import CategoryHeader from './CategoryHeader'; +import Site from './Site'; + +import styles from './SiteList.module.scss'; + +interface OwnProps { + isActive?: boolean; + categoryId: number; + sites: ApiSite[]; +} + +function SiteList({ isActive, categoryId, sites }: OwnProps) { + const { closeSiteCategory } = getActions(); + + const { isPortrait } = useDeviceScreen(); + + useHistoryBack({ + isActive, + onBack: closeSiteCategory, + }); + + const { + handleScroll: handleContentScroll, + isScrolled, + } = useScrolledState(); + + return ( +
+ {isPortrait && } +
+ {sites.map((site) => ( + + ))} +
+
+ ); +} + +export default memo(SiteList); diff --git a/src/components/ledger/LedgerConfirmOperation.tsx b/src/components/ledger/LedgerConfirmOperation.tsx index fa16e12c..a2ef1273 100644 --- a/src/components/ledger/LedgerConfirmOperation.tsx +++ b/src/components/ledger/LedgerConfirmOperation.tsx @@ -3,7 +3,7 @@ import React, { memo, useEffect, useState } from '../../lib/teact/teact'; import { ANIMATED_STICKER_BIG_SIZE_PX } from '../../config'; import renderText from '../../global/helpers/renderText'; import buildClassName from '../../util/buildClassName'; -import resolveModalTransitionName from '../../util/resolveModalTransitionName'; +import resolveSlideTransitionName from '../../util/resolveSlideTransitionName'; import { ANIMATED_STICKERS_PATHS } from '../ui/helpers/animatedAssets'; import useHistoryBack from '../../hooks/useHistoryBack'; @@ -123,7 +123,7 @@ function LedgerConfirmOperation({ return ( void; onBackButtonClick?: NoneToVoidFunction; onCancel?: NoneToVoidFunction; @@ -55,7 +58,6 @@ interface OwnProps { interface StateProps { availableTransports?: LedgerTransport[]; lastUsedTransport?: LedgerTransport; - accounts?: Record; currentTheme: Theme; } @@ -72,10 +74,11 @@ function LedgerConnect({ isLedgerConnected, isTonAppConnected, isRemoteTab, + shouldDelegateToNative, availableTransports, lastUsedTransport, - accounts, currentTheme, + className, onConnected, onBackButtonClick, onCancel, @@ -102,7 +105,6 @@ function LedgerConnect({ const isWaitingForBrowser = state === HardwareConnectState.WaitingForBrowser; const title = isConnected ? lang('Ledger Connected!') : lang('Connect Ledger'); const shouldCloseOnCancel = !onCancel; - const hasAccounts = useMemo(() => Object.keys(accounts || {}).length > 0, [accounts]); const renderingAvailableTransports = useMemo(() => { return (availableTransports || []).map((transport) => ({ @@ -120,6 +122,8 @@ function LedgerConnect({ onBack: onCancel ?? onClose, }); + useHideBottomBar(isActive); + useEffect(() => { if (selectedTransport) return; if (availableTransports?.length) { @@ -130,13 +134,13 @@ function LedgerConnect({ }, [availableTransports, selectedTransport]); useEffect(() => { - if (isRemoteTab || !isActive || (IS_DELEGATING_BOTTOM_SHEET && hasAccounts)) return; + if (isRemoteTab || !isActive || (IS_DELEGATING_BOTTOM_SHEET && !shouldDelegateToNative)) return; - initializeHardwareWalletModal(); - }, [hasAccounts, isActive, isRemoteTab]); + initializeHardwareWalletModal({ shouldDelegateToNative: IS_DELEGATING_BOTTOM_SHEET && shouldDelegateToNative }); + }, [isActive, isRemoteTab, shouldDelegateToNative]); const handleConnected = useLastCallback((isSingleWallet: boolean) => { - if (isRemoteTab || (IS_DELEGATING_BOTTOM_SHEET && hasAccounts)) { + if (isRemoteTab || (IS_DELEGATING_BOTTOM_SHEET && !shouldDelegateToNative)) { return; } @@ -160,13 +164,16 @@ function LedgerConnect({ const handleCloseWithBrowserTab = useLastCallback(() => { const closeAction = shouldCloseOnCancel ? onClose : onCancel; - closeLedgerTab(); + void closeLedgerTab(); closeAction(); }); const handleSubmit = useLastCallback(() => { if (renderingAvailableTransports.length > 1) { - initializeHardwareWalletConnection({ transport: selectedTransport! }); + initializeHardwareWalletConnection({ + transport: selectedTransport!, + shouldDelegateToNative, + }); } else { connectHardwareWallet({ transport: availableTransports?.[0] }); } @@ -388,8 +395,8 @@ function LedgerConnect({ return ( @@ -400,12 +407,10 @@ function LedgerConnect({ export default memo(withGlobal((global): StateProps => { const { availableTransports, lastUsedTransport } = global.hardware; - const accounts = selectAccounts(global); return { availableTransports, lastUsedTransport, - accounts, currentTheme: global.settings.theme, }; })(LedgerConnect)); diff --git a/src/components/ledger/LedgerModal.module.scss b/src/components/ledger/LedgerModal.module.scss index 4b4de140..f9d231ae 100644 --- a/src/components/ledger/LedgerModal.module.scss +++ b/src/components/ledger/LedgerModal.module.scss @@ -25,11 +25,14 @@ flex-direction: column; height: 100%; - padding: 0 1rem 1rem; + padding: 0 1rem max(var(--safe-area-bottom, 1rem), 1rem); + + :global(html.is-android-app) &:not(.containerStatic) { + padding-bottom: 0; + } - :global(html.is-native-bottom-sheet) &, &.containerStatic { - padding-bottom: max(var(--safe-area-bottom, 1rem), 1rem); + padding-top: calc(var(--header-padding-top) + var(--header-title-height) + var(--header-padding-bottom)); } } diff --git a/src/components/ledger/LedgerModal.tsx b/src/components/ledger/LedgerModal.tsx index 8a57000d..eda5cfdb 100644 --- a/src/components/ledger/LedgerModal.tsx +++ b/src/components/ledger/LedgerModal.tsx @@ -8,7 +8,8 @@ import type { LedgerWalletInfo } from '../../util/ledger/types'; import { selectNetworkAccounts } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; -import resolveModalTransitionName from '../../util/resolveModalTransitionName'; +import resolveSlideTransitionName from '../../util/resolveSlideTransitionName'; +import { IS_DELEGATING_BOTTOM_SHEET } from '../../util/windowEnvironment'; import useLastCallback from '../../hooks/useLastCallback'; @@ -90,6 +91,7 @@ function LedgerModal({ { if (selectedAccountIndices.includes(index)) { setSelectedAccountIndices(selectedAccountIndices.filter((id) => id !== index)); diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index f697e21f..5550ebd6 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -20,7 +20,7 @@ import { getStatusBarHeight } from '../../util/capacitor'; import { captureEvents, SwipeDirection } from '../../util/captureEvents'; import { setStatusBarStyle } from '../../util/switchTheme'; import { - IS_DELEGATED_BOTTOM_SHEET, IS_ELECTRON, IS_TOUCH_ENV, REM, + IS_DELEGATED_BOTTOM_SHEET, IS_ELECTRON, IS_TOUCH_ENV, STICKY_CARD_INTERSECTION_THRESHOLD, } from '../../util/windowEnvironment'; import windowSize from '../../util/windowSize'; @@ -69,7 +69,6 @@ type StateProps = { accentColorIndex?: number; }; -const STICKY_CARD_INTERSECTION_THRESHOLD = -3.75 * REM; const UPDATE_SWAPS_INTERVAL_NOT_FOCUSED = 15000; // 15 sec const UPDATE_SWAPS_INTERVAL = 3000; // 3 sec diff --git a/src/components/main/modals/AddAccountModal.tsx b/src/components/main/modals/AddAccountModal.tsx index 54cb1235..9fd98f7e 100644 --- a/src/components/main/modals/AddAccountModal.tsx +++ b/src/components/main/modals/AddAccountModal.tsx @@ -8,7 +8,7 @@ import { ANIMATED_STICKER_BIG_SIZE_PX } from '../../../config'; import renderText from '../../../global/helpers/renderText'; import { selectFirstNonHardwareAccount, selectNetworkAccounts } from '../../../global/selectors'; import buildClassName from '../../../util/buildClassName'; -import resolveModalTransitionName from '../../../util/resolveModalTransitionName'; +import resolveSlideTransitionName from '../../../util/resolveSlideTransitionName'; import { IS_LEDGER_SUPPORTED } from '../../../util/windowEnvironment'; import { ANIMATED_STICKERS_PATHS } from '../../ui/helpers/animatedAssets'; @@ -260,7 +260,7 @@ function AddAccountModal({ onClose={closeAddAccountModal} > { - if (!fromToken) return undefined; - - return findChainConfig(fromToken.chain)?.nativeToken; - }, [fromToken]); - if (renderedActivity) { const { status, from, cex, @@ -336,13 +337,27 @@ function SwapActivityModal({ } function renderFee() { + if (!Number(networkFee) || !fromToken) { + return undefined; + } + + const precision: FeePrecision = activity?.status === 'pending' ? 'approximate' : 'exact'; + const terms = isFromToncoin ? { + native: Big(networkFee).add(ourFee).toString(), + } : { + native: networkFee, + token: ourFee, + }; + return (
- {lang('Blockchain Fee')} + {lang('Fee')}
- {formatCurrency(networkFee, nativeToken?.symbol ?? TONCOIN.symbol, undefined, true)} + + +
); @@ -378,7 +393,7 @@ function SwapActivityModal({ return (
- {Number(networkFee) > 0 && renderFee()} + {renderFee()} {lang('$swap_changelly_to_ton_description1', { value: ( @@ -411,7 +426,7 @@ function SwapActivityModal({ return ( <> - {Number(networkFee) > 0 && renderFee()} + {renderFee()} {!isInternalSwap && renderAddress()} {!isInternalSwap && renderMemo()} diff --git a/src/components/main/modals/TransactionModal.tsx b/src/components/main/modals/TransactionModal.tsx index 4cbb575a..bd9cd880 100644 --- a/src/components/main/modals/TransactionModal.tsx +++ b/src/components/main/modals/TransactionModal.tsx @@ -31,7 +31,7 @@ import { formatFullDay, formatRelativeHumanDateTime, formatTime } from '../../.. import { toDecimal } from '../../../util/decimals'; import { handleOpenUrl } from '../../../util/openUrl'; import { getIsTransactionWithPoisoning } from '../../../util/poisoningHash'; -import resolveModalTransitionName from '../../../util/resolveModalTransitionName'; +import resolveSlideTransitionName from '../../../util/resolveSlideTransitionName'; import { getNativeToken, getTransactionHashFromTxId } from '../../../util/tokens'; import { getExplorerName, getExplorerTransactionUrl } from '../../../util/url'; import { callApi } from '../../../api'; @@ -528,7 +528,7 @@ function TransactionModal({ onCloseAnimationEnd={closePasswordSlide} > 0; +} + +function BottomBar({ areSettingsOpen, areAssetsActive, isExploreOpen }: StateProps) { + const { + openSettings, closeSettings, setActiveContentTab, closeSiteCategory, selectToken, + } = getActions(); + const lang = useLang(); + const [isHidden, setIsHidden] = useState(getIsBottomBarHidden()); + const isWalletTabActive = !isExploreOpen && !areSettingsOpen; + + useEffectOnce(() => { + return getHideCounter.subscribe(() => { + setIsHidden(getIsBottomBarHidden()); + }); + }); + + const openExplore = useLastCallback(() => { + setActiveContentTab({ tab: ContentTab.Explore }, { forceOnHeavyAnimation: true }); + }); + + const closeExplore = useLastCallback(() => { + setActiveContentTab({ tab: ContentTab.Assets }, { forceOnHeavyAnimation: true }); + }); + + const handleWalletClick = useLastCallback(() => { + closeExplore(); + closeSettings(); + + if (!areAssetsActive && isWalletTabActive) { + selectToken({ slug: undefined }); + setActiveContentTab({ tab: ContentTab.Assets }, { forceOnHeavyAnimation: true }); + } + }); + + const handleExploreClick = useLastCallback(() => { + if (isExploreOpen) { + closeSiteCategory(); + } + + openExplore(); + closeSettings(); + }); + + const handleSettingsClick = useLastCallback(() => { + openSettings(undefined, { forceOnHeavyAnimation: true }); + closeExplore(); + }); + + useHistoryBack({ + isActive: areSettingsOpen || isExploreOpen, + onBack: handleWalletClick, + }); + + return ( +
+ + + +
+ ); +} + +export default memo(withGlobal((global): StateProps => { + const { areSettingsOpen } = global; + const { activeContentTab } = selectCurrentAccountState(global) ?? {}; + + return { + areSettingsOpen, + areAssetsActive: activeContentTab === ContentTab.Assets, + isExploreOpen: !areSettingsOpen && activeContentTab === ContentTab.Explore, + }; +})(BottomBar)); diff --git a/src/components/main/sections/Actions/PortraitActions.module.scss b/src/components/main/sections/Actions/PortraitActions.module.scss index 41fe4d8a..b1e7d7eb 100644 --- a/src/components/main/sections/Actions/PortraitActions.module.scss +++ b/src/components/main/sections/Actions/PortraitActions.module.scss @@ -25,7 +25,7 @@ justify-content: center; width: 100%; - height: 3.4375rem; + height: 3.625rem; padding: 0; font-size: 0.8125rem; @@ -62,12 +62,12 @@ .buttonIcon { display: block; - width: 1.875rem; - height: 1.875rem; + width: 2rem; + height: 2rem; margin-top: -0.375rem; margin-bottom: 0.0625rem; - font-size: 1.875rem; + font-size: 2rem; line-height: 1; color: var(--color-accent); diff --git a/src/components/main/sections/Card/AccountSelector.module.scss b/src/components/main/sections/Card/AccountSelector.module.scss index 739fc764..afa685df 100644 --- a/src/components/main/sections/Card/AccountSelector.module.scss +++ b/src/components/main/sections/Card/AccountSelector.module.scss @@ -89,6 +89,13 @@ &:hover { color: var(--action-color-hover, var(--color-card-text)); } + + @include respond-below(xs) { + width: 1.75rem; + height: 1.75rem; + + font-size: 1.75rem; + } } .edit { @@ -394,6 +401,7 @@ position: absolute; top: 0.75rem; right: 0.75rem; + &.inStickyCard { top: unset; right: 0; @@ -406,6 +414,12 @@ width: fit-content; height: 1.5rem; + @include respond-below(xs) { + gap: 0.5625rem; + + height: 1.75rem; + } + :global(.MtwCard__gold) & { --action-color: #835B0E; --action-color-hover: #654910; diff --git a/src/components/main/sections/Card/Card.tsx b/src/components/main/sections/Card/Card.tsx index 7b237009..16b918fa 100644 --- a/src/components/main/sections/Card/Card.tsx +++ b/src/components/main/sections/Card/Card.tsx @@ -22,6 +22,7 @@ import { IS_IOS, IS_SAFARI } from '../../../../util/windowEnvironment'; import { calculateFullBalance } from './helpers/calculateFullBalance'; import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev'; +import { useDeviceScreen } from '../../../../hooks/useDeviceScreen'; import useFlag from '../../../../hooks/useFlag'; import useHistoryBack from '../../../../hooks/useHistoryBack'; import useLastCallback from '../../../../hooks/useLastCallback'; @@ -73,6 +74,7 @@ function Card({ const shortBaseSymbol = getShortCurrencySymbol(baseCurrency); const [customCardClassName, setCustomCardClassName] = useState(undefined); const [withTextGradient, setWithTextGradient] = useState(false); + const { isPortrait } = useDeviceScreen(); const isUpdating = useUpdateIndicator(balanceUpdateStartedAt); @@ -203,6 +205,7 @@ function Card({
diff --git a/src/components/main/sections/Card/StickyCard.tsx b/src/components/main/sections/Card/StickyCard.tsx index 4882c2ca..11162581 100644 --- a/src/components/main/sections/Card/StickyCard.tsx +++ b/src/components/main/sections/Card/StickyCard.tsx @@ -11,7 +11,7 @@ import { } from '../../../../global/selectors'; import buildClassName from '../../../../util/buildClassName'; import { getShortCurrencySymbol } from '../../../../util/formatNumber'; -import { IS_ELECTRON, IS_MAC_OS, IS_WINDOWS } from '../../../../util/windowEnvironment'; +import { IS_ELECTRON, IS_MAC_OS } from '../../../../util/windowEnvironment'; import { calculateFullBalance } from './helpers/calculateFullBalance'; import useFlag from '../../../../hooks/useFlag'; @@ -73,7 +73,7 @@ function StickyCard({ accountClassName={buildClassName(styles.account, withTextGradient && 'gradientText')} accountSelectorClassName="sticky-card-account-selector" menuButtonClassName={styles.menuButton} - noSettingsButton={(IS_ELECTRON && IS_WINDOWS)} + noSettingsButton noAccountSelector={IS_ELECTRON && IS_MAC_OS} />
diff --git a/src/components/main/sections/Content/Content.module.scss b/src/components/main/sections/Content/Content.module.scss index 8bb15b6a..c5ab455c 100644 --- a/src/components/main/sections/Content/Content.module.scss +++ b/src/components/main/sections/Content/Content.module.scss @@ -34,12 +34,46 @@ flex-shrink: 0; - height: var(--tabs-container-height); + height: 2.75rem; + + transition: background-color 150ms; + :global(html.animation-level-0) & { + transition: none !important; + } + + &::after { + content: ''; + + position: absolute; + bottom: 0; + left: 50%; + transform: translate(-50%, -0.03125rem); + + width: 100%; + height: 0.0625rem; + + /* stylelint-disable-next-line plugin/whole-pixel */ + box-shadow: 0 0.025rem 0 0 var(--color-separator); + } .portraitContainer & { + --color-background-first: transparent; + position: sticky; top: var(--sticky-card-height); + width: 100%; + height: 3rem; + } +} + +.tabsContainerStuck { + background-color: var(--color-background-tab-bar); + backdrop-filter: saturate(180%) blur(20px); +} + +.tabsContent { + .portraitContainer & { width: 100%; max-width: 27rem; margin: 0 auto; @@ -47,14 +81,16 @@ } .tabs { - height: 2.75rem; + height: 3rem; padding: 0 1.5rem; .landscapeContainer & { justify-content: flex-start; + height: 2.75rem; padding: 0 0.75rem; + background-color: var(--color-background-first); border-radius: var(--border-radius-default) var(--border-radius-default) 0 0; } } @@ -68,6 +104,15 @@ padding-right: 0; padding-left: 0; } + + .portraitContainer & { + /* stylelint-disable-next-line plugin/whole-pixel */ + --tab-platform-height: 0.15625rem; + + padding: 0.5rem 0.25rem; + + font-size: 1rem; + } } .slides { @@ -87,30 +132,12 @@ @include respond-below(xs) { @supports (padding-bottom: var(--safe-area-bottom)) { padding-bottom: var(--safe-area-bottom) !important; - - :global(html.is-android) & { - &::after { - pointer-events: none; - content: ''; - - position: fixed; - z-index: 1; - right: 0; - bottom: 0; - left: 0; - - height: var(--safe-area-bottom); - - opacity: 80%; - background: var(--color-background-first); - } - } } } } .slide { - overflow: auto; + overflow: hidden; overflow-y: scroll; } @@ -141,6 +168,7 @@ flex: 1 1 auto; min-height: 0; + padding-bottom: calc(max(var(--safe-area-bottom), 0.375rem) + var(--bottombar-height)); } } diff --git a/src/components/main/sections/Content/Content.tsx b/src/components/main/sections/Content/Content.tsx index 98e9edf8..df5c2439 100644 --- a/src/components/main/sections/Content/Content.tsx +++ b/src/components/main/sections/Content/Content.tsx @@ -8,10 +8,12 @@ import type { DropdownItem } from '../../../ui/Dropdown'; import { ContentTab, SettingsState } from '../../../../global/types'; import { + IS_CAPACITOR, LANDSCAPE_MIN_ASSETS_TAB_VIEW, NOTCOIN_VOUCHERS_ADDRESS, PORTRAIT_MIN_ASSETS_TAB_VIEW, } from '../../../../config'; +import { requestMutation } from '../../../../lib/fasterdom/fasterdom'; import { getIsActiveStakingState } from '../../../../global/helpers/staking'; import { selectAccountStakingStates, @@ -20,8 +22,10 @@ import { selectEnabledTokensCountMemoizedFor, } from '../../../../global/selectors'; import buildClassName from '../../../../util/buildClassName'; +import { getStatusBarHeight } from '../../../../util/capacitor'; import { captureEvents, SwipeDirection } from '../../../../util/captureEvents'; -import { IS_TOUCH_ENV } from '../../../../util/windowEnvironment'; +import { IS_TOUCH_ENV, STICKY_CARD_INTERSECTION_THRESHOLD } from '../../../../util/windowEnvironment'; +import windowSize from '../../../../util/windowSize'; import { useDeviceScreen } from '../../../../hooks/useDeviceScreen'; import useEffectOnce from '../../../../hooks/useEffectOnce'; @@ -30,12 +34,13 @@ import useLang from '../../../../hooks/useLang'; import useLastCallback from '../../../../hooks/useLastCallback'; import useSyncEffect from '../../../../hooks/useSyncEffect'; +import CategoryHeader from '../../../explore/CategoryHeader'; +import Explore from '../../../explore/Explore'; import TabList from '../../../ui/TabList'; import Transition from '../../../ui/Transition'; import HideNftModal from '../../modals/HideNftModal'; import Activity from './Activities'; import Assets from './Assets'; -import Explore from './Explore'; import NftCollectionHeader from './NftCollectionHeader'; import Nfts from './Nfts'; import NftSelectionHeader from './NftSelectionHeader'; @@ -60,6 +65,7 @@ interface StateProps { addresses: string[]; isCollection: boolean; }; + currentSiteCategoryId?: number; } let activeNftKey = 0; @@ -76,6 +82,7 @@ function Content({ selectedNftsToHide, states, hasVesting, + currentSiteCategoryId, }: OwnProps & StateProps) { const { selectToken, @@ -88,6 +95,8 @@ function Content({ const lang = useLang(); const { isPortrait } = useDeviceScreen(); + // eslint-disable-next-line no-null/no-null + const tabsRef = useRef(null); const hasNftSelection = Boolean(selectedAddresses?.length); const numberOfStaking = useMemo(() => { @@ -163,7 +172,7 @@ function Content({ : [] ), { id: ContentTab.Activity, title: lang('Activity'), className: styles.tab }, - { id: ContentTab.Explore, title: lang('Explore'), className: styles.tab }, + ...(!isPortrait ? [{ id: ContentTab.Explore, title: lang('Explore'), className: styles.tab }] : []), { id: ContentTab.Nft, title: lang('NFT'), @@ -186,7 +195,7 @@ function Content({ className: styles.tab, }] : []), ], - [lang, nftCollections, shouldShowSeparateAssetsPanel, shouldRenderHiddenNftsSection], + [lang, nftCollections, shouldShowSeparateAssetsPanel, shouldRenderHiddenNftsSection, isPortrait], ); const activeTabIndex = useMemo( @@ -226,6 +235,28 @@ function Content({ onBack: () => handleSwitchTab(ContentTab.Assets), }); + useEffect(() => { + const stickyElm = tabsRef.current; + if (!isPortrait || !stickyElm) return undefined; + + const safeAreaTop = IS_CAPACITOR ? getStatusBarHeight() : windowSize.get().safeAreaTop; + const rootMarginTop = STICKY_CARD_INTERSECTION_THRESHOLD - safeAreaTop - 1; + + const observer = new IntersectionObserver(([e]) => { + requestMutation(() => { + e.target.classList.toggle(styles.tabsContainerStuck, e.intersectionRatio < 1); + }); + }, { + rootMargin: `${rootMarginTop}px 0px 0px 0px`, + threshold: [1], + }); + observer.observe(stickyElm); + + return () => { + observer.unobserve(stickyElm); + }; + }, [isPortrait, tabsRef]); + useEffect(() => { if (!IS_TOUCH_ENV) { return undefined; @@ -270,12 +301,15 @@ function Content({ return ; } + if (!isPortrait && currentSiteCategoryId) { + return ; + } + return currentCollectionAddress ? : ( ); @@ -311,13 +345,17 @@ function Content({ } function renderContent() { - const activeKey = hasNftSelection ? 2 : (currentCollectionAddress ? 1 : 0); + const activeKey = hasNftSelection || (!isPortrait && currentSiteCategoryId) + ? 2 + : (currentCollectionAddress ? 1 : 0); return ( <> - - {renderTabsPanel()} - +
+ + {renderTabsPanel()} + +
stickToFirst(global.currentAccountId), diff --git a/src/components/main/sections/Content/Explore.module.scss b/src/components/main/sections/Content/Explore.module.scss deleted file mode 100644 index 415b499b..00000000 --- a/src/components/main/sections/Content/Explore.module.scss +++ /dev/null @@ -1,296 +0,0 @@ -@import "../../../../styles/mixins"; - -.wrapper { - padding-top: 0.75rem; -} - -.list { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0.5rem; - align-content: start; - - padding: 0.5rem; - - &.landscapeList { - grid-template-columns: 1fr 1fr 1fr; - } - - @include adapt-padding-to-scrollbar(0.5rem); -} - -.suggestions { - --offset-y-value: 0; - - z-index: 1; - - margin: 0 0.5rem; - - @include adapt-margin-to-scrollbar(0.5rem); -} - -@include respond-below(xs) { - .suggestionsMenu { - max-height: 9.5rem; - } -} - -.suggestion { - align-items: center; - - padding: 0.5rem 2.25rem 0.5rem 0.5rem !important; -} - -.suggestionIcon { - margin-inline-end: 0.5rem; - - font-size: 1.25rem !important; - color: var(--color-gray-3); -} - -.suggestionAddress { - overflow: hidden; - - font-size: 1rem; - text-overflow: ellipsis; -} - -.clearSuggestion { - cursor: var(--custom-cursor, pointer); - - position: absolute; - top: 0; - right: 0; - - display: flex; - align-items: center; - justify-content: center; - - width: 2.25rem; - height: 2.25rem; - padding: 0; - - font-size: 1.25rem; - line-height: 1rem; - color: var(--color-gray-4); - - background: none; - border: none; - - transition: color 150ms; - - &:hover, - &:focus-visible { - color: var(--color-gray-3); - } -} - -.item { - cursor: var(--custom-cursor, pointer); - - position: relative; - - padding-bottom: 0.125rem; - - font-size: 0.9375rem; - font-weight: 600; - text-decoration: none; - - @media (hover: hover) { - &:hover, - &:focus-visible { - text-decoration: none; - - .image { - transform: scale(1.05); - } - } - } - - @media (pointer: coarse) { - &:active { - text-decoration: none; - - .image { - transform: scale(1.05); - } - } - } - - &.extended { - grid-column: 1 / 3; - - .imageWrapper { - aspect-ratio: 2; - } - } - - &.withBorder { - .imageWrapper { - border: 0.125rem solid var(--color-accent); - } - } -} - -.badge { - position: absolute; - z-index: 1; - top: -0.25rem; - right: -0.25rem; - - padding: 0 0.25rem; - - font-size: 0.75rem; - font-weight: 700; - line-height: 1.125rem; - color: var(--color-accent-button-text); - - background: var(--color-accent); - border-radius: 0.3125rem; -} - -.imageWrapper { - /* Fix for `border-radius` missing during transform on Safari. See https://stackoverflow.com/a/58283449 */ - isolation: isolate; - position: relative; - - overflow: hidden; - display: block !important; - - aspect-ratio: 1; - width: 100%; - max-width: 100%; - margin-bottom: 0.4375rem; - - border-radius: var(--border-radius-normal); - - @supports not (aspect-ratio: 1) { - height: auto; - max-height: 100%; - } -} - -.image { - position: absolute; - top: 0; - left: 0; - transform-origin: center; - - width: 100%; - height: 100%; - - object-fit: cover; - - transition: transform 300ms, opacity 300ms ease; - - :global(html.animation-level-0) & { - transition: none !important; - } -} - -.infoWrapper { - padding: 0 0.25rem; - - line-height: 1.0625rem; -} - -.title { - font-weight: 700; - color: var(--color-black); - word-break: break-word; -} - -.description { - overflow: hidden; - - padding: 0.125rem 0.25rem 0; - - font-size: 0.75rem; - line-height: 0.875rem; - color: var(--color-gray-2); - text-overflow: ellipsis; -} - -.emptyList { - display: flex; - flex-direction: column; - align-items: center; - - height: 100%; - padding-top: 1.875rem; - padding-bottom: 2rem; - - color: var(--color-gray-2); - - @include respond-above(xs) { - justify-content: center; - } -} - -.emptyListLoading { - padding-top: 8rem; - - @include respond-above(xs) { - padding-bottom: 8rem; - } -} - -.searchWrapper { - position: relative; - - display: flex; - flex-shrink: 0; - align-items: center; - - height: 2.25rem; - margin: 0 0.75rem 0.5rem; - - font-size: 1.25rem; - line-height: 1; - color: var(--color-gray-3); - - background-color: var(--color-background-second); - border-radius: var(--border-radius-big); - - @include adapt-margin-to-scrollbar(0.75rem); -} - -.searchIcon { - margin-left: 0.5rem; -} - -.searchInput { - $verticalScrollMargin: 0.625rem; - - scroll-margin: $verticalScrollMargin 0.5rem; - - width: 100%; - padding: 0 0.25rem; - - font-size: 1rem; - font-weight: 600; - color: var(--color-black); - - background: transparent; - border: none; - outline: none; - - appearance: none; - - &::placeholder { - font-weight: 600; - color: var(--color-gray-2); - } - - &:hover, - &:focus { - &::placeholder { - color: var(--color-interactive-input-text-hover-active); - } - } - - @include respond-below(xs) { - // To fit the floating elements, otherwise the input will hide under the elements - scroll-margin-top: calc(var(--safe-area-top) + var(--tabs-container-height) + var(--sticky-card-height) + $verticalScrollMargin); - } -} diff --git a/src/components/main/sections/Content/Explore.tsx b/src/components/main/sections/Content/Explore.tsx deleted file mode 100644 index 12515efd..00000000 --- a/src/components/main/sections/Content/Explore.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import React, { - memo, useEffect, useMemo, useRef, useState, -} from '../../../../lib/teact/teact'; -import { getActions, withGlobal } from '../../../../global'; - -import type { ApiSite } from '../../../../api/types'; - -import { ANIMATED_STICKER_BIG_SIZE_PX } from '../../../../config'; -import { selectCurrentAccountState } from '../../../../global/selectors'; -import buildClassName from '../../../../util/buildClassName'; -import { vibrate } from '../../../../util/capacitor'; -import { openUrl } from '../../../../util/openUrl'; -import stopEvent from '../../../../util/stopEvent'; -import { getHostnameFromUrl, isValidUrl } from '../../../../util/url'; -import { IS_ANDROID, IS_ANDROID_APP, IS_IOS_APP } from '../../../../util/windowEnvironment'; -import { ANIMATED_STICKERS_PATHS } from '../../../ui/helpers/animatedAssets'; - -import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev'; -import { useDeviceScreen } from '../../../../hooks/useDeviceScreen'; -import useFlag from '../../../../hooks/useFlag'; -import useLang from '../../../../hooks/useLang'; -import useLastCallback from '../../../../hooks/useLastCallback'; - -import DappFeed from '../../../dapps/DappFeed'; -import Site from '../../../explore/Site'; -import AnimatedIconWithPreview from '../../../ui/AnimatedIconWithPreview'; -import Menu from '../../../ui/Menu'; -import MenuItem from '../../../ui/MenuItem'; -import Spinner from '../../../ui/Spinner'; - -import styles from './Explore.module.scss'; - -interface OwnProps { - isActive?: boolean; -} - -interface StateProps { - sites?: ApiSite[]; - shouldRestrict: boolean; - browserHistory?: string[]; -} - -const SUGGESTIONS_OPEN_DELAY = 300; -const GOOGLE_SEARCH_URL = 'https://www.google.com/search?q='; - -function Explore({ - isActive, sites, shouldRestrict, browserHistory, -}: OwnProps & StateProps) { - const { - loadExploreSites, - getDapps, - addSiteToBrowserHistory, - removeSiteFromBrowserHistory, - } = getActions(); - - // eslint-disable-next-line no-null/no-null - const inputRef = useRef(null); - // eslint-disable-next-line no-null/no-null - const suggestionsTimeoutRef = useRef(null); - const lang = useLang(); - const { isLandscape } = useDeviceScreen(); - const [searchValue, setSearchValue] = useState(''); - const [isSuggestionsVisible, showSuggestions, hideSuggestions] = useFlag(false); - const filteredBrowserHistory = useMemo(() => { - const result = browserHistory?.filter((url) => url.toLowerCase().includes(searchValue.toLowerCase())); - - return result?.length ? result : undefined; - }, [browserHistory, searchValue]); - const renderingBrowserHistory = useCurrentOrPrev(filteredBrowserHistory, true); - - const openSite = (originalUrl: string, isExternal?: boolean, title?: string) => { - let url = originalUrl; - if (!url.startsWith('http:') && !url.startsWith('https:')) { - url = `https://${url}`; - } - if (!isValidUrl(url)) { - url = `${GOOGLE_SEARCH_URL}${encodeURIComponent(originalUrl)}`; - } else { - addSiteToBrowserHistory({ url }); - } - - openUrl(url, isExternal, title, getHostnameFromUrl(url)); - }; - - const handleSiteClick = useLastCallback(( - e: React.SyntheticEvent, - url: string, - ) => { - vibrate(); - hideSuggestions(); - const site = sites?.find(({ url: currentUrl }) => currentUrl === url); - openSite(url, site?.isExternal, site?.name); - }); - - const handleSiteClear = (e: React.MouseEvent, url: string) => { - stopEvent(e); - - removeSiteFromBrowserHistory({ url }); - }; - - const handleChange = (e: React.ChangeEvent) => { - setSearchValue(e.target.value); - }; - - const handleFocus = () => { - if (!filteredBrowserHistory?.length) return; - - // Simultaneous opening of the virtual keyboard and display of Saved Addresses causes animation degradation - if (IS_ANDROID) { - suggestionsTimeoutRef.current = window.setTimeout(showSuggestions, SUGGESTIONS_OPEN_DELAY); - } else { - showSuggestions(); - } - }; - - const handleBlur = () => { - if (!isSuggestionsVisible) return; - - hideSuggestions(); - if (suggestionsTimeoutRef.current) { - window.clearTimeout(suggestionsTimeoutRef.current); - } - }; - - const handleMenuClose = useLastCallback(() => { - inputRef.current?.blur(); - }); - - const handleSubmit = (e: React.FormEvent) => { - stopEvent(e); - - handleMenuClose(); - openSite(searchValue); - setSearchValue(''); - }; - - useEffect(() => { - if (!isActive) return; - - getDapps(); - loadExploreSites({ isLandscape }); - }, [isActive, isLandscape]); - - function renderSearch() { - return ( -
- - - - ); - } - - function renderSearchSuggestions() { - return ( - - {renderingBrowserHistory?.map((url) => ( - - - {getHostnameFromUrl(url)} - - - - ))} - - ); - } - - if (sites === undefined) { - return ( -
- -
- ); - } - - if (sites.length === 0) { - return ( -
- -

{lang('No partners yet')}

-
- ); - } - - return ( -
- {renderSearch()} - {renderSearchSuggestions()} - -
- {sites.filter((site) => !(shouldRestrict && site.canBeRestricted)).map((site, i) => ( - - ))} -
-
- ); -} - -export default memo(withGlobal((global): StateProps => { - const { browserHistory } = selectCurrentAccountState(global) || {}; - return { - sites: global.exploreSites, - shouldRestrict: global.restrictions.isLimitedRegion && (IS_IOS_APP || IS_ANDROID_APP), - browserHistory, - }; -})(Explore)); diff --git a/src/components/main/sections/Content/NftCollectionHeader.module.scss b/src/components/main/sections/Content/NftCollectionHeader.module.scss index cb1a821f..92958188 100644 --- a/src/components/main/sections/Content/NftCollectionHeader.module.scss +++ b/src/components/main/sections/Content/NftCollectionHeader.module.scss @@ -1,3 +1,5 @@ +@import "../../../../styles/mixins"; + .root { display: flex; align-items: center; @@ -8,19 +10,10 @@ background: var(--color-background-first); border-radius: var(--border-radius-default) var(--border-radius-default) 0 0; - &::after { - content: ''; - - position: absolute; - bottom: 0; - left: 50%; - transform: translate(-50%, -0.03125rem); - - width: 100vw; - height: 0.0625rem; + transition: background-color 150ms; - /* stylelint-disable-next-line plugin/whole-pixel */ - box-shadow: 0 0.025rem 0 0 var(--color-separator); + :global(html.animation-level-0) & { + transition: none !important; } } diff --git a/src/components/main/sections/Content/NftSelectionHeader.tsx b/src/components/main/sections/Content/NftSelectionHeader.tsx index abb777be..501c7ed1 100644 --- a/src/components/main/sections/Content/NftSelectionHeader.tsx +++ b/src/components/main/sections/Content/NftSelectionHeader.tsx @@ -163,7 +163,6 @@ function NftSelectionHeader({ selectedAddresses, byAddress, currentCollectionAdd onClose={handleMenuClose} />
-
); } diff --git a/src/components/main/sections/Content/Nfts.tsx b/src/components/main/sections/Content/Nfts.tsx index dcc1e998..cbec67fe 100644 --- a/src/components/main/sections/Content/Nfts.tsx +++ b/src/components/main/sections/Content/Nfts.tsx @@ -3,7 +3,7 @@ import { getActions, withGlobal } from '../../../../global'; import type { ApiNft } from '../../../../api/types'; -import { ANIMATED_STICKER_BIG_SIZE_PX, NOTCOIN_VOUCHERS_ADDRESS, TON_DIAMONDS_URL } from '../../../../config'; +import { ANIMATED_STICKER_BIG_SIZE_PX, TON_DIAMONDS_URL } from '../../../../config'; import renderText from '../../../../global/helpers/renderText'; import { selectCurrentAccountState } from '../../../../global/selectors'; import buildClassName from '../../../../util/buildClassName'; @@ -13,10 +13,8 @@ import { ANIMATED_STICKERS_PATHS } from '../../../ui/helpers/animatedAssets'; import { useDeviceScreen } from '../../../../hooks/useDeviceScreen'; import { useIntersectionObserver } from '../../../../hooks/useIntersectionObserver'; import useLang from '../../../../hooks/useLang'; -import useLastCallback from '../../../../hooks/useLastCallback'; import AnimatedIconWithPreview from '../../../ui/AnimatedIconWithPreview'; -import Button from '../../../ui/Button'; import Spinner from '../../../ui/Spinner'; import Transition from '../../../ui/Transition'; import Nft from './Nft'; @@ -49,7 +47,7 @@ function Nfts({ blacklistedNftAddresses, whitelistedNftAddresses, }: OwnProps & StateProps) { - const { clearNftsSelection, burnNfts } = getActions(); + const { clearNftsSelection } = getActions(); const lang = useLang(); const { isLandscape } = useDeviceScreen(); @@ -85,10 +83,6 @@ function Nfts({ isDisabled: !nfts?.length, }); - const handleBurnNotcoinVouchersClick = useLastCallback(() => { - burnNfts({ nfts: nfts! }); - }); - if (nfts === undefined) { return (
@@ -123,51 +117,21 @@ function Nfts({ } return ( -
- {currentCollectionAddress === NOTCOIN_VOUCHERS_ADDRESS && ( - - )} - -
- {nfts.map((nft) => ( - - ))} -
-
-
+ + {nfts.map((nft) => ( + + ))} + ); } diff --git a/src/components/settings/Settings.module.scss b/src/components/settings/Settings.module.scss index 8872bd08..0d4a9838 100644 --- a/src/components/settings/Settings.module.scss +++ b/src/components/settings/Settings.module.scss @@ -1,7 +1,22 @@ @import "../../styles/mixins"; .wrapper { + --header-padding-top: 1.5rem; + --header-title-height: 1.1875rem; + --header-padding-bottom: 1.375rem; + height: 100%; + + :global(html.with-safe-area-top) & { + --header-padding-top: 0.75rem; + } + + @include respond-below(xs) { + // Fix for opera, dead zone of 37 pixels in extension window on windows + :global(html.is-windows.is-opera.is-extension) & { + --header-padding-top: 2.3125rem; + } + } } .slide { @@ -13,24 +28,23 @@ min-height: 0; max-height: 100%; margin: 0 auto; + padding-top: var(--safe-area-top); - @include respond-below(xs) { - max-width: 27rem; + &:not(:global(.Transition_slide-from)):not(:global(.Transition_slide-to)) { + position: relative; + } + + & > & { + --safe-area-top: 0px; } } -.transitionContainer { - background-color: var(--color-background-window); +.nestedTransition { + --safe-area-top: 0px; } -@include respond-below(xs) { - @supports (padding-top: var(--safe-area-top)) { - html:global(:not(.is-native-bottom-sheet)) { - .transitionSlide { - padding-top: var(--safe-area-top); - } - } - } +.transitionContainer { + background-color: var(--color-background-second); } .developerTitle { @@ -52,25 +66,37 @@ grid-template-columns: 1fr max-content 1fr; align-items: center; - padding: 1.5rem 0.125rem 1.375rem; + padding: var(--header-padding-top) 0.125rem var(--header-padding-bottom); font-size: 1.0625rem; font-weight: 700; line-height: 1.0625rem; - @include respond-below(xs) { - // Fix for opera, dead zone of 37 pixels in extension window on windows - :global(html.is-windows.is-opera.is-extension) & { - padding-top: 2.3125rem; - } + transition: background-color 300ms; + + &.onlyTextHeader { + grid-template-columns: unset; } - :global(html.with-safe-area-top) & { - padding-top: 0.75rem; + :global(html.animation-level-0) & { + transition: none !important; } - &.onlyTextHeader { - grid-template-columns: unset; + @include respond-below(xs) { + position: absolute !important; + z-index: 2; + top: 0; + left: 0; + + width: 100%; + padding-top: calc(var(--header-padding-top) + var(--safe-area-top)); + + &:global(.is-scrolled) { + @supports (backdrop-filter: saturate(180%) blur(20px)) { + background-color: var(--color-background-tab-bar); + backdrop-filter: saturate(180%) blur(20px); + } + } } } @@ -88,6 +114,10 @@ font-size: 1.0625rem; color: var(--color-accent); + + &.hidden { + visibility: hidden; + } } .iconChevron { @@ -106,6 +136,7 @@ padding: 0 0.5rem; + line-height: var(--header-title-height); color: var(--color-black) } @@ -121,7 +152,7 @@ height: 100%; min-height: 0; - padding: 0.75rem 1rem 1rem; + padding: 0.75rem 1rem max(var(--safe-area-bottom), 1rem); @include adapt-padding-to-scrollbar(1rem); @@ -129,8 +160,14 @@ overflow: visible; } - @supports (padding-bottom: max(var(--safe-area-bottom), 1rem)) { - padding-bottom: max(var(--safe-area-bottom), 1rem); + @include respond-below(xs) { + padding-top: calc(var(--header-padding-top) + var(--header-title-height) + var(--header-padding-bottom) + 0.75rem); + } + + @include respond-below(xs) { + &.withBottomSpace { + padding-bottom: calc(max(var(--safe-area-bottom), 1rem) + var(--bottombar-height) + 1rem); + } } } @@ -554,6 +591,10 @@ a.item:hover { @supports (padding-bottom: var(--safe-area-bottom)) { padding-bottom: max(var(--safe-area-bottom), 1rem); } + + @include respond-below(xs) { + padding-top: calc(var(--header-padding-top) + var(--header-title-height) + var(--header-padding-bottom)); + } } .sticker { diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index 09e379ff..b259888f 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -36,19 +36,22 @@ import { toBig, toDecimal } from '../../util/decimals'; import { formatCurrency, getShortCurrencySymbol } from '../../util/formatNumber'; import { MEMO_EMPTY_ARRAY } from '../../util/memo'; import { openUrl } from '../../util/openUrl'; -import resolveModalTransitionName from '../../util/resolveModalTransitionName'; +import resolveSlideTransitionName from '../../util/resolveSlideTransitionName'; import { captureControlledSwipe } from '../../util/swipeController'; import { IS_BIOMETRIC_AUTH_SUPPORTED, IS_DAPP_SUPPORTED, IS_DELEGATED_BOTTOM_SHEET, + IS_DELEGATING_BOTTOM_SHEET, IS_ELECTRON, IS_LEDGER_SUPPORTED, IS_TOUCH_ENV, IS_WEB, } from '../../util/windowEnvironment'; +import { useDeviceScreen } from '../../hooks/useDeviceScreen'; import useFlag from '../../hooks/useFlag'; +import useHideBottomBar from '../../hooks/useHideBottomBar'; import useHistoryBack from '../../hooks/useHistoryBack'; import useLang from '../../hooks/useLang'; import useLastCallback from '../../hooks/useLastCallback'; @@ -171,6 +174,7 @@ function Settings({ } = getActions(); const lang = useLang(); + const { isPortrait } = useDeviceScreen(); // eslint-disable-next-line no-null/no-null const transitionRef = useRef(null); const { renderingKey } = useModalTransitionKeys(state, isOpen); @@ -179,6 +183,7 @@ function Settings({ const [isDeveloperModalOpen, openDeveloperModal, closeDeveloperModal] = useFlag(); const [isLogOutModalOpened, openLogOutModal, closeLogOutModal] = useFlag(); + const isInitialScreen = renderingKey === SettingsState.Initial; const activeLang = useMemo(() => LANG_LIST.find((l) => l.langCode === langCode), [langCode]); @@ -226,10 +231,12 @@ function Settings({ }); useHistoryBack({ - isActive: !isInsideModal && renderingKey === SettingsState.Initial, + isActive: !isInsideModal && isInitialScreen, onBack: handleCloseSettings, }); + useHideBottomBar(isOpen && !isInitialScreen); + const handleConnectedDappsOpen = useLastCallback(() => { getDapps(); setSettingsState({ state: SettingsState.Dapps }); @@ -333,8 +340,8 @@ function Settings({ }); const handleBackOrCloseAction = useLastCallback(() => { - if (renderingKey === SettingsState.Initial) { - handleCloseSettings(); + if (isInitialScreen) { + if (isInsideModal) handleCloseSettings(); } else { handleBackClick(); } @@ -360,8 +367,8 @@ function Settings({ }; useEffect( - () => captureEscKeyListener(handleBackOrCloseAction), - [handleBackOrCloseAction], + () => captureEscKeyListener(isInsideModal ? handleBackOrCloseAction : handleBackClick), + [isInsideModal], ); useEffect(() => { @@ -375,7 +382,7 @@ function Settings({ setSettingsState({ state: prevRenderingKeyRef.current! }); }, }); - }, [handleBackClick, handleBackOrCloseAction, prevRenderingKeyRef]); + }, [prevRenderingKeyRef]); function renderHandleDeeplinkButton() { return ( @@ -399,12 +406,17 @@ function Settings({ ) : (
- @@ -413,7 +425,7 @@ function Settings({ )}
{IS_WEB && ( @@ -735,10 +747,12 @@ function Settings({ {renderContent} - {IS_BIOMETRIC_AUTH_SUPPORTED && } + {IS_BIOMETRIC_AUTH_SUPPORTED && }
); } diff --git a/src/components/settings/SettingsAppearance.tsx b/src/components/settings/SettingsAppearance.tsx index a2d00b39..65e8a341 100644 --- a/src/components/settings/SettingsAppearance.tsx +++ b/src/components/settings/SettingsAppearance.tsx @@ -78,7 +78,7 @@ function SettingsAppearance({ const handleThemeChange = useLastCallback((newTheme: string) => { document.documentElement.classList.add('no-transitions'); setTheme({ theme: newTheme as Theme }); - switchTheme(newTheme as Theme, true); + switchTheme(newTheme as Theme, isInsideModal); setTimeout(() => { document.documentElement.classList.remove('no-transitions'); }, SWITCH_THEME_DURATION_MS); diff --git a/src/components/settings/SettingsDapps.tsx b/src/components/settings/SettingsDapps.tsx index ca4abc55..826842cf 100644 --- a/src/components/settings/SettingsDapps.tsx +++ b/src/components/settings/SettingsDapps.tsx @@ -82,7 +82,7 @@ function SettingsDapps({ const dappList = dapps.map(renderDapp); return ( -
+ <>
-
+ ); } diff --git a/src/components/settings/SettingsSecurity.tsx b/src/components/settings/SettingsSecurity.tsx index 12fc93ac..326f216b 100644 --- a/src/components/settings/SettingsSecurity.tsx +++ b/src/components/settings/SettingsSecurity.tsx @@ -20,7 +20,7 @@ import { selectIsMultichainAccount, selectIsPasswordPresent } from '../../global import buildClassName from '../../util/buildClassName'; import { getIsNativeBiometricAuthSupported, vibrateOnSuccess } from '../../util/capacitor'; import isMnemonicPrivateKey from '../../util/isMnemonicPrivateKey'; -import resolveModalTransitionName from '../../util/resolveModalTransitionName'; +import resolveSlideTransitionName from '../../util/resolveSlideTransitionName'; import { pause } from '../../util/schedulers'; import { IS_BIOMETRIC_AUTH_SUPPORTED, IS_ELECTRON, IS_IOS, IS_IOS_APP, @@ -489,6 +489,7 @@ function SettingsSecurity({ isActive={isSlideActive && isActive} error={passwordError} containerClassName={IS_CAPACITOR ? styles.passwordFormContent : styles.passwordFormContentInModal} + forceBiometricsInMain={!isInsideModal} placeholder={lang('Enter your current password')} submitLabel={lang('Continue')} onCancel={handleBackToSettingsClick} @@ -515,7 +516,7 @@ function SettingsSecurity({ {lang('Change Password')}
)} -
+
{lang('Password Changed!')}
)} -
+
{ if (!isActive) return; @@ -59,11 +64,6 @@ function NativeBiometricsTurnOn({ } }, [isNativeBiometricsEnabled, handleBackClick]); - useHistoryBack({ - isActive, - onBack: handleBackClick, - }); - const handleSubmit = useLastCallback((password: string) => { enableNativeBiometrics({ password }); }); @@ -71,15 +71,12 @@ function NativeBiometricsTurnOn({ return (
- +
+ +
- {nativeToken ? renderText(lang('$fee_value_bold', { - fee: `${formatFee({ - terms: { native: isNativeEnough ? realNetworkFee : networkFee }, - token: nativeToken, - nativeToken, - precision: isNativeEnough ? 'approximate' : 'lessThan', - })}`, - })) : ''} + {token && renderText(lang('$fee_value_bold', { + fee: ( + + ), + }))}
); @@ -187,7 +188,7 @@ function StakingClaimModal({ onClose={cancelStakingClaim} > {error} ); } else { - content = nativeToken ? lang('$fee_value', { - fee: ( - - {formatFee({ - terms: { native: realFee }, - token: nativeToken, - nativeToken, - precision: 'approximate', - })} - - ), + content = token ? lang('$fee_value', { + fee: , }) : ''; } diff --git a/src/components/staking/UnstakeModal.tsx b/src/components/staking/UnstakeModal.tsx index 06d3eb5b..7ab5b49d 100644 --- a/src/components/staking/UnstakeModal.tsx +++ b/src/components/staking/UnstakeModal.tsx @@ -25,14 +25,13 @@ import { import buildClassName from '../../util/buildClassName'; import { formatRelativeHumanDateTime } from '../../util/dateFormat'; import { fromDecimal, toBig, toDecimal } from '../../util/decimals'; -import { formatFee } from '../../util/fee/formatFee'; import { getTonStakingFees } from '../../util/fee/getTonOperationFees'; import { formatCurrency, formatCurrencySimple, getShortCurrencySymbol, } from '../../util/formatNumber'; -import resolveModalTransitionName from '../../util/resolveModalTransitionName'; +import resolveSlideTransitionName from '../../util/resolveSlideTransitionName'; import { ANIMATED_STICKERS_PATHS } from '../ui/helpers/animatedAssets'; import { ASSET_LOGO_PATHS } from '../ui/helpers/assetLogos'; @@ -52,6 +51,7 @@ import LedgerConfirmOperation from '../ledger/LedgerConfirmOperation'; import LedgerConnect from '../ledger/LedgerConnect'; import AnimatedIconWithPreview from '../ui/AnimatedIconWithPreview'; import Button from '../ui/Button'; +import Fee from '../ui/Fee'; import Modal from '../ui/Modal'; import ModalHeader from '../ui/ModalHeader'; import PasswordForm from '../ui/PasswordForm'; @@ -331,17 +331,8 @@ function UnstakeModal({ : instantAvailable ? 2 : 3; - let content: string | React.JSX.Element | TeactNode[] = nativeToken ? lang('$fee_value', { - fee: ( - - {formatFee({ - terms: { native: realFee }, - token: nativeToken, - nativeToken, - precision: 'approximate', - })} - - ), + let content: string | React.JSX.Element | TeactNode[] = token ? lang('$fee_value', { + fee: , }) : ''; if (token) { @@ -563,7 +554,7 @@ function UnstakeModal({ onCloseAnimationEnd={updateNextKey} > (null); - const [hasAmountInError, setHasAmountInError] = useState(false); - const currentTokenInSlug = tokenInSlug ?? TONCOIN.slug; const currentTokenOutSlug = tokenOutSlug ?? DEFAULT_SWAP_SECOND_TOKEN_SLUG; @@ -148,80 +146,67 @@ function SwapInitial({ [nativeTokenInSlug, tokens], ); const nativeBalance = nativeUserTokenIn?.amount ?? 0n; - const isNativeIn = currentTokenInSlug && currentTokenInSlug === nativeTokenInSlug; - const isTonIn = tokenIn?.chain === 'ton'; - const amountInBigint = amountIn && tokenIn ? fromDecimal(amountIn, tokenIn.decimals) : 0n; - const amountOutBigint = amountOut && tokenOut ? fromDecimal(amountOut, tokenOut.decimals) : 0n; + const amountInBigint = amountIn && tokenIn ? fromDecimal(amountIn, tokenIn.decimals) : undefined; + const amountOutBigint = amountOut && tokenOut ? fromDecimal(amountOut, tokenOut.decimals) : undefined; const balanceIn = tokenIn?.amount ?? 0n; - const networkFeeBigint = (() => { - let value = 0n; - - if (Number(networkFee) > 0) { - value = fromDecimal(networkFee, nativeUserTokenIn?.decimals); - } - - return value; - })(); - - const dieselFeeBigint = dieselFee && tokenIn ? fromDecimal(dieselFee, tokenIn.decimals) : 0n; + const explainedFee = useMemo( + () => explainSwapFee({ + swapType, + tokenInSlug, + networkFee, + realNetworkFee, + ourFee, + dieselStatus, + dieselFee, + nativeTokenInBalance: nativeBalance, + }), + [swapType, tokenInSlug, networkFee, realNetworkFee, ourFee, dieselStatus, dieselFee, nativeBalance], + ); - const maxAmount = (() => { - let value = balanceIn; + const maxAmount = getMaxSwapAmount({ + swapType, + tokenInBalance: balanceIn, + tokenIn, + fullNetworkFee: explainedFee.fullFee?.networkTerms, + ourFeePercent, + }); - if (isNativeIn) { - value -= networkFeeBigint; - } + // Note: this constant has 3 distinct meaningful values + const isEnoughBalance = isBalanceSufficientForSwap({ + swapType, + tokenInBalance: balanceIn, + tokenIn, + fullNetworkFee: explainedFee.fullFee?.networkTerms, + ourFeePercent, + amountIn, + nativeTokenInBalance: nativeBalance, + }); - if (swapType === SwapType.OnChain) { - if (dieselFeeBigint) { - value -= dieselFeeBigint; - } + const networkFeeBigint = networkFee !== undefined && nativeUserTokenIn + ? fromDecimal(networkFee, nativeUserTokenIn.decimals) + : 0n; + const isEnoughNative = nativeBalance >= networkFeeBigint; - if (ourFeePercent) { - value = bigintDivideToNumber(value, 1 + (ourFeePercent / 100)); - } - } + const isDieselNotAuthorized = explainedFee.isGasless && dieselStatus === 'not-authorized'; - return bigintMax(value, 0n); - })(); + const canSubmit = isDieselNotAuthorized || ( + (amountInBigint ?? 0n) > 0n + && (amountOutBigint ?? 0n) > 0n + && isEnoughBalance + && (!explainedFee.isGasless || dieselStatus === 'available') + && !isEstimating + && errorType === undefined + ); - const totalNativeAmount = networkFeeBigint + (isNativeIn ? amountInBigint : 0n); - const isEnoughNative = nativeBalance >= totalNativeAmount; - const amountOutValue = amountInBigint <= 0n && inputSource === SwapInputSource.In + const hasAmountInError = amountInBigint !== undefined && maxAmount !== undefined && amountInBigint > maxAmount; + const amountOutValue = (amountInBigint ?? 0n) <= 0n && inputSource === SwapInputSource.In ? '' : amountOut?.toString(); - - const dieselRealFee = useMemo(() => { - if (!dieselFee || !realNetworkFee) return 0; - - const nativeDeficit = toDecimal(totalNativeAmount - nativeBalance); - return Big(dieselFee!).div(nativeDeficit).mul(realNetworkFee ?? 0).toNumber(); - }, [dieselFee, totalNativeAmount, nativeBalance, realNetworkFee]); - - const isErrorExist = errorType !== undefined; - - const isGaslessSwap = Boolean(swapType === SwapType.OnChain - && !isEnoughNative - && tokenIn?.tokenAddress - && dieselStatus - && dieselStatus !== 'not-available'); - - const isCorrectAmountIn = Boolean( - amountIn - && tokenIn - && amountInBigint > 0 - && amountInBigint <= maxAmount, - ) || (tokenIn && !nativeTokenInSlug); - - const isEnoughFee = swapType !== SwapType.CrosschainToWallet - ? (isEnoughNative && (swapType === SwapType.CrosschainFromWallet || swapType === SwapType.OnChain)) - || (swapType === SwapType.OnChain && dieselStatus && ['available', 'not-authorized'].includes(dieselStatus)) - : true; - - const isCorrectAmountOut = amountOut && amountOutBigint > 0; - const canSubmit = Boolean(isCorrectAmountIn && isCorrectAmountOut && isEnoughFee && !isEstimating && !isErrorExist); + const isAmountGreaterThanBalance = balanceIn !== undefined && amountInBigint !== undefined + && amountInBigint > balanceIn; + const isInsufficientFee = isEnoughBalance === false && !isAmountGreaterThanBalance; const isPriceImpactError = priceImpact >= MAX_PRICE_IMPACT_VALUE; const isCrosschain = swapType === SwapType.CrosschainFromWallet || swapType === SwapType.CrosschainToWallet; @@ -265,6 +250,8 @@ function SwapInitial({ }, ESTIMATE_REQUEST_INTERVAL); }); + const [currentSubModal, openSettingsModal, openFeeModal, closeSubModal] = useSubModals(explainedFee); + useEffect(() => { if (!tokenInSlug && !tokenOutSlug) { setDefaultSwapParams(); @@ -324,34 +311,10 @@ function SwapInitial({ } }, [tokenIn, tokenOut, isMultichainAccount]); - const validateAmountIn = useLastCallback((amount: string | undefined) => { - if (swapType === SwapType.CrosschainToWallet || !amount || !tokenIn) { - setHasAmountInError(false); - return; - } - - const hasError = fromDecimal(amount, tokenIn.decimals) > maxAmount; - setHasAmountInError(hasError); - }); - - useEffect(() => { - validateAmountIn(amountIn); - }, [amountIn, tokenIn, validateAmountIn, swapType, maxAmount]); - const handleAmountInChange = useLastCallback( - (amount: string | undefined, noReset = false) => { + (amount: string | undefined) => { setSwapIsMaxAmount({ isMaxAmount: false }); - if (!noReset) { - setHasAmountInError(false); - } - - if (!amount) { - debounceSetAmountIn({ amount: undefined }); - return; - } - - validateAmountIn(amount); - debounceSetAmountIn({ amount }); + debounceSetAmountIn({ amount: amount || undefined }); }, ); @@ -365,10 +328,13 @@ function SwapInitial({ const handleMaxAmountClick = (e: React.MouseEvent) => { e.preventDefault(); + if (maxAmount === undefined) { + return; + } + void vibrate(); const amount = toDecimal(maxAmount, tokenIn!.decimals); - validateAmountIn(amount); setSwapIsMaxAmount({ isMaxAmount: true }); setSwapAmountIn({ amount }); }; @@ -380,7 +346,7 @@ function SwapInitial({ return; } - if (isGaslessSwap && dieselStatus === 'not-authorized') { + if (isDieselNotAuthorized) { authorizeDiesel(); return; } @@ -412,23 +378,13 @@ function SwapInitial({ switchSwapTokens(); }); - const openSettingsModal = useLastCallback(() => { - toggleSwapSettingsModal({ isOpen: true }); - }); - - const closeSettingsModal = useLastCallback(() => { - toggleSwapSettingsModal({ isOpen: false }); - }); - function renderBalance() { - const isBalanceVisible = Boolean(isMultichainAccount ? nativeTokenInSlug : isTonIn); - return ( - {isBalanceVisible && ( + {maxAmount !== undefined && (
{lang('$max_balance', { @@ -450,6 +406,33 @@ function SwapInitial({ ); } + function renderFee() { + const shouldShow = (amountIn && amountOut) // We aim to synchronize the disappearing of the fee with the DEX chooser disappearing + || ((amountIn || amountOut) && errorType); // Without this sub-condition the fee wouldn't be shown when the amount is outside the Changelly limits + + let terms: FeeTerms | undefined; + let precision: FeePrecision = 'exact'; + + if (shouldShow) { + const actualFee = isInsufficientFee ? explainedFee.fullFee : explainedFee.realFee; + if (actualFee) { + ({ terms, precision } = actualFee); + } + } + + return ( + + ); + } + function renderPriceImpactWarning() { if (!priceImpact || !isPriceImpactError || isCrosschain) { return undefined; @@ -489,29 +472,27 @@ function SwapInitial({ return (
-
- - - {lang('Cross-chain exchange provided by Changelly')} - - - { - lang('$swap_changelly_agreement_message', { - terms: ( - - {lang('$swap_changelly_terms_of_use')} - - ), - policy: ( - - {lang('$swap_changelly_privacy_policy')} - - ), - kyc: Changelly AML/KYC, - }) - } - -
+ + + {lang('Cross-chain exchange provided by Changelly')} + + + { + lang('$swap_changelly_agreement_message', { + terms: ( + + {lang('$swap_changelly_terms_of_use')} + + ), + policy: ( + + {lang('$swap_changelly_privacy_policy')} + + ), + kyc: Changelly AML/KYC, + }) + } +
); } @@ -569,45 +550,43 @@ function SwapInitial({ {!isCrosschain && }
+ {renderFee()} {renderPriceImpactWarning()} {renderChangellyInfo()} -
- - - -
- +
+ ); @@ -703,3 +682,17 @@ function AnimatedArrows({ onClick }: { onClick?: NoneToVoidFunction }) {
); } + +function useSubModals(explainedFee: ExplainedSwapFee) { + const isFeeModalAvailable = explainedFee.realFee?.precision !== 'exact'; + const [currentModal, setCurrentModal] = useState<'settings' | 'feeDetails'>(); + + const openSettings = useLastCallback(() => setCurrentModal('settings')); + const openFeeDetailsIfAvailable = useMemo( + () => (isFeeModalAvailable ? () => setCurrentModal('feeDetails') : undefined), + [isFeeModalAvailable], + ); + const close = useLastCallback(() => setCurrentModal(undefined)); + + return [currentModal, openSettings, openFeeDetailsIfAvailable, close] as const; +} diff --git a/src/components/swap/SwapModal.tsx b/src/components/swap/SwapModal.tsx index 4ca16d53..e82cafaf 100644 --- a/src/components/swap/SwapModal.tsx +++ b/src/components/swap/SwapModal.tsx @@ -15,7 +15,7 @@ import { } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import { formatCurrencyExtended } from '../../util/formatNumber'; -import resolveModalTransitionName from '../../util/resolveModalTransitionName'; +import resolveSlideTransitionName from '../../util/resolveSlideTransitionName'; import { useDeviceScreen } from '../../hooks/useDeviceScreen'; import useLang from '../../hooks/useLang'; @@ -60,7 +60,6 @@ function SwapModal({ payinAddress, payoutAddress, payinExtraId, - isSettingsModalOpen, shouldResetOnClose, }, swapTokens, @@ -266,8 +265,7 @@ function SwapModal({ } } - const forceFullNative = isSettingsModalOpen - || FULL_SIZE_NBS_STATES.includes(renderingKey) + const forceFullNative = FULL_SIZE_NBS_STATES.includes(renderingKey) // Crosschain exchanges have additional information that may cause the height of the modal to be insufficient || (renderingKey === SwapState.Complete && renderedSwapType !== SwapType.OnChain); @@ -283,7 +281,7 @@ function SwapModal({ onCloseAnimationEnd={handleModalClose} > void; + onNetworkFeeClick?: () => void; } interface StateProps { @@ -42,7 +44,6 @@ interface StateProps { amountOut?: string; tokenIn?: ApiSwapAsset; tokenOut?: ApiSwapAsset; - nativeTokenIn?: Pick; networkFee?: string; realNetworkFee?: string; swapType?: SwapType; @@ -51,6 +52,9 @@ interface StateProps { amountOutMin?: string; ourFee?: string; ourFeePercent?: number; + dieselStatus?: DieselStatus; + dieselFee?: string; + nativeTokenInBalance?: bigint; } const SLIPPAGE_VALUES = [0.5, 1, 2, 5, 10]; @@ -58,51 +62,67 @@ const MAX_SLIPPAGE_VALUE = 50; export const MAX_PRICE_IMPACT_VALUE = 5; -function SwapSettingsModal({ - isOpen, - isGaslessSwap, +function SwapSettingsContent({ onClose, amountIn, amountOut, swapType, tokenIn, tokenOut, - nativeTokenIn, slippage, - priceImpact = 0, - networkFee = '0', - realNetworkFee = '0', - realNetworkFeeInDiesel, - amountOutMin = '0', - ourFee = '0', + priceImpact, + networkFee, + realNetworkFee, + amountOutMin, + ourFee, ourFeePercent = DEFAULT_OUR_SWAP_FEE, -}: OwnProps & StateProps) { + dieselStatus, + dieselFee, + nativeTokenInBalance, + showFullNetworkFee, + onNetworkFeeClick, +}: Omit & StateProps) { const { setSlippage } = getActions(); const lang = useLang(); const canEditSlippage = swapType === SwapType.OnChain; const [isSlippageFocused, markSlippageFocused, unmarkSlippageFocused] = useFlag(); - const [hasError, setHasError] = useState(false); + // In order to reset this state when the modal is closed, we rely on the fact that Modal unmounts the content when + // it's closed. const [currentSlippage, setCurrentSlippage] = useState(slippage); - const priceImpactError = priceImpact >= MAX_PRICE_IMPACT_VALUE; - const slippageError = currentSlippage === undefined || currentSlippage > MAX_SLIPPAGE_VALUE; + const priceImpactError = (priceImpact ?? 0) >= MAX_PRICE_IMPACT_VALUE; + const slippageError = currentSlippage === undefined + ? 'Slippage not specified' + : currentSlippage > MAX_SLIPPAGE_VALUE + ? 'Slippage too high' + : ''; const handleSave = useLastCallback(() => { setSlippage({ slippage: currentSlippage! }); onClose(); }); - const resetModal = useLastCallback(() => { - setCurrentSlippage(slippage); - }); - const handleInputChange = useLastCallback((stringValue?: string) => { const value = stringValue ? Number(stringValue) : undefined; setCurrentSlippage(value); }); + const explainedFee = useMemo( + () => explainSwapFee({ + swapType, + tokenInSlug: tokenIn?.slug, + networkFee, + realNetworkFee, + ourFee, + dieselStatus, + dieselFee, + nativeTokenInBalance, + }), + [swapType, tokenIn, networkFee, realNetworkFee, ourFee, dieselStatus, dieselFee, nativeTokenInBalance], + ); + function renderSlippageValues() { const slippageList = SLIPPAGE_VALUES.map((value, index) => { return ( @@ -127,17 +147,9 @@ function SwapSettingsModal({ } function renderSlippageError() { - const error = currentSlippage === undefined - ? lang('Slippage not specified') - : currentSlippage > MAX_SLIPPAGE_VALUE - ? lang('Slippage too high') - : ''; - - setHasError(!!error); - return (
- {lang(error)} + {lang(slippageError)}
); } @@ -151,38 +163,42 @@ function SwapSettingsModal({ true, ); - if (!rate) return undefined; - return (
{lang('Exchange Rate')} - {`${rate.firstCurrencySymbol} ≈ ${rate.price} ${rate.secondCurrencySymbol}`} + {rate + ? `${rate.firstCurrencySymbol} ≈ ${rate.price} ${rate.secondCurrencySymbol}` + : }
); } function renderNetworkFee() { - let feeOptions: FormatFeeOptions | undefined; - - if (isGaslessSwap && tokenIn) { - feeOptions = { - terms: { native: fromDecimal(realNetworkFeeInDiesel, tokenIn.decimals) }, - token: tokenIn, - nativeToken: tokenIn, - precision: 'approximate', - }; - } - if (!isGaslessSwap && nativeTokenIn) { - feeOptions = { - terms: { native: fromDecimal(realNetworkFee, nativeTokenIn.decimals) }, - token: nativeTokenIn, - nativeToken: nativeTokenIn, - precision: realNetworkFee === networkFee ? 'exact' : 'approximate', - }; + const actualFee = showFullNetworkFee ? explainedFee.fullFee : explainedFee.realFee; + let feeElement = actualFee && tokenIn && ( + + ); + + if (feeElement && onNetworkFeeClick) { + feeElement = ( + onNetworkFeeClick()} + > + {feeElement} + + + ); } return ( @@ -191,7 +207,7 @@ function SwapSettingsModal({ {lang('Blockchain Fee')} - {feeOptions && formatFee(feeOptions)} + {feeElement || }
); @@ -218,15 +234,7 @@ function SwapSettingsModal({ } return ( - -
- {lang('Swap Details')} -
+ <> {canEditSlippage && (
{renderSlippageValues()} @@ -234,9 +242,10 @@ function SwapSettingsModal({ labelText={renderSlippageLabel()} labelClassName={styles.slippageLabel} value={currentSlippage?.toString()} - hasError={hasError} + hasError={Boolean(slippageError)} decimals={2} suffix={isSlippageFocused ? '' : '%'} + size="normal" onChange={handleInputChange} onFocus={markSlippageFocused} onBlur={unmarkSlippageFocused} @@ -248,25 +257,29 @@ function SwapSettingsModal({ {renderRate()} {renderNetworkFee()} + {explainedFee.shouldShowOurFee && ( +
+ + {lang('Aggregator Fee')} + + {renderText(lang('$swap_aggregator_fee_tooltip', { percent: `${ourFeePercent}%` }))} +
+ )} + tooltipClassName={styles.advancedTooltipContainer} + iconClassName={styles.advancedTooltip} + /> + + + {ourFee !== undefined + ? formatCurrency(ourFee, tokenIn?.symbol ?? '', undefined, true) + : } + +
+ )} {swapType === SwapType.OnChain && ( <> -
- - {lang('Aggregator Fee')} - - {renderText(lang('$swap_aggregator_fee_tooltip', { percent: `${ourFeePercent}%` }))} -
- )} - tooltipClassName={styles.advancedTooltipContainer} - iconClassName={styles.advancedTooltip} - /> - - - {formatCurrency(ourFee, tokenIn?.symbol ?? '', undefined, true)} - -
{lang('Price Impact')} @@ -283,11 +296,8 @@ function SwapSettingsModal({ )} /> - {priceImpact}% + + {priceImpact !== undefined ? `${priceImpact}%` : }
@@ -304,12 +314,11 @@ function SwapSettingsModal({ iconClassName={styles.advancedTooltip} /> - {tokenOut && ( - { - formatCurrency(Number(amountOutMin), tokenOut.symbol) - } - - )} + + {amountOutMin !== undefined && tokenOut + ? formatCurrency(amountOutMin, tokenOut.symbol) + : } +
)} @@ -324,7 +333,7 @@ function SwapSettingsModal({ {canEditSlippage && ( )}
- + ); } -export default memo( - withGlobal((global): StateProps => { +const SwapSettings = memo( + withGlobal>((global): StateProps => { const { + tokenInSlug, amountIn, amountOut, networkFee, @@ -349,8 +359,13 @@ export default memo( amountOutMin, ourFee, ourFeePercent, + dieselStatus, + dieselFee, } = global.currentSwap; + const nativeToken = tokenInSlug ? findChainConfig(getChainBySlug(tokenInSlug))?.nativeToken : undefined; + const nativeTokenInBalance = nativeToken ? selectCurrentAccountTokenBalance(global, nativeToken.slug) : undefined; + return { amountIn, amountOut, @@ -359,12 +374,30 @@ export default memo( swapType, tokenIn: selectCurrentSwapTokenIn(global), tokenOut: selectCurrentSwapTokenOut(global), - nativeTokenIn: selectCurrentSwapNativeTokenIn(global), slippage, priceImpact, amountOutMin, ourFee, ourFeePercent, + dieselStatus, + dieselFee, + nativeTokenInBalance, }; - })(SwapSettingsModal), + })(SwapSettingsContent), ); + +export default function SwapSettingsModal({ isOpen, onClose, ...restProps }: OwnProps) { + const lang = useLang(); + + return ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + ); +} + +function ValuePlaceholder() { + const lang = useLang(); + return {lang('No Data')}; +} diff --git a/src/components/transfer/Transfer.module.scss b/src/components/transfer/Transfer.module.scss index 2b3ac1e1..64dfb0f3 100644 --- a/src/components/transfer/Transfer.module.scss +++ b/src/components/transfer/Transfer.module.scss @@ -241,8 +241,6 @@ } .currencySymbol { - margin-left: 0.25rem; - color: var(--color-gray-3); } diff --git a/src/components/transfer/TransferConfirm.tsx b/src/components/transfer/TransferConfirm.tsx index c550112c..29498cc1 100644 --- a/src/components/transfer/TransferConfirm.tsx +++ b/src/components/transfer/TransferConfirm.tsx @@ -17,11 +17,8 @@ import renderText from '../../global/helpers/renderText'; import buildClassName from '../../util/buildClassName'; import { vibrate } from '../../util/capacitor'; import { toDecimal } from '../../util/decimals'; -import { formatFee } from '../../util/fee/formatFee'; import { explainApiTransferFee } from '../../util/fee/transferFee'; -import { formatCurrencySimple } from '../../util/formatNumber'; -import { getIsNativeToken, getNativeToken } from '../../util/tokens'; -import { NFT_TRANSFER_AMOUNT } from '../../api/chains/ton/constants'; +import { getChainBySlug } from '../../util/tokens'; import { ANIMATED_STICKERS_PATHS } from '../ui/helpers/animatedAssets'; import useHistoryBack from '../../hooks/useHistoryBack'; @@ -31,6 +28,7 @@ import useLastCallback from '../../hooks/useLastCallback'; import AmountWithFeeTextField from '../ui/AmountWithFeeTextField'; import AnimatedIconWithPreview from '../ui/AnimatedIconWithPreview'; import Button from '../ui/Button'; +import Fee from '../ui/Fee'; import IconWithTooltip from '../ui/IconWithTooltip'; import InteractiveTextField from '../ui/InteractiveTextField'; import ModalHeader from '../ui/ModalHeader'; @@ -54,9 +52,9 @@ interface StateProps { function TransferConfirm({ currentTransfer: { + tokenSlug, amount, toAddress, - chain, resolvedAddress, fee, realFee, @@ -83,22 +81,25 @@ function TransferConfirm({ const lang = useLang(); + const isNftTransfer = Boolean(nfts?.length); + if (isNftTransfer) { + tokenSlug = TONCOIN.slug; + } + + const chain = getChainBySlug(tokenSlug); const savedAddressName = useMemo(() => { - return toAddress && chain && savedAddresses?.find((item) => { + return toAddress && savedAddresses?.find((item) => { return item.address === toAddress && item.chain === chain; })?.name; }, [toAddress, chain, savedAddresses]); const addressName = savedAddressName || toAddressName; - const isNftTransfer = Boolean(nfts?.length); const isBurning = resolvedAddress === BURN_ADDRESS; const isNotcoinBurning = resolvedAddress === NOTCOIN_EXCHANGERS[0]; - const nativeToken = chain ? getNativeToken(chain) : undefined; const explainedFee = explainApiTransferFee({ fee, realFee, diesel, - chain, - isNativeToken: getIsNativeToken(token?.slug), + tokenSlug, }); useHistoryBack({ @@ -120,40 +121,31 @@ function TransferConfirm({ } function renderFee() { - if (isNftTransfer) { - return renderFeeForNft(); - } - - if (!explainedFee.realFee || !token || !nativeToken) { + if (!explainedFee.realFee || !token) { return undefined; } - return ( - ); - } - - function renderFeeForNft() { - const totalFee = (NFT_TRANSFER_AMOUNT + (fee ?? 0n)) * BigInt(Math.ceil(nfts!.length / NFT_BATCH_SIZE)); - return ( + return isNftTransfer ? ( <>
{lang('Fee')}
-
- ≈ {formatCurrencySimple(totalFee, '')} - {TONCOIN.symbol} -
+
{feeText}
+ ) : ( + ); } diff --git a/src/components/transfer/TransferInitial.tsx b/src/components/transfer/TransferInitial.tsx index ffa1816d..031a5966 100644 --- a/src/components/transfer/TransferInitial.tsx +++ b/src/components/transfer/TransferInitial.tsx @@ -7,6 +7,7 @@ import { getActions, withGlobal } from '../../global'; import type { ApiFetchEstimateDieselResult } from '../../api/chains/ton/types'; import type { ApiBaseCurrency, ApiChain, ApiNft } from '../../api/types'; import type { Account, SavedAddress, UserToken } from '../../global/types'; +import type { ExplainedTransferFee } from '../../util/fee/transferFee'; import type { FeePrecision, FeeTerms } from '../../util/fee/types'; import type { DropdownItem } from '../ui/Dropdown'; import { TransferState } from '../../global/types'; @@ -38,9 +39,7 @@ import { debounce } from '../../util/schedulers'; import { shortenAddress } from '../../util/shortenAddress'; import stopEvent from '../../util/stopEvent'; import getChainNetworkIcon from '../../util/swap/getChainNetworkIcon'; -import { getIsNativeToken } from '../../util/tokens'; import { IS_ANDROID, IS_FIREFOX, IS_TOUCH_ENV } from '../../util/windowEnvironment'; -import { NFT_TRANSFER_AMOUNT } from '../../api/chains/ton/constants'; import { ASSET_LOGO_PATHS } from '../ui/helpers/assetLogos'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; @@ -51,6 +50,7 @@ import useLastCallback from '../../hooks/useLastCallback'; import useQrScannerSupport from '../../hooks/useQrScannerSupport'; import useShowTransition from '../../hooks/useShowTransition'; +import FeeDetailsModal from '../common/FeeDetailsModal'; import DeleteSavedAddressModal from '../main/modals/DeleteSavedAddressModal'; import Button from '../ui/Button'; import Dropdown from '../ui/Dropdown'; @@ -162,6 +162,13 @@ function TransferInitial({ fetchTransferDieselState, } = getActions(); + const isNftTransfer = Boolean(nfts?.length); + if (isNftTransfer) { + // Token and amount can't be selected in the NFT transfer form, so they are overwritten once for convenience + tokenSlug = TONCOIN.slug; + amount = undefined; + } + // eslint-disable-next-line no-null/no-null const toAddressRef = useRef(null); // eslint-disable-next-line no-null/no-null @@ -184,7 +191,6 @@ function TransferInitial({ const [isAddressBookOpen, openAddressBook, closeAddressBook] = useFlag(); const [savedAddressForDeletion, setSavedAddressForDeletion] = useState(); const [savedChainForDeletion, setSavedChainForDeletion] = useState(); - const isNftTransfer = Boolean(nfts?.length); const toAddressShort = toAddress.length > MIN_ADDRESS_LENGTH_TO_SHORTEN ? shortenAddress(toAddress, SHORT_ADDRESS_SHIFT) || '' : toAddress; @@ -202,8 +208,8 @@ function TransferInitial({ const isToncoin = tokenSlug === TONCOIN.slug; - const shouldDisableClearButton = !toAddress && !amount && !(comment || binPayload) && !shouldEncrypt - && !(nfts?.length && isStatic); + const shouldDisableClearButton = !toAddress && !(comment || binPayload) && !shouldEncrypt + && !(isNftTransfer ? isStatic : amount !== undefined); const isQrScannerSupported = useQrScannerSupport(); @@ -215,19 +221,18 @@ function TransferInitial({ const withAddressClearButton = !!toAddress.length; const shortBaseSymbol = getShortCurrencySymbol(baseCurrency); - const isNativeToken = getIsNativeToken(tokenSlug); const explainedFee = useMemo( () => explainApiTransferFee({ - fee, realFee, diesel, chain, isNativeToken, + fee, realFee, diesel, tokenSlug, }), - [fee, realFee, diesel, chain, isNativeToken], + [fee, realFee, diesel, tokenSlug], ); // Note: this constant has 3 distinct meaningful values const isEnoughBalance = isBalanceSufficientForTransfer({ tokenBalance: balance, nativeTokenBalance: nativeToken.amount, - transferAmount: isNftTransfer ? NFT_TRANSFER_AMOUNT : amount, + transferAmount: isNftTransfer ? 0n : amount, fullFee: explainedFee.fullFee?.terms, canTransferFullBalance: explainedFee.canTransferFullBalance, }); @@ -238,7 +243,7 @@ function TransferInitial({ const maxAmount = getMaxTransferAmount({ tokenBalance: balance, - isNativeToken, + tokenSlug, fullFee: explainedFee.fullFee?.terms, canTransferFullBalance: explainedFee.canTransferFullBalance, }); @@ -301,13 +306,14 @@ function TransferInitial({ fetchNftFee({ toAddress, comment, - nftAddresses: nfts?.map(({ address }) => address) || [], + nfts: nfts ?? [], }); } else { fetchTransferFee({ tokenSlug, toAddress, comment, + shouldEncrypt, binPayload, stateInit, }); @@ -320,7 +326,10 @@ function TransferInitial({ isDisabledDebounce.current = false; runFunction(); } - }, [isAmountMissing, binPayload, comment, isAddressValid, isNftTransfer, nfts, stateInit, toAddress, tokenSlug]); + }, [ + isAmountMissing, binPayload, comment, shouldEncrypt, isAddressValid, isNftTransfer, nfts, stateInit, toAddress, + tokenSlug, + ]); const handleTokenChange = useLastCallback((slug: string) => { changeTransferToken({ tokenSlug: slug }); @@ -497,8 +506,11 @@ function TransferInitial({ const hasToAddressError = toAddress.length > 0 && !isAddressValid; const isAmountGreaterThanBalance = !isNftTransfer && balance !== undefined && amount !== undefined && amount > balance; - const hasAmountError = !isNftTransfer && ((amount ?? 0) < 0 || isEnoughBalance === false); const isInsufficientFee = isEnoughBalance === false && !isAmountGreaterThanBalance; + const hasAmountError = !isNftTransfer && amount !== undefined && ( + (maxAmount !== undefined && amount > maxAmount) + || isInsufficientFee // Ideally, the insufficient fee error message should be displayed somewhere else + ); const isCommentRequired = Boolean(toAddress) && isMemoRequired; const hasCommentError = isCommentRequired && !comment; @@ -528,7 +540,7 @@ function TransferInitial({ comment, binPayload, shouldEncrypt, - nftAddresses: isNftTransfer ? nfts!.map(({ address }) => address) : undefined, + nfts, withDiesel: explainedFee.isGasless, isGaslessWithStars: diesel?.status === 'stars-fee', stateInit, @@ -539,6 +551,8 @@ function TransferInitial({ setTransferShouldEncrypt({ shouldEncrypt: option === 'encrypted' }); }); + const [isFeeModalOpen, openFeeModal, closeFeeModal] = useFeeModal(explainedFee); + const renderedSavedAddresses = useMemo(() => { if (!savedAddresses || savedAddresses.length === 0) { return undefined; @@ -689,7 +703,7 @@ function TransferInitial({ } function renderBalance() { - if (!symbol || nfts?.length) { + if (!symbol || isNftTransfer) { return undefined; } @@ -815,9 +829,7 @@ function TransferInitial({ let terms: FeeTerms | undefined; let precision: FeePrecision = 'exact'; - if (isNftTransfer) { - // NFT fee estimation is not supported yet. It will be added later. - } else if (amount) { + if (!isAmountMissing) { const actualFee = isInsufficientFee ? explainedFee.fullFee : explainedFee.realFee; if (actualFee) { ({ terms, precision } = actualFee); @@ -829,8 +841,8 @@ function TransferInitial({ isStatic={isStatic} terms={terms} token={transferToken} - nativeToken={nativeToken} precision={precision} + onDetailsClick={openFeeModal} /> ); } @@ -920,6 +932,16 @@ function TransferInitial({ chain={savedChainForDeletion} onClose={closeDeleteSavedAddressModal} /> + ); } @@ -1087,3 +1109,10 @@ function getChainFromAddress(address: string): ApiChain { return 'ton'; } + +function useFeeModal(explainedFee: ExplainedTransferFee) { + const isAvailable = explainedFee.realFee?.precision !== 'exact'; + const [isOpen, open, close] = useFlag(false); + const openIfAvailable = isAvailable ? open : undefined; + return [isOpen, openIfAvailable, close] as const; +} diff --git a/src/components/transfer/TransferModal.tsx b/src/components/transfer/TransferModal.tsx index 97c61110..c255377f 100644 --- a/src/components/transfer/TransferModal.tsx +++ b/src/components/transfer/TransferModal.tsx @@ -18,7 +18,7 @@ import buildClassName from '../../util/buildClassName'; import captureKeyboardListeners from '../../util/captureKeyboardListeners'; import { toDecimal } from '../../util/decimals'; import { formatCurrency } from '../../util/formatNumber'; -import resolveModalTransitionName from '../../util/resolveModalTransitionName'; +import resolveSlideTransitionName from '../../util/resolveSlideTransitionName'; import { shortenAddress } from '../../util/shortenAddress'; import { useDeviceScreen } from '../../hooks/useDeviceScreen'; @@ -232,7 +232,7 @@ function TransferModal({ onCloseAnimationEnd={handleModalClose} > { setIsPasswordsNotEqual(false); if (firstPassword === '' || !isActive || isPasswordFocused) { diff --git a/src/components/ui/Fee.module.scss b/src/components/ui/Fee.module.scss new file mode 100644 index 00000000..c7eeb605 --- /dev/null +++ b/src/components/ui/Fee.module.scss @@ -0,0 +1,11 @@ +.term { + line-height: 1.25em; // Setting an absolute line-height prevents the icon from shifting 1px vertically depending on the surrounding content + white-space: nowrap; +} + +.tokenIcon { + margin: 0 -0.1875em; + + font-size: 106.7%; + vertical-align: -0.1875em; +} diff --git a/src/components/ui/Fee.tsx b/src/components/ui/Fee.tsx new file mode 100644 index 00000000..304d1a33 --- /dev/null +++ b/src/components/ui/Fee.tsx @@ -0,0 +1,124 @@ +import type { TeactNode } from '../../lib/teact/teact'; +import React, { memo } from '../../lib/teact/teact'; + +import type { ApiToken } from '../../api/types'; +import type { FeePrecision, FeeTerms, FeeValue } from '../../util/fee/types'; + +import { STARS_SYMBOL, TONCOIN, TRX } from '../../config'; +import buildClassName from '../../util/buildClassName'; +import { findChainConfig } from '../../util/chain'; +import { toDecimal } from '../../util/decimals'; +import { formatCurrency } from '../../util/formatNumber'; +import { getChainBySlug } from '../../util/tokens'; + +import styles from './Fee.module.scss'; + +const TERM_SEPARATOR = ' + '; +const PRECISION_PREFIX: Record = { + exact: '', + approximate: '~\u202F', + lessThan: '<\u202F', +}; +const STARS_TOKEN: FeeToken = { + slug: '__stars__', + symbol: STARS_SYMBOL, + decimals: 0, +}; +const UNKNOWN_TOKEN: FeeToken = { + slug: '__unknown__', + symbol: '', + decimals: 0, +}; +const TOKEN_ICONS: Record = { + [TONCOIN.slug]: 'icon-chain-ton', + [TRX.slug]: 'icon-chain-tron', +}; + +export type FeeToken = Pick; + +type FeeListTerm = { + tokenType: keyof FeeTerms; + amount: FeeValue; +}; + +export type OwnProps = { + terms: FeeTerms; + /** The token acting as the transferred token in the `terms` object */ + token: FeeToken; + /** Affects the sign indicating the fee precision and standing before the terms. */ + precision: FeePrecision; + shouldPreferIcons?: boolean; + termClassName?: string; + symbolClassName?: string; +}; + +/** + * Formats a complex fee (containing multiple terms) into a human-readable span + */ +function Fee({ + terms, + token, + precision, + shouldPreferIcons, + termClassName, + symbolClassName, +}: OwnProps) { + const nativeToken = findChainConfig(getChainBySlug(token.slug))?.nativeToken ?? UNKNOWN_TOKEN; + const content: TeactNode[] = [PRECISION_PREFIX[precision]]; + + convertTermsObjectToList(terms).forEach(({ tokenType, amount }, index) => { + if (index > 0) { + content.push(TERM_SEPARATOR); + } + + const currentToken = tokenType === 'stars' ? STARS_TOKEN : tokenType === 'native' ? nativeToken : token; + const icon = shouldPreferIcons ? TOKEN_ICONS[currentToken.slug] : undefined; + + if (typeof amount === 'bigint') { + amount = toDecimal(amount, currentToken.decimals); + } + + let symbolNode = icon + ? + : currentToken.symbol; + if (symbolClassName) { + symbolNode = {symbolNode}; + } + + content.push( + + {formatCurrency(amount, '', undefined, true)} + {symbolNode} + , + ); + }); + + return content; +} + +export default memo(Fee); + +function convertTermsObjectToList(terms: FeeTerms) { + const termList: FeeListTerm[] = []; + let firstDefinedTerm: FeeListTerm | undefined; + + for (const [tokenType, amount] of Object.entries(terms) as [keyof FeeTerms, bigint | undefined][]) { + if (amount === undefined) { + continue; + } + + const term = { tokenType, amount }; + firstDefinedTerm ||= term; + + if (Number(amount)) { + termList.push(term); + } + } + + // Keeping at least 1 term for better UX + if (termList.length === 0) { + termList.push(firstDefinedTerm ?? { tokenType: 'native', amount: 0 }); + } + + return termList; +} diff --git a/src/components/ui/FeeLine.module.scss b/src/components/ui/FeeLine.module.scss index 90965078..b0aac281 100644 --- a/src/components/ui/FeeLine.module.scss +++ b/src/components/ui/FeeLine.module.scss @@ -3,7 +3,6 @@ font-size: 0.8125rem; font-weight: 600; - line-height: 1; color: var(--color-gray-3); text-align: center; white-space: nowrap; @@ -12,3 +11,32 @@ color: var(--color-gray-3-desktop); } } + +.details { + cursor: var(--custom-cursor, pointer); + + color: var(--color-accent); + + opacity: 0.85; + + transition: color 150ms; + + &:active { + color: var(--color-accent-button-background-hover); + } + + @media (hover: hover) { + &:hover, + &:focus-visible { + color: var(--color-accent-button-background-hover); + } + } + + :global(html.animation-level-0) & { + transition: none !important; + } +} + +.detailsIcon { + vertical-align: middle; +} diff --git a/src/components/ui/FeeLine.tsx b/src/components/ui/FeeLine.tsx index 0824bc4d..f0313e03 100644 --- a/src/components/ui/FeeLine.tsx +++ b/src/components/ui/FeeLine.tsx @@ -1,25 +1,32 @@ import type { TeactNode } from '../../lib/teact/teact'; import React, { memo } from '../../lib/teact/teact'; -import type { FormatFeeOptions } from '../../util/fee/formatFee'; +import type { OwnProps as FeeProps } from './Fee'; import buildClassName from '../../util/buildClassName'; -import { formatFee } from '../../util/fee/formatFee'; import useLang from '../../hooks/useLang'; +import Fee from './Fee'; import Transition from './Transition'; import styles from './FeeLine.module.scss'; +const TERMS_TRANSITION_KEY = 0b1; +const DETAILS_TRANSITION_KEY = 0b10; + /** - * The component will be rendered empty unless all the required options from `FormatFeeOptions` are provided. + * The component will be rendered empty unless all the required options from `FeeProps` are provided. * After that it will fade in. */ -type OwnProps = Partial & Pick & { +type OwnProps = Partial> & Pick & { className?: string; /** Whether the component is rendered on a landscape layout (with a lighter background) */ isStatic?: boolean; + /** If true, the "Details" button will be shown even when no fee can be displayed. */ + keepDetailsButtonWithoutFee?: boolean; + /** If undefined, the details button is not shown */ + onDetailsClick?(): void; }; function FeeLine({ @@ -27,26 +34,43 @@ function FeeLine({ isStatic, terms, token, - nativeToken, - ...formatOptions + precision, + keepDetailsButtonWithoutFee, + onDetailsClick, }: OwnProps) { const lang = useLang(); + const content: TeactNode[] = []; let transitionKey = 0; - let content: TeactNode = ' '; - - if (terms && token && nativeToken) { - const feeText = formatFee({ - terms, - token, - nativeToken, - ...formatOptions, - }); - const langKey = formatOptions.precision === 'exact' - ? '$fee_value_with_colon' - : '$fee_value'; - - transitionKey = 1; - content = lang(langKey, { fee: feeText }); + + if (terms && token) { + const langKey = precision === 'exact' ? '$fee_value_with_colon' : '$fee_value'; + + content.push( + lang(langKey, { + fee: , + }), + ); + + if (onDetailsClick) { + content.push(' · '); + } + + transitionKey += TERMS_TRANSITION_KEY; + } + + if (onDetailsClick && (keepDetailsButtonWithoutFee || content.length)) { + content.push( + onDetailsClick()} + > + {lang('Details')} + + , + ); + transitionKey += DETAILS_TRANSITION_KEY; } return ( diff --git a/src/components/ui/Image.tsx b/src/components/ui/Image.tsx index 1652ba95..cdfbf8a9 100644 --- a/src/components/ui/Image.tsx +++ b/src/components/ui/Image.tsx @@ -40,6 +40,7 @@ function ImageComponent({ alt={alt} loading={loading} className={imageClassName} + draggable={false} referrerPolicy="same-origin" onLoad={!isLoaded ? handleLoad : undefined} /> diff --git a/src/components/ui/Input.module.scss b/src/components/ui/Input.module.scss index 8618e0cb..465b5e30 100644 --- a/src/components/ui/Input.module.scss +++ b/src/components/ui/Input.module.scss @@ -118,13 +118,11 @@ overflow: hidden; flex: 1 1 auto; - height: 3.75rem; + height: 2.75rem; margin-right: 0.5rem; padding: 0.375rem 0 0.375rem 0.625rem; - font-size: calc(3rem * var(--base-font-size, 1)); - font-weight: 600; - line-height: 3rem; + line-height: 2rem; white-space: nowrap; transition: color 150ms; @@ -143,7 +141,6 @@ &.isEmpty::before { content: "0"; - font-size: 3rem; color: var(--color-gray-4); transition: color 150ms; @@ -171,6 +168,13 @@ } } + &_large { + height: 3.75rem; + + font-size: calc(3rem * var(--base-font-size, 1)); + line-height: 3rem; + } + :global(html.animation-level-0) & { transition: none !important; } diff --git a/src/components/ui/PasswordForm.tsx b/src/components/ui/PasswordForm.tsx index 18887e2b..1c366519 100644 --- a/src/components/ui/PasswordForm.tsx +++ b/src/components/ui/PasswordForm.tsx @@ -18,6 +18,7 @@ import { ANIMATED_STICKERS_PATHS } from './helpers/animatedAssets'; import { useDeviceScreen } from '../../hooks/useDeviceScreen'; import useFlag from '../../hooks/useFlag'; import useFocusAfterAnimation from '../../hooks/useFocusAfterAnimation'; +import useHideBottomBar from '../../hooks/useHideBottomBar'; import useLang from '../../hooks/useLang'; import useLastCallback from '../../hooks/useLastCallback'; @@ -111,6 +112,8 @@ function PasswordForm({ } }, [isActive]); + useHideBottomBar(Boolean(isActive)); + const handleBiometrics = useLastCallback(async () => { try { const biometricPassword = await authApi.getPassword(authConfig!); diff --git a/src/components/ui/PinPad.tsx b/src/components/ui/PinPad.tsx index 29babcf5..3157a086 100644 --- a/src/components/ui/PinPad.tsx +++ b/src/components/ui/PinPad.tsx @@ -47,10 +47,10 @@ function PinPad({ value, resetStateDelayMs = RESET_STATE_DELAY_MS, length = DEFAULT_PIN_LENGTH, - onBiometricsClick, isPinAccepted, className, isMinified, + onBiometricsClick, onChange, onClearError, onSubmit, diff --git a/src/components/ui/RichNumberField.tsx b/src/components/ui/RichNumberField.tsx index 7188c417..7815ef1c 100644 --- a/src/components/ui/RichNumberField.tsx +++ b/src/components/ui/RichNumberField.tsx @@ -95,6 +95,7 @@ function RichNumberField({ const inputFullClass = buildClassName( styles.input, styles.input_rich, + styles.input_large, styles.disabled, error && styles.error, valueClassName, diff --git a/src/components/ui/RichNumberInput.tsx b/src/components/ui/RichNumberInput.tsx index 07e4339c..6fd484c3 100644 --- a/src/components/ui/RichNumberInput.tsx +++ b/src/components/ui/RichNumberInput.tsx @@ -36,6 +36,7 @@ type OwnProps = { decimals?: number; disabled?: boolean; isStatic?: boolean; + size?: 'large' | 'normal'; }; const MIN_LENGTH_FOR_SHRINK = 5; @@ -61,6 +62,7 @@ function RichNumberInput({ decimals = FRACTION_DIGITS, disabled = false, isStatic = false, + size = 'large', }: OwnProps) { // eslint-disable-next-line no-null/no-null const inputRef = useRef(null); @@ -148,6 +150,7 @@ function RichNumberInput({ const inputFullClass = buildClassName( styles.input, styles.input_rich, + size === 'large' && styles.input_large, !value && styles.isEmpty, valueClassName, disabled && styles.disabled, diff --git a/src/components/ui/Tab.module.scss b/src/components/ui/Tab.module.scss index 7039a5e4..a9b8a528 100644 --- a/src/components/ui/Tab.module.scss +++ b/src/components/ui/Tab.module.scss @@ -84,7 +84,7 @@ box-sizing: content-box; width: calc(100% - 1.5rem); - height: 0.125rem; + height: var(--tab-platform-height, 0.125rem); opacity: 0; background-color: var(--color-accent); diff --git a/src/components/ui/TabList.module.scss b/src/components/ui/TabList.module.scss index 52d72898..aff24356 100644 --- a/src/components/ui/TabList.module.scss +++ b/src/components/ui/TabList.module.scss @@ -15,7 +15,7 @@ font-size: 0.9375rem; font-weight: 500; - background-color: var(--color-background-first); + transition: background-color 150ms; scrollbar-color: rgba(0, 0, 0, 0); @@ -30,6 +30,10 @@ // `box-shadow` prevents repaint on macOS when hovering out of scrollable container box-shadow: 0 0 1px rgba(255, 255, 255, 0.01); } + + :global(html.animation-level-0) & { + transition: none !important; + } } .withBorder { diff --git a/src/components/vesting/VestingPasswordModal.tsx b/src/components/vesting/VestingPasswordModal.tsx index d87d278c..26b8997d 100644 --- a/src/components/vesting/VestingPasswordModal.tsx +++ b/src/components/vesting/VestingPasswordModal.tsx @@ -22,7 +22,7 @@ import { import buildClassName from '../../util/buildClassName'; import { toBig } from '../../util/decimals'; import { formatCurrency } from '../../util/formatNumber'; -import resolveModalTransitionName from '../../util/resolveModalTransitionName'; +import resolveSlideTransitionName from '../../util/resolveSlideTransitionName'; import { shortenAddress } from '../../util/shortenAddress'; import { calcVestingAmountByStatus } from '../main/helpers/calcVestingAmountByStatus'; @@ -178,7 +178,7 @@ function VestingPasswordModal({ onClose={cancelClaimingVesting} > { addActionHandler('connectHardwareWallet', async (global, actions, params) => { const accounts = selectAccounts(global) ?? {}; const isFirstAccount = !Object.values(accounts).length; + const { areSettingsOpen } = global; - if (IS_DELEGATING_BOTTOM_SHEET && !isFirstAccount) return; + if (IS_DELEGATING_BOTTOM_SHEET && !isFirstAccount && !areSettingsOpen) return; global = updateHardware(global, { hardwareState: HardwareConnectState.Connecting, diff --git a/src/global/actions/api/dapps.ts b/src/global/actions/api/dapps.ts index e51f638c..8d5b32f9 100644 --- a/src/global/actions/api/dapps.ts +++ b/src/global/actions/api/dapps.ts @@ -461,19 +461,12 @@ addActionHandler('apiUpdateDappCloseLoading', async (global) => { }); addActionHandler('loadExploreSites', async (global, _, { isLandscape }) => { - const { settings: { langCode } } = global; - const sites = await callApi('loadExploreSites', { - isLandscape, - langCode, - }); + const exploreData = await callApi('loadExploreSites', { isLandscape }); global = getGlobal(); - if (areDeepEqual(sites, global.exploreSites)) { + if (areDeepEqual(exploreData, global.exploreData)) { return; } - global = { - ...global, - exploreSites: sites, - }; + global = { ...global, exploreData }; setGlobal(global); }); diff --git a/src/global/actions/api/nfts.ts b/src/global/actions/api/nfts.ts index c26a11ff..f67614bd 100644 --- a/src/global/actions/api/nfts.ts +++ b/src/global/actions/api/nfts.ts @@ -4,7 +4,6 @@ import { import { findDifference } from '../../../util/iteratees'; import { IS_DELEGATED_BOTTOM_SHEET, IS_DELEGATING_BOTTOM_SHEET } from '../../../util/windowEnvironment'; import { callApi } from '../../../api'; -import { NFT_TRANSFER_AMOUNT } from '../../../api/chains/ton/constants'; import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { updateAccountSettings, updateCurrentAccountState } from '../../reducers'; import { selectCurrentAccountState } from '../../selectors'; @@ -24,9 +23,9 @@ addActionHandler('burnNfts', (global, actions, { nfts }) => { setTimeout(() => { actions.submitTransferInitial({ tokenSlug: TONCOIN.slug, - amount: NFT_TRANSFER_AMOUNT, + amount: 0n, toAddress: isNotcoinVouchers ? NOTCOIN_EXCHANGERS[0] : BURN_ADDRESS, - nftAddresses: nfts.map(({ address }) => address), + nfts, }); }, NBS_INIT_TIMEOUT); }); diff --git a/src/global/actions/api/staking.ts b/src/global/actions/api/staking.ts index 9486fc32..01a6e4b0 100644 --- a/src/global/actions/api/staking.ts +++ b/src/global/actions/api/staking.ts @@ -4,6 +4,7 @@ import { StakingState } from '../../types'; import { IS_CAPACITOR } from '../../../config'; import { vibrateOnError, vibrateOnSuccess } from '../../../util/capacitor'; +import { getTonStakingFees } from '../../../util/fee/getTonOperationFees'; import { logDebugError } from '../../../util/logs'; import { callActionInMain } from '../../../util/multitab'; import { pause } from '../../../util/schedulers'; @@ -155,11 +156,7 @@ addActionHandler('submitStakingInitial', async (global, actions, payload) => { addActionHandler('submitStakingPassword', async (global, actions, payload) => { const { password, isUnstaking } = payload; - const { - fee, - amount, - tokenAmount, - } = global.currentStaking; + const { amount, tokenAmount } = global.currentStaking; const { currentAccountId } = global; if (!(await callApi('verifyPassword', password))) { @@ -198,7 +195,7 @@ addActionHandler('submitStakingPassword', async (global, actions, payload) => { password, unstakeAmount, state, - fee, + getTonStakingFees(state.type).unstake.real, ); const isLongUnstakeRequested = Boolean(state.type === 'nominators' || ( @@ -232,7 +229,7 @@ addActionHandler('submitStakingPassword', async (global, actions, payload) => { password, amount!, state, - fee, + getTonStakingFees(state.type).stake.real, ); global = getGlobal(); @@ -259,11 +256,7 @@ addActionHandler('submitStakingPassword', async (global, actions, payload) => { addActionHandler('submitStakingHardware', async (global, actions, payload) => { const { isUnstaking } = payload || {}; - const { - fee, - amount, - tokenAmount, - } = global.currentStaking; + const { amount, tokenAmount } = global.currentStaking; const { currentAccountId } = global; const state = selectAccountStakingState(global, currentAccountId!)!; @@ -289,7 +282,12 @@ addActionHandler('submitStakingHardware', async (global, actions, payload) => { const stakingBalance = state.balance; const unstakeAmount = state.type === 'nominators' ? stakingBalance : tokenAmount!; - result = await ledgerApi.submitLedgerUnstake(accountId, state, unstakeAmount); + result = await ledgerApi.submitLedgerUnstake( + accountId, + state, + unstakeAmount, + getTonStakingFees(state.type).unstake.real, + ); const isLongUnstakeRequested = Boolean(state.type === 'nominators' || ( state.type === 'liquid' @@ -305,7 +303,7 @@ addActionHandler('submitStakingHardware', async (global, actions, payload) => { accountId, amount!, state, - fee, + getTonStakingFees(state.type).stake.real, ); } } catch (err: any) { @@ -476,7 +474,13 @@ addActionHandler('submitStakingClaim', async (global, actions, { password }) => const stakingState = selectAccountStakingState(global, accountId)! as ApiJettonStakingState; - const result = await callApi('submitStakingClaim', accountId, password, stakingState); + const result = await callApi( + 'submitStakingClaim', + accountId, + password, + stakingState, + getTonStakingFees(stakingState.type).claim?.real, + ); global = getGlobal(); global = updateCurrentStaking(global, { isLoading: false }); @@ -519,7 +523,11 @@ addActionHandler('submitStakingClaimHardware', async (global, actions) => { let result: string | { error: ApiTransactionError } | undefined; try { - result = await ledgerApi.submitLedgerStakingClaim(accountId, stakingState); + result = await ledgerApi.submitLedgerStakingClaim( + accountId, + stakingState, + getTonStakingFees(stakingState.type).claim?.real, + ); } catch (err: any) { if (err instanceof ApiHardwareBlindSigningNotEnabled) { setGlobal(updateCurrentStaking(getGlobal(), { diff --git a/src/global/actions/api/swap.ts b/src/global/actions/api/swap.ts index aa545aa0..56ca79fc 100644 --- a/src/global/actions/api/swap.ts +++ b/src/global/actions/api/swap.ts @@ -1,4 +1,4 @@ -import type { ApiSubmitTransferWithDieselResult } from '../../../api/chains/ton/types'; +import type { ApiCheckTransactionDraftResult, ApiSubmitTransferWithDieselResult } from '../../../api/chains/ton/types'; import type { ApiSubmitTransferOptions, ApiSubmitTransferResult } from '../../../api/methods/types'; import type { ApiChain, @@ -18,7 +18,6 @@ import { ApiCommonError } from '../../../api/types'; import { ActiveTab, SwapErrorType, - SwapFeeSource, SwapInputSource, SwapState, SwapType, @@ -30,7 +29,6 @@ import { DEFAULT_SWAP_SECOND_TOKEN_SLUG, IS_CAPACITOR, TONCOIN, - TRX, TRX_SWAP_COUNT_FEE_ADDRESS, } from '../../../config'; import { Big } from '../../../lib/big.js'; @@ -43,7 +41,7 @@ import { buildCollectionByKey, pick } from '../../../util/iteratees'; import { callActionInMain, callActionInNative } from '../../../util/multitab'; import { pause } from '../../../util/schedulers'; import { buildSwapId } from '../../../util/swap/buildSwapId'; -import { getIsTonToken } from '../../../util/tokens'; +import { getIsTonToken, getNativeToken } from '../../../util/tokens'; import { IS_DELEGATED_BOTTOM_SHEET, IS_DELEGATING_BOTTOM_SHEET } from '../../../util/windowEnvironment'; import { callApi } from '../../../api'; import { addActionHandler, getGlobal, setGlobal } from '../..'; @@ -84,6 +82,17 @@ const SERVER_ERRORS_MAP = { 'Too small amount': SwapErrorType.TooSmallAmount, }; +const feeResetParams = { + networkFee: undefined, + realNetworkFee: undefined, + swapFee: undefined, + swapFeePercent: undefined, + ourFee: undefined, + ourFeePercent: undefined, + dieselStatus: undefined, + dieselFee: undefined, +}; + function buildSwapBuildRequest(global: GlobalState): ApiSwapBuildRequest { const { currentDexLabel, @@ -95,6 +104,7 @@ function buildSwapBuildRequest(global: GlobalState): ApiSwapBuildRequest { swapFee, ourFee, dieselFee, + realNetworkFee, } = global.currentSwap; const tokenIn = selectCurrentSwapTokenIn(global)!; @@ -119,7 +129,7 @@ function buildSwapBuildRequest(global: GlobalState): ApiSwapBuildRequest { fromAddress: account?.addressByChain[tokenIn.chain as ApiChain] || account?.addressByChain.ton!, shouldTryDiesel, dexLabel: currentDexLabel!, - networkFee: networkFee!, + networkFee: realNetworkFee ?? networkFee!, swapFee: swapFee!, ourFee: ourFee!, dieselFee, @@ -163,13 +173,8 @@ function processNativeMaxSwap(global: GlobalState) { && global.currentSwap.inputSource === SwapInputSource.In && global.currentSwap.isMaxAmount ) { - if (global.currentSwap.tokenInSlug === TONCOIN.slug) { - const nativeBalance = selectCurrentToncoinBalance(global); - fromAmount = toDecimal(nativeBalance); - } else { - const tokenBalance = selectCurrentAccountTokenBalance(global, tokenIn.slug); - fromAmount = toDecimal(tokenBalance, tokenIn.decimals); - } + const tokenBalance = selectCurrentAccountTokenBalance(global, tokenIn.slug); + fromAmount = toDecimal(tokenBalance, tokenIn.decimals); isFromAmountMax = true; } return { fromAmount, isFromAmountMax }; @@ -246,13 +251,10 @@ addActionHandler('setDefaultSwapParams', (global, actions, payload) => { } global = updateCurrentSwap(global, { + ...feeResetParams, tokenInSlug: requiredTokenInSlug, tokenOutSlug: requiredTokenOutSlug, priceImpact: 0, - transactionFee: '0', - swapFee: '0', - ourFee: undefined, - networkFee: '0', amountOutMin: '0', inputSource: SwapInputSource.In, isDexLabelChanged: undefined, @@ -269,10 +271,10 @@ addActionHandler('cancelSwap', (global, actions, { shouldReset } = {}) => { global = clearCurrentSwap(global); global = updateCurrentSwap(global, { + ...feeResetParams, tokenInSlug, tokenOutSlug, priceImpact: 0, - transactionFee: undefined, amountIn: undefined, amountOutMin: undefined, amountOut: undefined, @@ -280,14 +282,6 @@ addActionHandler('cancelSwap', (global, actions, { shouldReset } = {}) => { isDexLabelChanged: undefined, swapType, pairs, - // Fees - networkFee: '0', - realNetworkFee: '0', - swapFee: undefined, - swapFeePercent: undefined, - ourFee: undefined, - ourFeePercent: undefined, - dieselFee: undefined, }); setGlobal(global); @@ -359,8 +353,9 @@ addActionHandler('submitSwap', async (global, actions, { password }) => { fromAmount: swapBuildRequest.fromAmount, to: swapBuildRequest.to, toAmount: swapBuildRequest.toAmount, - networkFee: global.currentSwap.networkFee!, + networkFee: global.currentSwap.realNetworkFee ?? global.currentSwap.networkFee!, swapFee: global.currentSwap.swapFee!, + ourFee: global.currentSwap.ourFee, txIds: [], }; @@ -651,366 +646,333 @@ addActionHandler('setSlippage', (global, actions, { slippage }) => { addActionHandler('estimateSwap', async (global, actions, payload) => { if (isEstimateSwapBeingExecuted) return; - const { shouldBlock, toncoinBalance, isEnoughToncoin } = payload; + try { + isEstimateSwapBeingExecuted = true; - isEstimateSwapBeingExecuted = true; - const resetParams = { - amountOutMin: '0', - transactionFee: '0', - swapFee: '0', - ourFee: undefined, - networkFee: '0', - realNetworkFee: '0', - priceImpact: 0, - errorType: undefined, - isEstimating: false, - }; + const { shouldBlock, toncoinBalance, isEnoughToncoin } = payload; - global = updateCurrentSwap(global, { - shouldEstimate: false, - }); + const resetParams = { + ...feeResetParams, + amountOutMin: '0', + priceImpact: 0, + errorType: undefined, + shouldEstimate: false, + isEstimating: false, + }; - // Check for empty string - if ((global.currentSwap.amountIn === undefined && global.currentSwap.inputSource === SwapInputSource.In) + // Check for empty string + if ((global.currentSwap.amountIn === undefined && global.currentSwap.inputSource === SwapInputSource.In) || (global.currentSwap.amountOut === undefined && global.currentSwap.inputSource === SwapInputSource.Out)) { - global = updateCurrentSwap(global, { - amountIn: undefined, - amountOut: undefined, - ...resetParams, - }); - setGlobal(global); - isEstimateSwapBeingExecuted = false; - return; - } + global = updateCurrentSwap(global, { + amountIn: undefined, + amountOut: undefined, + ...resetParams, + }); + setGlobal(global); + return; + } - const pairsBySlug = global.currentSwap.pairs?.bySlug ?? {}; - const canSwap = global.currentSwap.tokenOutSlug! in pairsBySlug; + const pairsBySlug = global.currentSwap.pairs?.bySlug ?? {}; + const canSwap = global.currentSwap.tokenOutSlug! in pairsBySlug; - // Check for invalid pair - if (!canSwap) { - const amount = global.currentSwap.inputSource === SwapInputSource.In - ? { amountOut: undefined } - : { amountIn: undefined }; + // Check for invalid pair + if (!canSwap) { + const amount = global.currentSwap.inputSource === SwapInputSource.In + ? { amountOut: undefined } + : { amountIn: undefined }; + + global = updateCurrentSwap(global, { + ...amount, + ...resetParams, + errorType: SwapErrorType.InvalidPair, + }); + setGlobal(global); + return; + } global = updateCurrentSwap(global, { - ...amount, - ...resetParams, - errorType: SwapErrorType.InvalidPair, + shouldEstimate: false, + isEstimating: shouldBlock, }); setGlobal(global); - isEstimateSwapBeingExecuted = false; - return; - } - global = updateCurrentSwap(global, { - isEstimating: shouldBlock, - }); - setGlobal(global); + const tokenIn = global.swapTokenInfo!.bySlug[global.currentSwap.tokenInSlug!]; + const tokenOut = global.swapTokenInfo!.bySlug[global.currentSwap.tokenOutSlug!]; - const tokenIn = global.swapTokenInfo!.bySlug[global.currentSwap.tokenInSlug!]; - const tokenOut = global.swapTokenInfo!.bySlug[global.currentSwap.tokenOutSlug!]; + const from = tokenIn.slug === TONCOIN.slug ? tokenIn.symbol : tokenIn.tokenAddress!; + const to = tokenOut.slug === TONCOIN.slug ? tokenOut.symbol : tokenOut.tokenAddress!; + const { fromAmount, isFromAmountMax } = processNativeMaxSwap(global); + const toAmount = global.currentSwap.amountOut ?? '0'; + const fromAddress = selectCurrentAccount(global)!.addressByChain.ton; - const from = tokenIn.slug === TONCOIN.slug ? tokenIn.symbol : tokenIn.tokenAddress!; - const to = tokenOut.slug === TONCOIN.slug ? tokenOut.symbol : tokenOut.tokenAddress!; - const { fromAmount, isFromAmountMax } = processNativeMaxSwap(global); - const toAmount = global.currentSwap.amountOut ?? '0'; - const fromAddress = selectCurrentAccount(global)!.addressByChain.ton; + const estimateAmount = global.currentSwap.inputSource === SwapInputSource.In ? { fromAmount } : { toAmount }; - const estimateAmount = global.currentSwap.inputSource === SwapInputSource.In ? { fromAmount } : { toAmount }; + const shouldTryDiesel = isEnoughToncoin === false; - const shouldTryDiesel = isEnoughToncoin === false; + const estimate = await callApi('swapEstimate', global.currentAccountId!, { + ...estimateAmount, + from, + to, + slippage: global.currentSwap.slippage, + fromAddress, + shouldTryDiesel, + isFromAmountMax, + toncoinBalance: toDecimal(toncoinBalance ?? 0n, TONCOIN.decimals), + }); - const estimate = await callApi('swapEstimate', global.currentAccountId!, { - ...estimateAmount, - from, - to, - slippage: global.currentSwap.slippage, - fromAddress, - shouldTryDiesel, - isFromAmountMax, - toncoinBalance: toDecimal(toncoinBalance ?? 0n, TONCOIN.decimals), - }); + global = getGlobal(); - isEstimateSwapBeingExecuted = false; - global = getGlobal(); + if (!estimate || 'error' in estimate) { + const errorType = estimate?.error && estimate?.error in SERVER_ERRORS_MAP + ? SERVER_ERRORS_MAP[estimate.error as keyof typeof SERVER_ERRORS_MAP] + : SwapErrorType.UnexpectedError; + + global = updateCurrentSwap(global, { + ...resetParams, + errorType, + }); + setGlobal(global); + return; + } - if (!estimate || 'error' in estimate) { - const errorType = estimate?.error && estimate?.error in SERVER_ERRORS_MAP - ? SERVER_ERRORS_MAP[estimate.error as keyof typeof SERVER_ERRORS_MAP] - : SwapErrorType.UnexpectedError; + // Check for outdated response + if ( + !isFromAmountMax + && ( + ( + global.currentSwap.inputSource === SwapInputSource.In + && global.currentSwap.amountIn !== estimate.fromAmount + ) || ( + global.currentSwap.inputSource === SwapInputSource.Out + && global.currentSwap.amountOut !== estimate.toAmount + ) + )) { + global = updateCurrentSwap(global, { + ...resetParams, + }); + setGlobal(global); + return; + } - global = updateCurrentSwap(global, { - ...resetParams, - errorType, - }); - setGlobal(global); - return; - } + const errorType = estimate.toAmount === '0' && shouldTryDiesel + ? SwapErrorType.NotEnoughForFee + : undefined; + + const estimates = buildSwapEstimates(estimate); + const currentDexLabel = global.currentSwap.currentDexLabel && global.currentSwap.isDexLabelChanged + && estimates.some(({ dexLabel }) => dexLabel === global.currentSwap.currentDexLabel) + ? global.currentSwap.currentDexLabel + : estimate.dexLabel; + const currentEstimate = estimates.find(({ dexLabel }) => dexLabel === currentDexLabel)!; - // Check for outdated response - if ( - !isFromAmountMax - && ( - ( - global.currentSwap.inputSource === SwapInputSource.In - && global.currentSwap.amountIn !== estimate.fromAmount - ) || ( - global.currentSwap.inputSource === SwapInputSource.Out - && global.currentSwap.amountOut !== estimate.toAmount - ) - )) { global = updateCurrentSwap(global, { - ...resetParams, + ...( + global.currentSwap.inputSource === SwapInputSource.In + ? { + amountOut: currentEstimate.toAmount, + ...( + isFromAmountMax + ? { amountIn: currentEstimate.fromAmount } + : undefined + ), + } : { amountIn: currentEstimate.fromAmount } + ), + bestRateDexLabel: estimate.dexLabel, + amountOutMin: currentEstimate.toMinAmount, + priceImpact: currentEstimate.impact, + isEstimating: false, + errorType, + dieselStatus: estimate.dieselStatus, + estimates, + currentDexLabel, + // Fees + networkFee: currentEstimate.networkFee, + realNetworkFee: currentEstimate.realNetworkFee, + swapFee: currentEstimate.swapFee, + swapFeePercent: currentEstimate.swapFeePercent, + ourFee: currentEstimate.ourFee, + ourFeePercent: estimate.ourFeePercent, + dieselFee: currentEstimate.dieselFee, }); setGlobal(global); - return; + } finally { + isEstimateSwapBeingExecuted = false; } - - const errorType = estimate.toAmount === '0' && shouldTryDiesel - ? SwapErrorType.NotEnoughForFee - : undefined; - - const estimates = buildSwapEstimates(estimate); - const currentDexLabel = global.currentSwap.currentDexLabel && global.currentSwap.isDexLabelChanged - && estimates.some(({ dexLabel }) => dexLabel === global.currentSwap.currentDexLabel) - ? global.currentSwap.currentDexLabel - : estimate.dexLabel; - const currentEstimate = estimates.find(({ dexLabel }) => dexLabel === currentDexLabel)!; - - global = updateCurrentSwap(global, { - ...( - global.currentSwap.inputSource === SwapInputSource.In - ? { - amountOut: currentEstimate.toAmount, - ...( - isFromAmountMax - ? { amountIn: currentEstimate.fromAmount } - : undefined - ), - } : { amountIn: currentEstimate.fromAmount } - ), - bestRateDexLabel: estimate.dexLabel, - amountOutMin: currentEstimate.toMinAmount, - priceImpact: currentEstimate.impact, - feeSource: SwapFeeSource.In, - isEstimating: false, - errorType, - dieselStatus: estimate.dieselStatus, - estimates, - currentDexLabel, - // Fees - networkFee: currentEstimate.networkFee, - realNetworkFee: currentEstimate.realNetworkFee, - swapFee: currentEstimate.swapFee, - swapFeePercent: currentEstimate.swapFeePercent, - ourFee: currentEstimate.ourFee, - ourFeePercent: estimate.ourFeePercent, - dieselFee: currentEstimate.dieselFee, - }); - setGlobal(global); }); addActionHandler('estimateSwapCex', async (global, actions, { shouldBlock }) => { if (isEstimateCexSwapBeingExecuted) return; - isEstimateCexSwapBeingExecuted = true; - const amount = global.currentSwap.inputSource === SwapInputSource.In - ? { amountOut: undefined } - : { amountIn: undefined }; + try { + isEstimateCexSwapBeingExecuted = true; - const resetParams = { - ...amount, - amountOutMin: '0', - transactionFee: '0', - swapFee: '0', - networkFee: '0', - realNetworkFee: '0', - priceImpact: 0, - errorType: undefined, - isEstimating: false, - }; + const amount = global.currentSwap.inputSource === SwapInputSource.In + ? { amountOut: undefined } + : { amountIn: undefined }; - const resetSwapFeeParams = { - shouldEstimate: false, - transactionFee: '0', - swapFee: '0', - networkFee: '0', - realNetworkFee: '0', - priceImpact: 0, - }; + const resetParams = { + ...feeResetParams, + ...amount, + amountOutMin: '0', + priceImpact: 0, + errorType: undefined, + shouldEstimate: false, + isEstimating: false, + }; + + // Check for empty string + const { amountIn, amountOut, inputSource } = global.currentSwap; + if (((amountIn === undefined || amountIn === '0') && inputSource === SwapInputSource.In) + || ((amountOut === undefined || amountOut === '0') && inputSource === SwapInputSource.Out)) { + global = updateCurrentSwap(global, { + amountIn: undefined, + amountOut: undefined, + ...resetParams, + }); + setGlobal(global); + return; + } + + const pairsBySlug = global.currentSwap.pairs?.bySlug ?? {}; + const canSwap = global.currentSwap.tokenOutSlug! in pairsBySlug; + + // Check for invalid pair + if (!canSwap) { + global = updateCurrentSwap(global, { + ...resetParams, + errorType: SwapErrorType.InvalidPair, + }); + setGlobal(global); + return; + } - // Check for empty string - const { amountIn, amountOut, inputSource } = global.currentSwap; - if (((amountIn === undefined || amountIn === '0') && inputSource === SwapInputSource.In) - || ((amountOut === undefined || amountOut === '0') && global.currentSwap.inputSource === SwapInputSource.Out)) { global = updateCurrentSwap(global, { - amountIn: undefined, - amountOut: undefined, - ...resetSwapFeeParams, - ...resetParams, + shouldEstimate: false, + isEstimating: shouldBlock, }); setGlobal(global); - isEstimateCexSwapBeingExecuted = false; - return; - } - const pairsBySlug = global.currentSwap.pairs?.bySlug ?? {}; - const canSwap = global.currentSwap.tokenOutSlug! in pairsBySlug; + const tokenIn = global.swapTokenInfo!.bySlug[global.currentSwap.tokenInSlug!]; + const tokenOut = global.swapTokenInfo!.bySlug[global.currentSwap.tokenOutSlug!]; - // Check for invalid pair - if (!canSwap) { - global = updateCurrentSwap(global, { - ...resetParams, - ...resetSwapFeeParams, - errorType: SwapErrorType.InvalidPair, + const from = resolveSwapAssetId(tokenIn); + const to = resolveSwapAssetId(tokenOut); + const fromAmount = global.currentSwap.amountIn ?? '0'; + + const estimate = await callApi('swapCexEstimate', { + fromAmount, + from, + to, }); - setGlobal(global); - return; - } - global = updateCurrentSwap(global, { - isEstimating: shouldBlock, - }); - setGlobal(global); + global = getGlobal(); - const tokenIn = global.swapTokenInfo!.bySlug[global.currentSwap.tokenInSlug!]; - const tokenOut = global.swapTokenInfo!.bySlug[global.currentSwap.tokenOutSlug!]; + if (!estimate || 'errors' in estimate) { + global = updateCurrentSwap(global, { + ...resetParams, + errorType: window.navigator.onLine ? SwapErrorType.InvalidPair : SwapErrorType.UnexpectedError, + }); + setGlobal(global); + return; + } - const from = resolveSwapAssetId(tokenIn); - const to = resolveSwapAssetId(tokenOut); - const fromAmount = global.currentSwap.amountIn ?? '0'; + if ('errors' in estimate) { + global = updateCurrentSwap(global, { + ...resetParams, + errorType: SwapErrorType.UnexpectedError, + }); + setGlobal(global); + return; + } - const estimate = await callApi('swapCexEstimate', { - fromAmount, - from, - to, - }); + if ('error' in estimate) { + const { error } = estimate as { error: string }; + if (error.includes('requests limit')) return; + + if (error.includes('Invalid amount')) { + const [, mode, matchedAmount] = error.match(/(Maximum|Minimal) amount is ([\d.]+) .*/) || []; + if (mode && matchedAmount) { + const isLessThanMin = mode === 'Minimal'; + global = updateCurrentSwap(global, { + ...resetParams, + limits: isLessThanMin ? { fromMin: matchedAmount } : { fromMax: matchedAmount }, + errorType: isLessThanMin ? SwapErrorType.ChangellyMinSwap : SwapErrorType.ChangellyMaxSwap, + }); + setGlobal(global); + return; + } + } - global = getGlobal(); - isEstimateCexSwapBeingExecuted = false; + global = updateCurrentSwap(global, { + ...resetParams, + errorType: SwapErrorType.UnexpectedError, + }); + setGlobal(global); + return; + } - if (!estimate || 'errors' in estimate) { - global = updateCurrentSwap(global, { - ...resetParams, - ...resetSwapFeeParams, - errorType: window.navigator.onLine ? SwapErrorType.InvalidPair : SwapErrorType.UnexpectedError, - }); - setGlobal(global); - return; - } + // Check for outdated response + if ( + (global.currentSwap.inputSource === SwapInputSource.In + && global.currentSwap.amountIn !== estimate.fromAmount) + || (global.currentSwap.inputSource === SwapInputSource.Out + && global.currentSwap.amountOut !== estimate.toAmount) + ) { + global = updateCurrentSwap(global, resetParams); + setGlobal(global); + return; + } - if ('errors' in estimate) { - global = updateCurrentSwap(global, { - ...resetParams, - ...resetSwapFeeParams, - errorType: SwapErrorType.UnexpectedError, - }); - setGlobal(global); - return; - } + const account = global.accounts?.byId[global.currentAccountId!]; + let networkFee: string | undefined; + let realNetworkFee: string | undefined; - if ('error' in estimate) { - const { error } = estimate as { error: string }; - if (error.includes('requests limit')) return; - - if (error.includes('Invalid amount')) { - const [, mode, matchedAmount] = error.match(/(Maximum|Minimal) amount is ([\d.]+) .*/) || []; - if (mode && matchedAmount) { - const isLessThanMin = mode === 'Minimal'; - global = updateCurrentSwap(global, { - ...resetParams, - ...resetSwapFeeParams, - limits: isLessThanMin ? { fromMin: matchedAmount } : { fromMax: matchedAmount }, - errorType: isLessThanMin ? SwapErrorType.ChangellyMinSwap : SwapErrorType.ChangellyMaxSwap, - }); - setGlobal(global); - return; + if ( + global.currentSwap.swapType === SwapType.CrosschainFromWallet + && (tokenIn.chain === 'ton' || tokenIn.chain === 'tron') + ) { + const toAddress = { + ton: account?.addressByChain.ton!, + tron: TRX_SWAP_COUNT_FEE_ADDRESS, + }[tokenIn.chain]; + + const txDraft = await callApi('checkTransactionDraft', tokenIn.chain, { + accountId: global.currentAccountId!, + toAddress, + tokenAddress: tokenIn.tokenAddress, + }); + if (txDraft) { + ({ networkFee, realNetworkFee } = convertTransferFeesToSwapFees(txDraft, tokenIn.chain)); } } - global = updateCurrentSwap(global, { - ...resetParams, - ...resetSwapFeeParams, - errorType: SwapErrorType.UnexpectedError, - }); - setGlobal(global); - return; - } - - const isLessThanMin = Big(fromAmount).lt(estimate.fromMin); - const isBiggerThanMax = Big(fromAmount).gt(estimate.fromMax); + global = getGlobal(); - if (isLessThanMin || isBiggerThanMax) { global = updateCurrentSwap(global, { ...resetParams, - ...resetSwapFeeParams, + amountOut: estimate.toAmount === '0' ? undefined : estimate.toAmount, limits: { fromMin: estimate.fromMin, fromMax: estimate.fromMax, }, - errorType: isLessThanMin ? SwapErrorType.ChangellyMinSwap : SwapErrorType.ChangellyMaxSwap, + swapFee: estimate.swapFee, + networkFee, + realNetworkFee, + ourFee: '0', + ourFeePercent: 0, + dieselStatus: 'not-available', + amountOutMin: estimate.toAmount, + isEstimating: false, + errorType: Big(fromAmount).lt(estimate.fromMin) + ? SwapErrorType.ChangellyMinSwap + : Big(fromAmount).gt(estimate.fromMax) + ? SwapErrorType.ChangellyMaxSwap + : undefined, }); setGlobal(global); - return; - } - - // Check for outdated response - if ( - (global.currentSwap.inputSource === SwapInputSource.In - && global.currentSwap.amountIn !== estimate.fromAmount) - || (global.currentSwap.inputSource === SwapInputSource.Out - && global.currentSwap.amountOut !== estimate.toAmount) - ) { - global = updateCurrentSwap(global, { - ...resetParams, - ...resetSwapFeeParams, - }); - setGlobal(global); - return; - } - - let networkFee = '0'; - let realNetworkFee = '0'; - const account = global.accounts?.byId[global.currentAccountId!]; - - if (global.currentSwap.swapType === SwapType.CrosschainFromWallet) { - if (tokenIn.chain === 'ton') { - // todo: Force gasfull estimation - const txDraft = await callApi('checkTransactionDraft', 'ton', { - accountId: global.currentAccountId!, - toAddress: account?.addressByChain.ton!, - tokenAddress: tokenIn.tokenAddress, - }); - networkFee = toDecimal(txDraft?.fee ?? 0n, TONCOIN.decimals); - realNetworkFee = toDecimal(txDraft?.realFee ?? 0n, TONCOIN.decimals); - } else if (tokenIn.chain === 'tron') { - const txDraft = await callApi('checkTransactionDraft', 'tron', { - accountId: global.currentAccountId!, - toAddress: TRX_SWAP_COUNT_FEE_ADDRESS, - tokenAddress: tokenIn.tokenAddress, - }); - networkFee = toDecimal(txDraft?.fee ?? 0n, TRX.decimals); - realNetworkFee = toDecimal(txDraft?.realFee ?? 0n, TRX.decimals); - } + } finally { + isEstimateCexSwapBeingExecuted = false; } - - global = getGlobal(); - - global = updateCurrentSwap(global, { - ...resetSwapFeeParams, - amountOut: estimate.toAmount === '0' ? undefined : estimate.toAmount, - limits: { - fromMin: estimate.fromMin, - fromMax: estimate.fromMax, - }, - swapFee: estimate.swapFee, - networkFee, - realNetworkFee, - amountOutMin: estimate.toAmount, - isEstimating: false, - errorType: undefined, - }); - setGlobal(global); }); addActionHandler('setSwapScreen', (global, actions, { state }) => { @@ -1178,3 +1140,21 @@ addActionHandler('setSwapDex', (global, actions, { dexLabel }) => { }); setGlobal(global); }); + +function convertTransferFeesToSwapFees( + txDraft: Pick, + chain: ApiChain, +) { + const nativeToken = getNativeToken(chain); + let networkFee: string | undefined; + let realNetworkFee: string | undefined; + + if (txDraft?.fee !== undefined) { + networkFee = toDecimal(txDraft.fee, nativeToken.decimals); + } + if (txDraft?.realFee !== undefined) { + realNetworkFee = toDecimal(txDraft.realFee, nativeToken.decimals); + } + + return { networkFee, realNetworkFee }; +} diff --git a/src/global/actions/api/transfer.ts b/src/global/actions/api/transfer.ts index 6e1e23b1..b750d880 100644 --- a/src/global/actions/api/transfer.ts +++ b/src/global/actions/api/transfer.ts @@ -4,8 +4,9 @@ import { type ApiDappTransfer, ApiTransactionDraftError, type ApiTransactionErro import { TransferState } from '../../types'; import { IS_CAPACITOR, NFT_BATCH_SIZE } from '../../../config'; +import { bigintDivideToNumber } from '../../../util/bigint'; import { vibrateOnError, vibrateOnSuccess } from '../../../util/capacitor'; -import { getDieselTokenAmount } from '../../../util/fee/transferFee'; +import { explainApiTransferFee, getDieselTokenAmount } from '../../../util/fee/transferFee'; import { callActionInNative } from '../../../util/multitab'; import { IS_DELEGATING_BOTTOM_SHEET } from '../../../util/windowEnvironment'; import { callApi } from '../../../api'; @@ -35,7 +36,7 @@ addActionHandler('submitTransferInitial', async (global, actions, payload) => { toAddress, comment, shouldEncrypt, - nftAddresses, + nfts, withDiesel, stateInit, isGaslessWithStars, @@ -45,21 +46,22 @@ addActionHandler('submitTransferInitial', async (global, actions, payload) => { setGlobal(updateCurrentTransferLoading(global, true)); - const { tokenAddress, chain } = selectToken(global, tokenSlug); + const isNftTransfer = Boolean(nfts?.length); let result: ApiCheckTransactionDraftResult | undefined; - if (nftAddresses?.length) { + if (isNftTransfer) { // This assignment is needed only for the amount checking hack in the 'newLocalTransaction' handler in // `src/global/actions/apiUpdates/activities.ts` to work. amount = NFT_TRANSFER_AMOUNT; result = await callApi('checkNftTransferDraft', { accountId: global.currentAccountId!, - nftAddresses, + nfts, toAddress, comment, }); } else { + const { tokenAddress, chain } = selectToken(global, tokenSlug); result = await callApi('checkTransactionDraft', chain, { accountId: global.currentAccountId!, tokenAddress, @@ -69,7 +71,7 @@ addActionHandler('submitTransferInitial', async (global, actions, payload) => { shouldEncrypt, stateInit, isBase64Data: Boolean(binPayload), - isGaslessWithStars, + allowGasless: true, }); } @@ -83,7 +85,7 @@ addActionHandler('submitTransferInitial', async (global, actions, payload) => { if (!result || 'error' in result) { setGlobal(global); - if (result?.error === ApiTransactionDraftError.InsufficientBalance && !nftAddresses?.length) { + if (result?.error === ApiTransactionDraftError.InsufficientBalance && !isNftTransfer) { actions.showDialog({ message: 'The network fee has slightly changed, try sending again.' }); } else { actions.showError({ error: result?.error }); @@ -96,7 +98,6 @@ addActionHandler('submitTransferInitial', async (global, actions, payload) => { state: TransferState.Confirm, error: undefined, toAddress, - chain, resolvedAddress: result.resolvedAddress, amount, comment, @@ -113,7 +114,7 @@ addActionHandler('fetchTransferFee', async (global, actions, payload) => { setGlobal(global); const { - tokenSlug, toAddress, comment, shouldEncrypt, binPayload, stateInit, isGaslessWithStars, + tokenSlug, toAddress, comment, shouldEncrypt, binPayload, stateInit, } = payload; const { tokenAddress, chain } = selectToken(global, tokenSlug); @@ -126,12 +127,12 @@ addActionHandler('fetchTransferFee', async (global, actions, payload) => { shouldEncrypt, isBase64Data: Boolean(binPayload), stateInit, - isGaslessWithStars, + allowGasless: true, }); global = getGlobal(); - if (tokenSlug !== global.currentTransfer.tokenSlug) { + if (tokenSlug !== global.currentTransfer.tokenSlug || global.currentTransfer.nfts?.length) { // For cases when the user switches the token before the result arrives return; } @@ -148,25 +149,29 @@ addActionHandler('fetchTransferFee', async (global, actions, payload) => { }); addActionHandler('fetchNftFee', async (global, actions, payload) => { - const { toAddress, nftAddresses, comment } = payload; + const { toAddress, nfts, comment } = payload; global = updateCurrentTransfer(global, { isLoading: true, error: undefined }); setGlobal(global); const result = await callApi('checkNftTransferDraft', { accountId: global.currentAccountId!, - nftAddresses, + nfts, toAddress, comment, }); global = getGlobal(); - global = updateCurrentTransfer(global, { isLoading: false }); - if (result?.fee) { - global = updateCurrentTransfer(global, { fee: result.fee }); + if (!global.currentTransfer.nfts?.length) { + // For cases when the user switches the token transfer mode before the result arrives + return; } + global = updateCurrentTransfer(global, { isLoading: false }); + if (result) { + global = updateCurrentTransferByCheckResult(global, result); + } setGlobal(global); if (result?.error) { @@ -185,7 +190,6 @@ addActionHandler('submitTransferPassword', async (global, actions, { password }) amount, promiseId, tokenSlug, - fee, shouldEncrypt, binPayload, nfts, @@ -226,6 +230,10 @@ addActionHandler('submitTransferPassword', async (global, actions, { password }) return; } + const explainedFee = explainApiTransferFee(global.currentTransfer); + const fullNativeFee = explainedFee.fullFee?.nativeSum; + const realNativeFee = explainedFee.realFee?.nativeSum; + let result: ApiSubmitTransferResult | ApiSubmitMultiTransferResult | undefined; if (nfts?.length) { @@ -235,16 +243,14 @@ addActionHandler('submitTransferPassword', async (global, actions, { password }) } for (const chunk of chunks) { - const addresses = chunk.map(({ address }) => address); const batchResult = await callApi( 'submitNftTransfers', global.currentAccountId!, password, - addresses, + chunk, resolvedAddress!, comment, - chunk, - fee, + realNativeFee && bigintDivideToNumber(realNativeFee, nfts.length / chunk.length), ); global = getGlobal(); @@ -265,7 +271,8 @@ addActionHandler('submitTransferPassword', async (global, actions, { password }) amount: amount!, comment: binPayload ?? comment, tokenAddress, - fee, + fee: fullNativeFee, + realFee: realNativeFee, shouldEncrypt, isBase64Data: Boolean(binPayload), withDiesel, @@ -304,7 +311,6 @@ addActionHandler('submitTransferHardware', async (global, actions) => { amount, promiseId, tokenSlug, - fee, rawPayload, parsedPayload, stateInit, @@ -346,6 +352,9 @@ addActionHandler('submitTransferHardware', async (global, actions) => { return; } + const explainedFee = explainApiTransferFee(global.currentTransfer); + const realNativeFee = explainedFee.realFee?.nativeSum; + let result: string | { error: ApiTransactionError } | undefined; let error: string | undefined; @@ -358,7 +367,7 @@ addActionHandler('submitTransferHardware', async (global, actions) => { toAddress: resolvedAddress!, comment, nft, - fee, + realFee: realNativeFee && bigintDivideToNumber(realNativeFee, nfts.length), }); global = getGlobal(); @@ -377,7 +386,7 @@ addActionHandler('submitTransferHardware', async (global, actions) => { amount: amount!, comment, tokenAddress, - fee, + realFee: realNativeFee, }; try { diff --git a/src/global/actions/apiUpdates/dapp.ts b/src/global/actions/apiUpdates/dapp.ts index b599f1df..fe6b4fb2 100644 --- a/src/global/actions/apiUpdates/dapp.ts +++ b/src/global/actions/apiUpdates/dapp.ts @@ -24,6 +24,7 @@ addActionHandler('apiUpdate', (global, actions, update) => { amount, toAddress, fee, + realFee, comment, rawPayload, parsedPayload, @@ -36,6 +37,7 @@ addActionHandler('apiUpdate', (global, actions, update) => { toAddress, amount, fee, + realFee, comment, promiseId, tokenSlug: TONCOIN.slug, diff --git a/src/global/actions/index.ts b/src/global/actions/index.ts index 00ce9548..5a4cbd45 100644 --- a/src/global/actions/index.ts +++ b/src/global/actions/index.ts @@ -16,7 +16,6 @@ import './ui/initial'; import './ui/misc'; import './ui/dapps'; import './ui/settings'; -import './ui/swap'; import './ui/nfts'; import './ui/vesting'; import './ui/tokens'; diff --git a/src/global/actions/ui/dapps.ts b/src/global/actions/ui/dapps.ts index fdc722d4..5976c124 100644 --- a/src/global/actions/ui/dapps.ts +++ b/src/global/actions/ui/dapps.ts @@ -97,3 +97,13 @@ addActionHandler('updateDappLastOpenedAt', (global, actions, { origin }) => { global = updateCurrentAccountState(global, { dappLastOpenedDatesByOrigin: newDates }); setGlobal(global); }); + +addActionHandler('openSiteCategory', (global, actions, { id }) => { + global = updateCurrentAccountState(global, { currentSiteCategoryId: id }); + setGlobal(global); +}); + +addActionHandler('closeSiteCategory', (global) => { + global = updateCurrentAccountState(global, { currentSiteCategoryId: undefined }); + setGlobal(global); +}); diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index 68dbad95..b2965922 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -63,7 +63,6 @@ import { updateSettings, } from '../../reducers'; import { - selectAccounts, selectCurrentAccount, selectCurrentAccountSettings, selectCurrentAccountState, @@ -335,10 +334,8 @@ addActionHandler('initializeHardwareWalletModal', async (global, actions) => { }); addActionHandler('initializeHardwareWalletConnection', async (global, actions, params) => { - const accountsAmount = Object.keys(selectAccounts(global) || {}).length; - - if (IS_DELEGATING_BOTTOM_SHEET && accountsAmount > 0) { - callActionInNative('initializeHardwareWalletConnection', params); + if (IS_DELEGATING_BOTTOM_SHEET && params.shouldDelegateToNative) { + callActionInNative('initializeHardwareWalletConnection', { transport: params.transport }); return; } @@ -829,7 +826,7 @@ addActionHandler('closeAnyModal', () => { closeModal(); }); -addActionHandler('closeAllEntities', (global, actions) => { +addActionHandler('closeAllOverlays', (global, actions) => { actions.closeAnyModal(); actions.closeMediaViewer(); }); diff --git a/src/global/actions/ui/settings.ts b/src/global/actions/ui/settings.ts index d4632fef..55f6626c 100644 --- a/src/global/actions/ui/settings.ts +++ b/src/global/actions/ui/settings.ts @@ -18,7 +18,7 @@ addCallback((global: GlobalState) => { const { settings } = global; if (settings.theme !== prevSettings.theme) { - switchTheme(settings.theme, true); + switchTheme(settings.theme); } if (settings.langCode !== prevSettings.langCode) { diff --git a/src/global/actions/ui/swap.ts b/src/global/actions/ui/swap.ts deleted file mode 100644 index be2a31a8..00000000 --- a/src/global/actions/ui/swap.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { addActionHandler } from '../../index'; - -addActionHandler('toggleSwapSettingsModal', (global, actions, { isOpen }) => { - return { - ...global, - currentSwap: { - ...global.currentSwap, - isSettingsModalOpen: isOpen, - }, - }; -}); diff --git a/src/global/actions/ui/transfer.ts b/src/global/actions/ui/transfer.ts index 52b04984..67267839 100644 --- a/src/global/actions/ui/transfer.ts +++ b/src/global/actions/ui/transfer.ts @@ -16,9 +16,15 @@ addActionHandler('startTransfer', (global, actions, payload) => { const { isPortrait, ...rest } = payload ?? {}; + const nftTokenSlug = Symbol('nft'); + const previousFeeTokenSlug = global.currentTransfer.nfts?.length ? nftTokenSlug : global.currentTransfer.tokenSlug; + const nextFeeTokenSlug = payload?.nfts?.length ? nftTokenSlug : payload?.tokenSlug; + const shouldClearFee = nextFeeTokenSlug && nextFeeTokenSlug !== previousFeeTokenSlug; + setGlobal(updateCurrentTransfer(global, { state: isPortrait ? TransferState.Initial : TransferState.None, error: undefined, + ...(shouldClearFee ? { fee: undefined, realFee: undefined, diesel: undefined } : {}), ...rest, })); @@ -28,8 +34,8 @@ addActionHandler('startTransfer', (global, actions, payload) => { }); addActionHandler('changeTransferToken', (global, actions, { tokenSlug, withResetAmount }) => { - const { amount, tokenSlug: currentTokenSlug } = global.currentTransfer; - if (tokenSlug === currentTokenSlug && !withResetAmount) { + const { amount, tokenSlug: currentTokenSlug, nfts } = global.currentTransfer; + if (!nfts?.length && tokenSlug === currentTokenSlug && !withResetAmount) { return; } diff --git a/src/global/reducers/transfer.ts b/src/global/reducers/transfer.ts index 9a6d8318..e6869680 100644 --- a/src/global/reducers/transfer.ts +++ b/src/global/reducers/transfer.ts @@ -36,7 +36,7 @@ export function clearCurrentTransfer(global: GlobalState) { */ export function preserveMaxTransferAmount(prevGlobal: GlobalState, nextGlobal: GlobalState) { const previousMaxAmount = selectCurrentTransferMaxAmount(prevGlobal); - const wasMaxAmountSelected = prevGlobal.currentTransfer.amount === previousMaxAmount; + const wasMaxAmountSelected = previousMaxAmount && prevGlobal.currentTransfer.amount === previousMaxAmount; if (!wasMaxAmountSelected) { return nextGlobal; } diff --git a/src/global/selectors/swap.ts b/src/global/selectors/swap.ts index 231c9536..07bf6997 100644 --- a/src/global/selectors/swap.ts +++ b/src/global/selectors/swap.ts @@ -1,10 +1,8 @@ import type { ApiBalanceBySlug, ApiSwapAsset } from '../../api/types'; import type { AccountSettings, GlobalState, UserSwapToken } from '../types'; -import { getChainConfig } from '../../util/chain'; import { toBig } from '../../util/decimals'; import memoize from '../../util/memoize'; -import { getChainBySlug } from '../../util/tokens'; import withCache from '../../util/withCache'; import { selectCurrentAccountSettings, selectCurrentAccountState } from './accounts'; import { selectAccountTokensMemoizedFor } from './tokens'; @@ -148,8 +146,3 @@ export function selectCurrentSwapTokenOut(global: GlobalState) { const { tokenOutSlug } = global.currentSwap; return tokenOutSlug === undefined ? undefined : global.swapTokenInfo.bySlug[tokenOutSlug]; } - -export function selectCurrentSwapNativeTokenIn(global: GlobalState) { - const { tokenInSlug } = global.currentSwap; - return tokenInSlug === undefined ? undefined : getChainConfig(getChainBySlug(tokenInSlug))?.nativeToken; -} diff --git a/src/global/selectors/transfer.ts b/src/global/selectors/transfer.ts index b3a9fbee..4890fb73 100644 --- a/src/global/selectors/transfer.ts +++ b/src/global/selectors/transfer.ts @@ -1,24 +1,15 @@ import type { GlobalState } from '../types'; import { explainApiTransferFee, getMaxTransferAmount } from '../../util/fee/transferFee'; -import { getChainBySlug, getIsNativeToken } from '../../util/tokens'; import { selectCurrentAccountTokenBalance } from './tokens'; export function selectCurrentTransferMaxAmount(global: GlobalState) { const { currentTransfer } = global; - const chain = getChainBySlug(currentTransfer.tokenSlug); - const isNativeToken = getIsNativeToken(currentTransfer.tokenSlug); const tokenBalance = selectCurrentAccountTokenBalance(global, currentTransfer.tokenSlug); - const { fullFee, canTransferFullBalance } = explainApiTransferFee({ - fee: currentTransfer.fee, - realFee: currentTransfer.realFee, - diesel: currentTransfer.diesel, - chain, - isNativeToken, - }); + const { fullFee, canTransferFullBalance } = explainApiTransferFee(currentTransfer); return getMaxTransferAmount({ tokenBalance, - isNativeToken, + tokenSlug: currentTransfer.tokenSlug, fullFee: fullFee?.terms, canTransferFullBalance, }); diff --git a/src/global/types.ts b/src/global/types.ts index 620d85f5..b53040db 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -19,6 +19,7 @@ import type { ApiPriceHistoryPeriod, ApiSignedTransfer, ApiSite, + ApiSiteCategory, ApiStakingCommonData, ApiStakingHistory, ApiStakingState, @@ -86,6 +87,7 @@ export type AuthMethod = 'createAccount' | 'importMnemonic' | 'importHardwareWal export enum AppState { Auth, Main, + Explore, Settings, Ledger, Inactive, @@ -155,11 +157,6 @@ export enum SwapState { SelectTokenTo, } -export enum SwapFeeSource { - In, - Out, -} - export enum SwapInputSource { In, Out, @@ -379,6 +376,7 @@ export interface AccountState { isLongUnstakeRequested?: boolean; dapps?: ApiDapp[]; + currentSiteCategoryId?: number; } export interface AccountSettings { @@ -448,12 +446,13 @@ export type GlobalState = { currentTransfer: { state: TransferState; isLoading?: boolean; + // Should be ignored when `nfts` is defined and not empty tokenSlug: string; toAddress?: string; toAddressName?: string; resolvedAddress?: string; - chain?: ApiChain; error?: string; + // Should be ignored when `nfts` is defined and not empty amount?: bigint; // Every time this field value changes, the `amount` value should be actualized using `preserveMaxTransferAmount` fee?: bigint; @@ -487,7 +486,6 @@ export type GlobalState = { amountIn?: string; amountOut?: string; amountOutMin?: string; - transactionFee?: string; priceImpact?: number; activityId?: string; error?: string; @@ -498,7 +496,6 @@ export type GlobalState = { isEstimating?: boolean; inputSource?: SwapInputSource; swapType?: SwapType; - feeSource?: SwapFeeSource; toAddress?: string; payinAddress?: string; payoutAddress?: string; @@ -510,7 +507,6 @@ export type GlobalState = { fromMin?: string; fromMax?: string; }; - isSettingsModalOpen?: boolean; dieselStatus?: DieselStatus; estimates?: ApiSwapEstimateVariant[]; // This property is necessary to ensure that when the DEX with the best rate changes, @@ -518,7 +514,7 @@ export type GlobalState = { isDexLabelChanged?: true; currentDexLabel?: ApiSwapDexLabel; bestRateDexLabel?: ApiSwapDexLabel; - // Fees + // Fees. Undefined values mean that these fields are unknown. networkFee?: string; realNetworkFee?: string; swapFee?: string; @@ -535,7 +531,10 @@ export type GlobalState = { isSigned?: boolean; }; - exploreSites?: ApiSite[]; + exploreData?: { + categories: ApiSiteCategory[]; + sites: ApiSite[]; + }; currentDappTransfer: { state: TransferState; @@ -727,8 +726,8 @@ export interface ActionPayloads { openCreateBackUpPage: undefined; openCheckWordsPage: undefined; closeCheckWordsPage: { isBackupCreated?: boolean } | undefined; - initializeHardwareWalletModal: undefined; - initializeHardwareWalletConnection: { transport: LedgerTransport }; + initializeHardwareWalletModal: { shouldDelegateToNative?: boolean }; + initializeHardwareWalletConnection: { transport: LedgerTransport; shouldDelegateToNative?: boolean }; connectHardwareWallet: { transport?: LedgerTransport; noRetry?: boolean }; createHardwareAccounts: undefined; addHardwareAccounts: { @@ -771,11 +770,10 @@ export interface ActionPayloads { shouldEncrypt?: boolean; binPayload?: string; stateInit?: string; - isGaslessWithStars?: boolean; }; fetchNftFee: { toAddress: string; - nftAddresses: string[]; + nfts: ApiNft[]; comment?: string; }; submitTransferInitial: { @@ -784,7 +782,7 @@ export interface ActionPayloads { toAddress: string; comment?: string; shouldEncrypt?: boolean; - nftAddresses?: string[]; + nfts?: ApiNft[]; withDiesel?: boolean; isBase64Data?: boolean; binPayload?: string; @@ -848,7 +846,7 @@ export interface ActionPayloads { closeHideNftModal: undefined; closeAnyModal: undefined; - closeAllEntities: undefined; + closeAllOverlays: undefined; submitSignature: { password: string }; clearSignatureError: undefined; cancelSignature: undefined; @@ -967,6 +965,8 @@ export interface ActionPayloads { removeSiteFromBrowserHistory: { url: string }; openBrowser: { url: string; title?: string; subtitle?: string }; closeBrowser: undefined; + openSiteCategory: { id: number }; + closeSiteCategory: undefined; apiUpdateDappConnect: ApiUpdateDappConnect; apiUpdateDappSendTransaction: ApiUpdateDappSendTransactions; diff --git a/src/hooks/useHideBottomBar.ts b/src/hooks/useHideBottomBar.ts new file mode 100644 index 00000000..cf216e18 --- /dev/null +++ b/src/hooks/useHideBottomBar.ts @@ -0,0 +1,15 @@ +import { useEffect } from '../lib/teact/teact'; + +import { hideBottomBar, showBottomBar } from '../components/main/sections/Actions/BottomBar'; + +// Use this hook when you need to temporarily hide the bottom bar on a screen, for example, +// when assumes the use of the entire screen height - `PasswordForm` with biometrics +export default function useHideBottomBar(isHidden: boolean) { + useEffect(() => { + if (!isHidden) return undefined; + + hideBottomBar(); + + return showBottomBar; + }, [isHidden]); +} diff --git a/src/i18n/de.yaml b/src/i18n/de.yaml index 679c71eb..83d591e3 100644 --- a/src/i18n/de.yaml +++ b/src/i18n/de.yaml @@ -566,7 +566,7 @@ $dapp_liquid_staking_vote_payload: Abstimmung „%vote%“ für Vorschlag %votin $dapp_single_nominator_change_validator_payload: Adresse des SingleNominator-Staking-Vertragsvalidators in %address% ändern $dapp_single_nominator_withdraw_payload: Abhebung von %amount% TON vom SingleNominator-Staking-Vertrag $dapp_vesting_add_whitelist_payload: "%address% zur Vesting Wallet-Whitelist hinzufügen" -Search or enter address...: Adresse suchen oder eingeben... +Search or enter address: Adresse suchen oder eingeben Connecting dapps is not yet supported by Ledger.: Das Verbinden von Dapps wird von Ledger noch nicht unterstützt. Time synchronization issue. Please ensure your device's time settings are correct.: Problem mit der Zeitsynchronisierung. Bitte stellen Sie sicher, dass die Zeiteinstellungen Ihres Geräts korrekt sind. Data was copied!: Daten wurden kopiert! @@ -725,3 +725,14 @@ Select up to %count% wallets for notifications: Wählen Sie bis zu %count% Walle $swap_aggregator_fee_tooltip: Ein integrierter **DEX-Aggregator** findet den **besten Kurs** über verfügbare DEXs. Die Servicegebühr beträgt %percent%. Accumulated Rewards: Akkumulierte Belohnungen $swap_reverse_prohibited: Das Festlegen des Kaufbetrags ist für dieses Tokenpaar nicht möglich +Blockchain Fee Details: Blockchain-Gebührenübersicht +Final Fee: Endgebühr +Excess: Überschuss +$fee_details: "%full_fee% müssen sofort von Ihrer Wallet abgebucht werden, um die Gebühr zu bezahlen. Ein Teil davon wird Ihnen **in **%excess_symbol% als Überschuss innerhalb weniger Minuten zurückerstattet." +This is how the %chain_name% Blockchain works.: So funktioniert die %chain_name% Blockchain. +Got It: Verstanden +Wallet: Wallet +Connected: Verbunden +All Dapps: Alle Dapps +Trending: Im Trend +No Data: Keine Daten diff --git a/src/i18n/en.yaml b/src/i18n/en.yaml index 3cbd9213..069f7b98 100644 --- a/src/i18n/en.yaml +++ b/src/i18n/en.yaml @@ -563,7 +563,7 @@ $dapp_liquid_staking_vote_payload: Voting "%vote%" for proposal %votingAddress% $dapp_single_nominator_change_validator_payload: Changing address of SingleNominator staking contract validator to %address% $dapp_single_nominator_withdraw_payload: Withdrawal of %amount% TON from SingleNominator staking contract $dapp_vesting_add_whitelist_payload: Adding %address% to vesting wallet whitelist -Search or enter address...: Search or enter address... +Search or enter address: Search or enter address Connecting dapps is not yet supported by Ledger.: Connecting dapps is not yet supported by Ledger. Time synchronization issue. Please ensure your device's time settings are correct.: Time synchronization issue. Please ensure your device's time settings are correct. Data was copied!: Data was copied! @@ -725,3 +725,14 @@ Select up to %count% wallets for notifications: Select up to %count% wallets for $swap_aggregator_fee_tooltip: A built-in **DEX aggregator** finds the **best rate** across available DEXes. Service fee is %percent%. Accumulated Rewards: Accumulated Rewards $swap_reverse_prohibited: Setting the buy amount is impossible for this pair of tokens +Blockchain Fee Details: Blockchain Fee Details +Final Fee: Final Fee +Excess: Excess +$fee_details: "%full_fee% need to be immediately debited from your wallet to pay the fee. Part of this will be returned **in **%excess_symbol% to you as excess within a few minutes." +This is how the %chain_name% Blockchain works.: This is how the %chain_name% Blockchain works. +Got It: Got It +Wallet: Wallet +Connected: Connected +All Dapps: All Dapps +Trending: Trending +No Data: No Data diff --git a/src/i18n/es.yaml b/src/i18n/es.yaml index 449d71d3..9b26695c 100644 --- a/src/i18n/es.yaml +++ b/src/i18n/es.yaml @@ -564,7 +564,7 @@ $dapp_liquid_staking_vote_payload: Votando "%vote%" por la propuesta %votingAddr $dapp_single_nominator_change_validator_payload: Cambiar la dirección del validador de contratos de participación de SingleNominator a %address% $dapp_single_nominator_withdraw_payload: Retiro de %amount% TON del contrato de participación de SingleNominator $dapp_vesting_add_whitelist_payload: Agregar %address% a la lista blanca de billeteras con derechos de uso -Search or enter address...: Buscar o ingresar dirección... +Search or enter address: Buscar o ingresar dirección Connecting dapps is not yet supported by Ledger.: Ledger aún no admite la conexión de dapps. Time synchronization issue. Please ensure your device's time settings are correct.: Problema de sincronización horaria. Asegúrese de que la configuración de hora de su dispositivo sea correcta. Data was copied!: ¡Los datos fueron copiados! @@ -723,3 +723,14 @@ Select up to %count% wallets for notifications: Selecciona hasta %count% billete $swap_aggregator_fee_tooltip: Un **agregador DEX** incorporado encuentra la **mejor tasa** entre los DEX disponibles. La tarifa de servicio es del %percent%. Accumulated Rewards: Recompensas acumuladas $swap_reverse_prohibited: Configurar la cantidad de compra es imposible para este par de tokens +Blockchain Fee Details: Detalles de la tarifa de blockchain +Final Fee: Tarifa final +Excess: Excedente +$fee_details: Se deben debitar %full_fee% inmediatamente de su billetera para pagar la tarifa. Una parte de esto se le devolverá **en **%excess_symbol% como excedente en unos minutos. +This is how the %chain_name% Blockchain works.: Así funciona la Blockchain de %chain_name%. +Got It: Entendido +Wallet: Monedero +Connected: Conectado +All Dapps: Todas las Dapps +Trending: Tendencia +No Data: Sin datos diff --git a/src/i18n/pl.yaml b/src/i18n/pl.yaml index a4182889..3d438573 100644 --- a/src/i18n/pl.yaml +++ b/src/i18n/pl.yaml @@ -568,7 +568,7 @@ $dapp_liquid_staking_vote_payload: Głosowanie „%vote%” na propozycję %voti $dapp_single_nominator_change_validator_payload: Zmiana adresu walidatora umowy stakingowej SingleNominator na %address% $dapp_single_nominator_withdraw_payload: Wycofanie %amount% TON z umowy stakingu SingleNominator $dapp_vesting_add_whitelist_payload: Dodanie %address% do białej listy portfeli uprawnień -Search or enter address...: Szukaj lub wprowadź adres... +Search or enter address: Szukaj lub wprowadź adres Connecting dapps is not yet supported by Ledger.: Łączenie dappów nie jest jeszcze obsługiwane przez Ledger. Time synchronization issue. Please ensure your device's time settings are correct.: Problem z synchronizacją czasu. Upewnij się, że ustawienia czasu urządzenia są poprawne. Data was copied!: Dane zostały skopiowane! @@ -729,3 +729,14 @@ Select up to %count% wallets for notifications: Wybierz do %count% portfeli do p $swap_aggregator_fee_tooltip: Wbudowany **agregator DEX** znajduje **najlepszy kurs** w dostępnych DEX-ach. Opłata za usługę wynosi %percent%. Accumulated Rewards: Nagromadzone nagrody $swap_reverse_prohibited: Ustawienie kwoty zakupu jest niemożliwe dla tej pary tokenów +Blockchain Fee Details: Szczegóły opłaty blockchain +Final Fee: Opłata końcowa +Excess: Reszta +$fee_details: "%full_fee% zostanie natychmiast pobrane z Twojego portfela w celu uiszczenia opłaty. Część tej kwoty zostanie zwrócona **w **%excess_symbol% jako reszta w ciągu kilku minut." +This is how the %chain_name% Blockchain works.: Tak działa Blockchain %chain_name%. +Got It: Zrozumiano +Wallet: Portfel +Connected: Połączono +All Dapps: Wszystkie Dapps +Trending: Popularne +No Data: Brak danych diff --git a/src/i18n/ru.yaml b/src/i18n/ru.yaml index 33ab349a..0972e7fe 100644 --- a/src/i18n/ru.yaml +++ b/src/i18n/ru.yaml @@ -566,7 +566,7 @@ $dapp_liquid_staking_vote_payload: Голосование "%vote%" за пред $dapp_single_nominator_change_validator_payload: Изменение адреса валидатора стейкинг-контракта SingleNominator на %address% $dapp_single_nominator_withdraw_payload: Вывод %amount% TON из стейкинг-контракта SingleNominator $dapp_vesting_add_whitelist_payload: Добавление адреса %address% в whitelist вестинг-кошелька -Search or enter address...: Введите адрес или запрос... +Search or enter address: Введите адрес или запрос Time synchronization issue. Please ensure your device's time settings are correct.: Проблема с синхронизацией времени. Пожалуйста, убедитесь, что настройки времени на вашем устройстве верны. Data was copied!: Данные скопированы! Open NFT Collection: Открыть коллекцию NFT @@ -724,3 +724,14 @@ Select up to %count% wallets for notifications: Выберите до %count% к $swap_aggregator_fee_tooltip: Встроенный **DEX-агрегатор** помогает выбрать **лучший курс** среди доступных бирж. Комиссия сервиса — %percent%. Accumulated Rewards: Накопленные награды $swap_reverse_prohibited: Указание суммы покупки невозможно для этой пары токенов +Blockchain Fee Details: Детали комиссии блокчейна +Final Fee: Итоговая комиссия +Excess: Сдача +$fee_details: "%full_fee% будет мгновенно списано с вашего кошелька для оплаты комиссии. Часть этой суммы будет возвращена вам **в **%excess_symbol% как сдача в течение нескольких минут." +This is how the %chain_name% Blockchain works.: Так работает блокчейн %chain_name%. +Got It: Понятно +Wallet: Кошелёк +Connected: Подключено +All Dapps: Все приложения +Trending: В тренде +No Data: Нет данных diff --git a/src/i18n/th.yaml b/src/i18n/th.yaml index 68ec1732..c5f60840 100644 --- a/src/i18n/th.yaml +++ b/src/i18n/th.yaml @@ -566,7 +566,7 @@ $dapp_liquid_staking_vote_payload: กำลังโหวต "%vote%" สำ $dapp_single_nominator_change_validator_payload: การเปลี่ยนที่อยู่ของผู้ตรวจสอบสัญญาการปักหลัก SingleNominator เป็น %address% $dapp_single_nominator_withdraw_payload: การถอน %amount% TON จากสัญญาการวางเดิมพัน SingleNominator $dapp_vesting_add_whitelist_payload: กำลังเพิ่ม %address% ไปยังรายการที่อนุญาตของกระเป๋าสตางค์ -Search or enter address...: ค้นหา หรือ กรอกที่อยู่บัญชี... +Search or enter address: ค้นหา หรือ กรอกที่อยู่บัญชี Connecting dapps is not yet supported by Ledger.: การเชื่อมต่อ dApps ยังไม่รองรับบน Ledger. Time synchronization issue. Please ensure your device's time settings are correct.: การซิงโครไนซ์เวลามีปัญหา, โปรดตรวจสอบให้แน่ใจว่าได้ตั้งค่าเวลาของอุปกรณ์ของคุณถูกต้อง. Data was copied!: ข้อมูลถูกคัดลอกแล้ว! @@ -727,3 +727,14 @@ Select up to %count% wallets for notifications: เลือกกระเป $swap_aggregator_fee_tooltip: ตัวรวบรวม **DEX ในตัว** จะค้นหา **อัตราที่ดีที่สุด** จาก DEX ที่มีอยู่ ค่าบริการคือ %percent%. Accumulated Rewards: รางวัลที่สะสม $swap_reverse_prohibited: การตั้งค่าจำนวนซื้อเป็นไปไม่ได้สำหรับคู่โทเค็นนี้ +Blockchain Fee Details: รายละเอียดค่าธรรมเนียมบล็อกเชน +Final Fee: ค่าธรรมเนียมสุดท้าย +Excess: เงินส่วนเกิน +$fee_details: จำเป็นต้องหัก %full_fee% จากกระเป๋าเงินของคุณทันทีเพื่อชำระค่าธรรมเนียม ส่วนหนึ่งของจำนวนนี้จะถูกคืนให้คุณ **เป็น **%excess_symbol% ในรูปแบบส่วนเกินภายในไม่กี่นาที +This is how the %chain_name% Blockchain works.: นี่คือวิธีการทำงานของบล็อกเชน %chain_name% +Got It: เข้าใจแล้ว +Wallet: กระเป๋าสตางค์ +Connected: เชื่อมต่อแล้ว +All Dapps: Dapps ทั้งหมด +Trending: มาแรง +No Data: ไม่มีข้อมูล diff --git a/src/i18n/tr.yaml b/src/i18n/tr.yaml index 621e52f9..527b9884 100644 --- a/src/i18n/tr.yaml +++ b/src/i18n/tr.yaml @@ -565,7 +565,7 @@ $dapp_liquid_staking_vote_payload: "%votingAddress% teklifi için \"%vote%\" oyu $dapp_single_nominator_change_validator_payload: SingleNominator staking sözleşmesi doğrulayıcısının adresi %address% olarak değiştiriliyor $dapp_single_nominator_withdraw_payload: "%amount% TON'un SingleNominator staking sözleşmesinden çekilmesi" $dapp_vesting_add_whitelist_payload: Vesting cüzdanı beyaz listesine %address% ekleniyor -Search or enter address...: Adresi arayın veya girin... +Search or enter address: Adresi arayın veya girin Connecting dapps is not yet supported by Ledger.: Dapp'lerin bağlanması henüz Ledger tarafından desteklenmiyor. Time synchronization issue. Please ensure your device's time settings are correct.: Time synchronization issue. Zaman senkronizasyonu sorunu. Lütfen cihazınızın saat ayarlarının doğru olduğundan emin olun. Data was copied!: Veri kopyalandı! @@ -724,3 +724,14 @@ Select up to %count% wallets for notifications: Bildirimler için en fazla %coun $swap_aggregator_fee_tooltip: Dahili bir **DEX toplayıcı**, mevcut DEX'ler arasında **en iyi oranı** bulur. Hizmet ücreti %percent%. Accumulated Rewards: Birikmiş ödüller $swap_reverse_prohibited: Bu token çifti için satın alma miktarını ayarlamak imkansız +Blockchain Fee Details: Blockchain Ücret Detayları +Final Fee: Nihai Ücret +Excess: Fazlalık +$fee_details: Ücreti ödemek için cüzdanınızdan %full_fee% hemen çekilmelidir. Bunun bir kısmı birkaç dakika içinde fazlalık olarak size %excess_symbol%** cinsinden** geri iade edilecektir. +This is how the %chain_name% Blockchain works.: "%chain_name% Blockchain'i böyle çalışır." +Got It: Anladım +Wallet: Cüzdan +Connected: Bağlandı +All Dapps: Tüm Dapp'lar +Trending: Trendler +No Data: Veri Yok diff --git a/src/i18n/uk.yaml b/src/i18n/uk.yaml index 49ea6871..84fd8149 100644 --- a/src/i18n/uk.yaml +++ b/src/i18n/uk.yaml @@ -571,7 +571,7 @@ $dapp_liquid_staking_vote_payload: Голосування "%vote%" за проп $dapp_single_nominator_change_validator_payload: Зміна адреси валідатора контракту стекінгу SingleNominator на %address% $dapp_single_nominator_withdraw_payload: Зняття %amount% TON з контракту на ставку SingleNominator $dapp_vesting_add_whitelist_payload: Додавання %address% до білого списку гаманців -Search or enter address...: Введіть адресу або запит... +Search or enter address: Введіть адресу або запит Time synchronization issue. Please ensure your device's time settings are correct.: Проблема із синхронізацією часу. Будь ласка, переконайтеся, що налаштування часу на вашому пристрої правильні. Data was copied!: Дані скопійовано! Open NFT Collection: Відкрити колекцію NFT @@ -729,3 +729,14 @@ Select up to %count% wallets for notifications: Виберіть до %count% г $swap_aggregator_fee_tooltip: Вбудований **агрегатор DEX** знаходить **найкращий курс** серед доступних DEX. Плата за послугу становить %percent%. Accumulated Rewards: Накоплені нагороди $swap_reverse_prohibited: Встановити суму покупки для цієї пари токенів неможливо +Blockchain Fee Details: Деталі комісії блокчейна +Final Fee: Остаточна комісія +Excess: Здача +$fee_details: "%full_fee% буде миттєво списано з вашого гаманця для оплати комісії. Частину цієї суми буде повернуто вам **в **%excess_symbol% як здача протягом кількох хвилин." +This is how the %chain_name% Blockchain works.: Ось як працює блокчейн %chain_name%. +Got It: Зрозуміло +Wallet: Гаманець +Connected: Підключено +All Dapps: Всі додатки +Trending: У тренді +No Data: Даних немає diff --git a/src/i18n/zh-Hans.yaml b/src/i18n/zh-Hans.yaml index c8854411..ca4f803d 100644 --- a/src/i18n/zh-Hans.yaml +++ b/src/i18n/zh-Hans.yaml @@ -555,7 +555,7 @@ $dapp_liquid_staking_vote_payload: 对提案 %votingAddress% 投票“%vote%” $dapp_single_nominator_change_validator_payload: 将 SingleNominator 质押合约验证者的地址更改为 %address% $dapp_single_nominator_withdraw_payload: 从 SingleNominator 质押合约中提取 %amount% TON $dapp_vesting_add_whitelist_payload: 将 %address% 添加到归属钱包白名单 -Search or enter address...: 搜索或输入地址... +Search or enter address: 搜索或输入地址 Connecting dapps is not yet supported by Ledger.: Ledger 尚不支持连接 dapp。 Time synchronization issue. Please ensure your device's time settings are correct.: 时间同步问题。 请确保您设备的时间设置正确。 Data was copied!: 数据被复制! @@ -713,3 +713,14 @@ Select up to %count% wallets for notifications: 选择最多 %count% 个钱包 $swap_aggregator_fee_tooltip: 内置的 **DEX 聚合器** 会在可用的 DEX 中找到 **最佳汇率**。服务费为 %percent%。 Accumulated Rewards: 累积奖励 $swap_reverse_prohibited: 无法为这对代币设置购买金额 +Blockchain Fee Details: 区块链费用详情 +Final Fee: 最终费用 +Excess: 多余款项 +$fee_details: 需要立即从您的钱包中扣除%full_fee%以支付费用。部分金额将在几分钟内以%excess_symbol%的形式返还给您作为多余款项。 +This is how the %chain_name% Blockchain works.: 这就是%chain_name%区块链的工作方式。 +Got It: 知道了 +Wallet: 钱包 +Connected: 已连接 +All Dapps: 所有去中心化应用 +Trending: 热门 +No Data: 无数据 diff --git a/src/i18n/zh-Hant.yaml b/src/i18n/zh-Hant.yaml index 231e845f..7020c18d 100644 --- a/src/i18n/zh-Hant.yaml +++ b/src/i18n/zh-Hant.yaml @@ -555,7 +555,7 @@ $dapp_liquid_staking_vote_payload: 對提案 %votingAddress% 投票“%vote%” $dapp_single_nominator_change_validator_payload: 將 SingleNominator 質押合約驗證器的位址改為 %address% $dapp_single_nominator_withdraw_payload: 從 SingleNominator 質押合約中提取 %amount% TON $dapp_vesting_add_whitelist_payload: 將%address%加入歸屬錢包白名單 -Search or enter address...: 搜尋或輸入地址... +Search or enter address: 搜尋或輸入地址 Connecting dapps is not yet supported by Ledger.: Ledger 尚不支援連接 dapp。 Time synchronization issue. Please ensure your device's time settings are correct.: 時間同步問題。 請確保您設備的時間設定正確。 Data was copied!: 資料被複製! @@ -713,3 +713,14 @@ Select up to %count% wallets for notifications: 選擇最多 %count% 個錢包 $swap_aggregator_fee_tooltip: 內建的 **DEX 聚合器** 會在可用的 DEX 中找到 **最佳匯率**。服務費為 %percent%。 Accumulated Rewards: 累積獎勵 $swap_reverse_prohibited: 無法為這對代幣設置購買金額 +Blockchain Fee Details: 區塊鏈費用詳情 +Final Fee: 最終費用 +Excess: 多餘款項 +$fee_details: 需要立即從您的錢包中扣除%full_fee%以支付費用。部分金額將在幾分鐘內以%excess_symbol%的形式返還給您作為多餘款項。 +This is how the %chain_name% Blockchain works.: 這就是%chain_name%區塊鏈的運作方式。 +Got It: 知道了 +Wallet: 錢包 +Connected: 已連接 +All Dapps: 所有去中心化應用 +Trending: 熱門 +No Data: 無數據 diff --git a/src/styles/brilliant-icons.css b/src/styles/brilliant-icons.css index c3deba33..b2ef5a8a 100644 --- a/src/styles/brilliant-icons.css +++ b/src/styles/brilliant-icons.css @@ -1,7 +1,7 @@ @font-face { font-family: "brilliant-icons"; - src: url("./brilliant-icons.woff?bbbd60b644415577f4895a78f54596c3") format("woff"), -url("./brilliant-icons.woff2?bbbd60b644415577f4895a78f54596c3") format("woff2"); + src: url("./brilliant-icons.woff?130ea017c9faa89f13a327e92bead72d") format("woff"), +url("./brilliant-icons.woff2?130ea017c9faa89f13a327e92bead72d") format("woff2"); font-weight: normal; font-style: normal; } @@ -23,234 +23,243 @@ url("./brilliant-icons.woff2?bbbd60b644415577f4895a78f54596c3") format("woff2"); .icon-windows-close::before { content: "\f103"; } -.icon-versions::before { +.icon-wallet::before { content: "\f104"; } -.icon-update::before { +.icon-versions::before { content: "\f105"; } -.icon-trash::before { +.icon-update::before { content: "\f106"; } -.icon-trash-small::before { +.icon-trash::before { content: "\f107"; } -.icon-touch-id::before { +.icon-trash-small::before { content: "\f108"; } -.icon-tooltip::before { +.icon-touch-id::before { content: "\f109"; } -.icon-tonexplorer::before { +.icon-tooltip::before { content: "\f10a"; } -.icon-tonexplorer-small::before { +.icon-tonexplorer::before { content: "\f10b"; } -.icon-ton::before { +.icon-tonexplorer-small::before { content: "\f10c"; } -.icon-telegram::before { +.icon-ton::before { content: "\f10d"; } -.icon-swap::before { +.icon-telegram::before { content: "\f10e"; } -.icon-star::before { +.icon-swap::before { content: "\f10f"; } -.icon-star-filled::before { +.icon-star::before { content: "\f110"; } -.icon-spinner::before { +.icon-star-filled::before { content: "\f111"; } -.icon-sort::before { +.icon-spinner::before { content: "\f112"; } -.icon-snow::before { +.icon-sort::before { content: "\f113"; } -.icon-share::before { +.icon-snow::before { content: "\f114"; } -.icon-send::before { +.icon-share::before { content: "\f115"; } -.icon-send-small::before { +.icon-settings::before { content: "\f116"; } -.icon-send-alt::before { +.icon-send::before { content: "\f117"; } -.icon-search::before { +.icon-send-small::before { content: "\f118"; } -.icon-replace::before { +.icon-send-alt::before { content: "\f119"; } -.icon-receive::before { +.icon-search::before { content: "\f11a"; } -.icon-receive-alt::before { +.icon-replace::before { content: "\f11b"; } -.icon-question::before { +.icon-receive::before { content: "\f11c"; } -.icon-qr-scanner::before { +.icon-receive-alt::before { content: "\f11d"; } -.icon-qr-scanner-alt::before { +.icon-question::before { content: "\f11e"; } -.icon-plus::before { +.icon-qr-scanner::before { content: "\f11f"; } -.icon-percent::before { +.icon-qr-scanner-alt::before { content: "\f120"; } -.icon-pen::before { +.icon-plus::before { content: "\f121"; } -.icon-paste::before { +.icon-percent::before { content: "\f122"; } -.icon-params::before { +.icon-pen::before { content: "\f123"; } -.icon-more::before { +.icon-paste::before { content: "\f124"; } -.icon-missed::before { +.icon-params::before { content: "\f125"; } -.icon-menu-dots::before { +.icon-more::before { content: "\f126"; } -.icon-manual-lock::before { +.icon-missed::before { content: "\f127"; } -.icon-lock::before { +.icon-menu-dots::before { content: "\f128"; } -.icon-link::before { +.icon-manual-lock::before { content: "\f129"; } -.icon-ledger::before { +.icon-lock::before { content: "\f12a"; } -.icon-laptop::before { +.icon-link::before { content: "\f12b"; } -.icon-globe::before { +.icon-ledger::before { content: "\f12c"; } -.icon-github::before { +.icon-laptop::before { content: "\f12d"; } -.icon-flashlight::before { +.icon-globe::before { content: "\f12e"; } -.icon-fire::before { +.icon-github::before { content: "\f12f"; } -.icon-face-id::before { +.icon-flashlight::before { content: "\f130"; } -.icon-eye::before { +.icon-fire::before { content: "\f131"; } -.icon-eye-closed::before { +.icon-face-id::before { content: "\f132"; } -.icon-external::before { +.icon-eye::before { content: "\f133"; } -.icon-earn::before { +.icon-eye-closed::before { content: "\f134"; } -.icon-download::before { +.icon-external::before { content: "\f135"; } -.icon-download-filled::before { +.icon-explore::before { content: "\f136"; } -.icon-dot::before { +.icon-earn::before { content: "\f137"; } -.icon-crypto::before { +.icon-download::before { content: "\f138"; } -.icon-copy::before { +.icon-download-filled::before { content: "\f139"; } -.icon-cog::before { +.icon-dot::before { content: "\f13a"; } -.icon-close::before { +.icon-crypto::before { content: "\f13b"; } -.icon-close-filled::before { +.icon-copy::before { content: "\f13c"; } -.icon-chevron-right::before { +.icon-cog::before { content: "\f13d"; } -.icon-chevron-left::before { +.icon-close::before { content: "\f13e"; } -.icon-chevron-down::before { +.icon-close-filled::before { content: "\f13f"; } -.icon-check::before { +.icon-chevron-right::before { content: "\f140"; } -.icon-changelly::before { +.icon-chevron-left::before { content: "\f141"; } -.icon-chain-tron::before { +.icon-chevron-down::before { content: "\f142"; } -.icon-chain-ton::before { +.icon-check::before { content: "\f143"; } -.icon-caret-down::before { +.icon-changelly::before { content: "\f144"; } -.icon-card::before { +.icon-chain-tron::before { content: "\f145"; } -.icon-backspace::before { +.icon-chain-ton::before { content: "\f146"; } -.icon-arrow-up::before { +.icon-caret-down::before { content: "\f147"; } -.icon-arrow-up-swap::before { +.icon-card::before { content: "\f148"; } -.icon-arrow-right::before { +.icon-backspace::before { content: "\f149"; } -.icon-arrow-right-swap::before { +.icon-arrow-up::before { content: "\f14a"; } -.icon-arrow-down::before { +.icon-arrow-up-swap::before { content: "\f14b"; } -.icon-action-swap::before { +.icon-arrow-right::before { content: "\f14c"; } -.icon-action-send::before { +.icon-arrow-right-swap::before { content: "\f14d"; } -.icon-action-earn::before { +.icon-arrow-down::before { content: "\f14e"; } -.icon-action-add::before { +.icon-action-swap::before { content: "\f14f"; } -.icon-accept::before { +.icon-action-send::before { content: "\f150"; } +.icon-action-earn::before { + content: "\f151"; +} +.icon-action-add::before { + content: "\f152"; +} +.icon-accept::before { + content: "\f153"; +} diff --git a/src/styles/brilliant-icons.woff b/src/styles/brilliant-icons.woff index 316a672c..934d204d 100644 Binary files a/src/styles/brilliant-icons.woff and b/src/styles/brilliant-icons.woff differ diff --git a/src/styles/brilliant-icons.woff2 b/src/styles/brilliant-icons.woff2 index 0cd61cb2..5f324372 100644 Binary files a/src/styles/brilliant-icons.woff2 and b/src/styles/brilliant-icons.woff2 differ diff --git a/src/styles/index.scss b/src/styles/index.scss index 5d720ee4..fb068ac8 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -47,6 +47,7 @@ html { --safe-area-right: env(safe-area-inset-right, 0px); --safe-area-bottom: env(safe-area-inset-bottom, 0px); --safe-area-left: env(safe-area-inset-left, 0px); + --bottombar-height: 0px; &.theme-dark { color-scheme: dark; @@ -69,6 +70,10 @@ html { overflow: auto; } } + + &.with-bottombar { + --bottombar-height: 3.25rem; + } } #root, diff --git a/src/styles/variables.scss b/src/styles/variables.scss index 5d627161..d6a5499a 100644 --- a/src/styles/variables.scss +++ b/src/styles/variables.scss @@ -4,7 +4,7 @@ --color-accent: #0088CC; --color-black: #2C333E; --color-gray-1: #616770; - --color-gray-2: #8399AE; + --color-gray-2: #8B8F96; --color-gray-3: #A5A8AD; --color-gray-3-desktop: #B7B9BD; --color-gray-4: #C8CACD; @@ -20,6 +20,8 @@ --color-background-second: #F3F4F5; --color-background-window: #F3F4F5; --color-background-drop-down: #FFFFFF; + --color-background-tab-bar: #F3F4F5CC; + --color-background-explore-folder: #E5E6E8; --color-background-lock: rgba(255, 255, 255, 0.8); --color-background-purple-1: #F9FAFE; --color-background-purple-2: #DFE1FE; @@ -211,7 +213,6 @@ --color-beidge-background: #D9EAF5; --sticky-card-height: 3.75rem; - --tabs-container-height: 2.75rem; &.is-ios { --layer-transition: 650ms cubic-bezier(0.22, 1, 0.36, 1); @@ -256,6 +257,8 @@ --color-background-second: #000000; --color-background-window: #131314; --color-background-drop-down: #222224; + --color-background-tab-bar: #1C1C1EE5; + --color-background-explore-folder: #161617; --color-background-lock: rgba(28, 28, 30, 0.8); --color-background-purple-1: #191D2A; --color-background-purple-2: #292F46; diff --git a/src/util/bigNumber.ts b/src/util/bigNumber.ts new file mode 100644 index 00000000..17ede1ec --- /dev/null +++ b/src/util/bigNumber.ts @@ -0,0 +1,11 @@ +import type { BigSource } from '../lib/big.js'; + +import { Big } from '../lib/big.js'; + +export function bigMin(value0: T1, value1: T2): T1 | T2 { + return Big(value0).lt(value1) ? value0 : value1; +} + +export function bigMax(value0: T1, value1: T2): T1 | T2 { + return Big(value0).gt(value1) ? value0 : value1; +} diff --git a/src/util/capacitor/notifications.ts b/src/util/capacitor/notifications.ts index f1901587..7a0113fb 100644 --- a/src/util/capacitor/notifications.ts +++ b/src/util/capacitor/notifications.ts @@ -83,7 +83,7 @@ function handlePushNotificationActionPerformed(notification: ActionPerformed) { showAnyAccountTx, showAnyAccountTokenActivity, openAnyAccountStakingInfo, - closeAllEntities, + closeAllOverlays, } = getActions(); const global = getGlobal(); const notificationData = notification.notification.data as MessageData; @@ -98,7 +98,7 @@ function handlePushNotificationActionPerformed(notification: ActionPerformed) { const network = 'mainnet'; - closeAllEntities(); + closeAllOverlays(); if (action === 'nativeTx' || action === 'swap') { const { txId } = notificationData; showAnyAccountTx({ accountId, txId, network }); diff --git a/src/util/fee/formatFee.ts b/src/util/fee/formatFee.ts deleted file mode 100644 index e6a9d2dc..00000000 --- a/src/util/fee/formatFee.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { UserToken } from '../../global/types'; -import type { FeePrecision, FeeTerms } from './types'; - -import { STARS_SYMBOL } from '../../config'; -import { toDecimal } from '../decimals'; -import { formatCurrency } from '../formatNumber'; - -export type FormatFeeOptions = { - terms: FeeTerms; - /** The token acting as the transferred token in the `terms` object */ - token: FeeToken; - /** The token acting as the native token in the `terms` object */ - nativeToken: FeeToken; - /** Affects the sign indicating the fee precision and standing before the terms. */ - precision: FeePrecision; -}; - -type FeeToken = Pick; - -type FeeListTerm = { - tokenType: keyof FeeTerms; - amount: bigint; -}; - -const TERM_SEPARATOR = ' + '; -const PRECISION_PREFIX: Record = { - exact: '', - approximate: '~\u202F', - lessThan: '<\u202F', -}; -const STARS_TOKEN = { - symbol: STARS_SYMBOL, - decimals: 0, -}; - -/** - * Formats a complex fee (containing multiple terms) into a human-readable string - */ -export function formatFee({ - terms, - token, - nativeToken, - precision, -}: FormatFeeOptions): string { - let result = convertTermsObjectToList(terms) - .map(({ tokenType, amount }) => { - const currentToken = tokenType === 'stars' ? STARS_TOKEN : tokenType === 'native' ? nativeToken : token; - return formatCurrency(toDecimal(amount, currentToken.decimals), currentToken.symbol, undefined, true); - }) - .join(TERM_SEPARATOR); - - if (precision !== undefined) { - result = PRECISION_PREFIX[precision] + result; - } - - return result; -} - -function convertTermsObjectToList(terms: FeeTerms) { - const termList: FeeListTerm[] = []; - let firstNonZeroTerm: FeeListTerm | undefined; - - for (const [tokenType, amount] of Object.entries(terms) as [keyof FeeTerms, bigint | undefined][]) { - if (amount !== undefined) { - const term = { tokenType, amount }; - - if (amount !== 0n) { - termList.push(term); - } else if (!firstNonZeroTerm) { - firstNonZeroTerm = term; - } - } - } - - // Keeping at least 1 term for better UX - if (termList.length === 0) { - termList.push(firstNonZeroTerm ?? { tokenType: 'native', amount: 0n }); - } - - return termList; -} diff --git a/src/util/fee/swapFee.ts b/src/util/fee/swapFee.ts new file mode 100644 index 00000000..6e8853c5 --- /dev/null +++ b/src/util/fee/swapFee.ts @@ -0,0 +1,283 @@ +import type { ApiToken } from '../../api/types'; +import type { GlobalState } from '../../global/types'; +import type { FeePrecision, FeeTerms } from './types'; +import { SwapType } from '../../global/types'; + +import { DEFAULT_OUR_SWAP_FEE } from '../../config'; +import { Big } from '../../lib/big.js'; +import { bigintDivideToNumber, bigintMax } from '../bigint'; +import { bigMax, bigMin } from '../bigNumber'; +import { findChainConfig } from '../chain'; +import { fromDecimal, toBig } from '../decimals'; +import { getChainBySlug, getIsNativeToken } from '../tokens'; + +type ExplainSwapFeeInput = Pick & { + /** The balance of the "in" token blockchain's native token. Undefined means that it's unknown. */ + nativeTokenInBalance: bigint | undefined; +}; + +export type ExplainedSwapFee = { + /** Whether the result implies paying the fee with a diesel */ + isGasless: boolean; + /** + * The fee that will be sent with the swap. The wallet must have it on the balance to conduct the swap. + * Show this in the swap form when the input amount is ≤ the balance, but the remaining balance can't cover the + * full fee; show `realFee` otherwise. Undefined means that it's unknown. + */ + fullFee?: { + precision: FeePrecision; + terms: FeeTerms; + /** Only the network fee terms (like `terms` but excluding our fee) */ + networkTerms: FeeTerms; + }; + /** + * The real fee (the full fee minus the excess). Undefined means that it's unknown. There is no need to fall back to + * `fullFee` when `realFee` is undefined (because it's undefined too in this case). + */ + realFee?: { + precision: FeePrecision; + terms: FeeTerms; + /** Only the network fee terms (like `terms` but excluding our fee) */ + networkTerms: FeeTerms; + }; + /** The excess fee. Measured in the native token. It's always approximate. Undefined means that it's unknown. */ + excessFee?: string; + shouldShowOurFee: boolean; +}; + +type MaxSwapAmountInput = Pick & { + /** The balance of the "in" token. Undefined means that it's unknown. */ + tokenInBalance: bigint | undefined; + tokenIn: Pick | undefined; + /** The full network fee terms calculated by `explainSwapFee`. Undefined means that they're unknown. */ + fullNetworkFee: FeeTerms | undefined; +}; + +type BalanceSufficientForSwapInput = Omit & { + /** The wallet balance of the native token of the "in" token chain. Undefined means that it's unknown. */ + nativeTokenInBalance: bigint | undefined; + /** The "in" amount to swap. Undefined means that it's unspecified. */ + amountIn: string | undefined; +}; + +/** + * Converts the swap fee data returned from API into data that is ready to be displayed in the swap form UI. + */ +export function explainSwapFee(input: ExplainSwapFeeInput): ExplainedSwapFee { + return shouldBeGasless(input) + ? explainGaslessSwapFee(input) + : explainGasfullSwapFee(input); +} + +/** + * Calculates the maximum amount available for the swap. + * Returns undefined when it can't be calculated because of insufficient input data. + */ +export function getMaxSwapAmount({ + swapType, + tokenInBalance, + tokenIn, + fullNetworkFee, + ourFeePercent, +}: MaxSwapAmountInput): bigint | undefined { + if (swapType === SwapType.CrosschainToWallet || tokenInBalance === undefined) { + return undefined; + } + + let maxAmount = tokenInBalance; + + // For a better UX, assuming the fee is 0 when it's unknown + if (fullNetworkFee) { + if (!tokenIn) { + return undefined; + } + + maxAmount -= fromDecimal(fullNetworkFee.token ?? '0', tokenIn.decimals); + + if (getIsNativeToken(tokenIn.slug)) { + // When the "in" token is native, both `token` and `native` refer to the same currency, so we consider them both + maxAmount -= fromDecimal(fullNetworkFee.native ?? '0', tokenIn.decimals); + } + } + + ourFeePercent ??= swapType === SwapType.OnChain ? DEFAULT_OUR_SWAP_FEE : 0; + maxAmount = bigintDivideToNumber(maxAmount, 1 + (ourFeePercent / 100)); + + return bigintMax(maxAmount, 0n); +} + +/** + * Decides whether the balance is sufficient to swap the amount and pay the fees. + * Returns undefined when it can't be calculated because of insufficient input data. + */ +export function isBalanceSufficientForSwap(input: BalanceSufficientForSwapInput) { + const { + swapType, + amountIn, + tokenInBalance, + nativeTokenInBalance, + tokenIn, + fullNetworkFee, + } = input; + + if (swapType === SwapType.CrosschainToWallet) { + return true; + } + + if ( + amountIn === undefined + || !tokenIn + || tokenInBalance === undefined + || nativeTokenInBalance === undefined + || !fullNetworkFee + ) { + return undefined; + } + + const nativeTokenIn = findChainConfig(getChainBySlug(tokenIn.slug))?.nativeToken; + if (!nativeTokenIn) { + return undefined; + } + + const maxAmount = getMaxSwapAmount(input); + if (maxAmount === undefined) { + return undefined; + } + + const swapAmountInBigint = fromDecimal(amountIn, tokenIn.decimals); + const networkNativeFeeBigint = fromDecimal(fullNetworkFee.native ?? '0', nativeTokenIn.decimals); + + return swapAmountInBigint <= maxAmount && networkNativeFeeBigint <= nativeTokenInBalance; +} + +function shouldBeGasless(input: ExplainSwapFeeInput) { + const isNativeIn = getIsNativeToken(input.tokenInSlug); + const nativeTokenBalance = getBigNativeTokenInBalance(input); + const isInsufficientNative = input.networkFee !== undefined && nativeTokenBalance !== undefined + && nativeTokenBalance.lt(input.networkFee); + + return ( + input.swapType === SwapType.OnChain + && isInsufficientNative + && !isNativeIn + && input.dieselStatus && input.dieselStatus !== 'not-available' + ); +} + +/** + * Converts the data of a swap not involving diesel + */ +function explainGasfullSwapFee(input: ExplainSwapFeeInput) { + const result: ExplainedSwapFee = { + isGasless: false, + excessFee: getExcessFee(input), + shouldShowOurFee: shouldShowOurFee(input), + }; + + const isNativeIn = getIsNativeToken(input.tokenInSlug); + const isExact = result.excessFee === '0'; + + if (input.networkFee !== undefined) { + const networkTerms = { native: input.networkFee }; + result.fullFee = { + precision: isExact ? 'exact' : 'lessThan', + terms: addOurFeeToTerms(networkTerms, input.ourFee ?? '0', isNativeIn), + networkTerms, + }; + result.realFee = result.fullFee; + } + + if (input.realNetworkFee !== undefined) { + const networkTerms = { native: input.realNetworkFee }; + result.realFee = { + precision: isExact ? 'exact' : 'approximate', + terms: addOurFeeToTerms(networkTerms, input.ourFee ?? '0', isNativeIn), + networkTerms, + }; + } + + return result; +} + +/** + * Converts the diesel of semi-diesel swap data + */ +function explainGaslessSwapFee(input: ExplainSwapFeeInput): ExplainedSwapFee { + const nativeTokenBalance = getBigNativeTokenInBalance(input); + const result: ExplainedSwapFee = { + isGasless: true, + excessFee: getExcessFee(input), + shouldShowOurFee: shouldShowOurFee(input), + }; + + if (input.networkFee === undefined || input.dieselFee === undefined || nativeTokenBalance === undefined) { + return result; + } + + const isExact = result.excessFee === '0'; + const isStarsDiesel = input.dieselStatus === 'stars-fee'; + const dieselKey = isStarsDiesel ? 'stars' : 'token'; + + const networkTerms = { + [dieselKey]: input.dieselFee, + native: nativeTokenBalance.toString(), + }; + result.fullFee = { + precision: isExact ? 'exact' : 'lessThan', + terms: addOurFeeToTerms(networkTerms, input.ourFee ?? '0', false), + networkTerms, + }; + result.realFee = result.fullFee; + + if (input.realNetworkFee !== undefined) { + // We are sure this amount is > 0 because `shouldBeGasless` would return `false` otherwise and this function + // wouldn't be called. + const networkFeeCoveredByDiesel = Big(input.networkFee).sub(nativeTokenBalance); + const realFeeInDiesel = Big(input.dieselFee).div(networkFeeCoveredByDiesel).mul(input.realNetworkFee); + // Cover as much displayed real fee as possible with diesel, because in the excess it will return as the native token. + const dieselRealFee = bigMin(input.dieselFee, realFeeInDiesel); + // Cover the remaining real fee with the native token. + const nativeRealFee = bigMax(0, Big(input.realNetworkFee).sub(networkFeeCoveredByDiesel)); + + const realNetworkTerms = { + [dieselKey]: dieselRealFee.toString(), + native: nativeRealFee.toString(), + }; + result.realFee = { + precision: isExact ? 'exact' : 'approximate', + terms: addOurFeeToTerms(realNetworkTerms, input.ourFee ?? '0', false), + networkTerms: realNetworkTerms, + }; + } + + return result; +} + +function getBigNativeTokenInBalance(input: Pick) { + if (!input.tokenInSlug || input.nativeTokenInBalance === undefined) { + return undefined; + } + + const nativeToken = findChainConfig(getChainBySlug(input.tokenInSlug))?.nativeToken; + return nativeToken ? toBig(input.nativeTokenInBalance, nativeToken.decimals) : undefined; +} + +function getExcessFee({ networkFee, realNetworkFee }: Pick) { + return networkFee !== undefined && realNetworkFee !== undefined + ? Big(networkFee).sub(realNetworkFee).toString() + : undefined; +} + +function addOurFeeToTerms(terms: FeeTerms, ourFee: string, isOurFeeNative: boolean) { + return { + ...terms, + native: isOurFeeNative ? Big(terms.native ?? '0').add(ourFee).toString() : terms.native, + token: isOurFeeNative ? terms.token : Big(terms.token ?? '0').add(ourFee).toString(), + }; +} + +function shouldShowOurFee(input: Pick) { + return input.swapType === SwapType.OnChain; +} diff --git a/src/util/fee/transferFee.ts b/src/util/fee/transferFee.ts index 9f0d853a..36699e79 100644 --- a/src/util/fee/transferFee.ts +++ b/src/util/fee/transferFee.ts @@ -1,18 +1,17 @@ import type { ApiCheckTransactionDraftResult, ApiFetchEstimateDieselResult } from '../../api/chains/ton/types'; -import type { ApiChain } from '../../api/types'; import type { FeePrecision, FeeTerms } from './types'; +import { TONCOIN } from '../../config'; import { Big } from '../../lib/big.js'; import { bigintMax, bigintMin } from '../bigint'; +import { getIsNativeToken } from '../tokens'; type ApiFee = Pick & { - /** Undefined means that the chain is unknown */ - chain: ApiChain | undefined; - /** True if the chain's native token is being transferred, otherwise false */ - isNativeToken: boolean; + /** The slug of the token that is being transferred */ + tokenSlug: string; }; -type ExplainedTransferFee = { +export type ExplainedTransferFee = { /** Whether the result implies paying the fee with a diesel */ isGasless: boolean; /** @@ -22,7 +21,9 @@ type ExplainedTransferFee = { */ fullFee?: { precision: FeePrecision; - terms: FeeTerms; + terms: FeeTerms; + /** The sum of `terms` measured in the native token */ + nativeSum: bigint; }; /** * The real fee (the full fee minus the excess). Undefined means that it's unknown. There is no need to fall back to @@ -30,8 +31,12 @@ type ExplainedTransferFee = { */ realFee?: { precision: FeePrecision; - terms: FeeTerms; + terms: FeeTerms; + /** The sum of `terms` measured in the native token */ + nativeSum: bigint; }; + /** The excess fee. Measured in the native token. It's always approximate. Undefined means that it's unknown. */ + excessFee?: bigint; /** * Whether the full token balance can be transferred despite the fee. * If yes, the fee will be taken from the transferred amount. @@ -45,18 +50,18 @@ type ApiFeeWithDiesel = ApiFee & { diesel: AvailableDiesel }; type MaxTransferAmountInput = { /** The wallet balance of the transferred token. Undefined means that it's unknown. */ tokenBalance: bigint | undefined; - /** True if the chain's native token is being transferred, otherwise false. */ - isNativeToken: boolean; + /** The slug of the token that is being transferred */ + tokenSlug: string; /** The full fee terms calculated by `explainApiTransferFee`. Undefined means that they're unknown. */ - fullFee: FeeTerms | undefined; + fullFee: FeeTerms | undefined; /** Whether the full token balance can be transferred despite the fee. */ canTransferFullBalance: boolean; }; -type BalanceSufficientForTransferInput = Omit & { +type BalanceSufficientForTransferInput = Omit & { /** The wallet balance of the native token of the transfer chain. Undefined means that it's unknown. */ nativeTokenBalance: bigint | undefined; - /** The transferred amount. Undefined means that it's unknown. */ + /** The transferred amount. Use 0 for NFT transfers. Undefined means that it's unspecified. */ transferAmount: bigint | undefined; }; @@ -75,7 +80,7 @@ export function explainApiTransferFee(input: ApiFee): ExplainedTransferFee { */ export function getMaxTransferAmount({ tokenBalance, - isNativeToken, + tokenSlug, fullFee, canTransferFullBalance, }: MaxTransferAmountInput): bigint | undefined { @@ -89,8 +94,8 @@ export function getMaxTransferAmount({ } let fee = fullFee.token ?? 0n; - if (isNativeToken) { - // When `isNativeToken` is true, both `token` and `native` refer to the same currency, so they should be added + if (getIsNativeToken(tokenSlug)) { + // When the token is native, both `token` and `native` refer to the same currency, so they should be added fee += fullFee.native ?? 0n; } @@ -137,13 +142,14 @@ function shouldUseDiesel(input: ApiFee): input is ApiFeeWithDiesel { function explainGasfullTransferFee(input: ApiFee) { const result: ExplainedTransferFee = { isGasless: false, - canTransferFullBalance: input.chain === 'ton' && input.isNativeToken, + canTransferFullBalance: input.tokenSlug === TONCOIN.slug, }; if (input.fee !== undefined) { result.fullFee = { precision: input.realFee === input.fee ? 'exact' : 'lessThan', terms: { native: input.fee }, + nativeSum: input.fee, }; result.realFee = result.fullFee; } @@ -152,9 +158,14 @@ function explainGasfullTransferFee(input: ApiFee) { result.realFee = { precision: input.realFee === input.fee ? 'exact' : 'approximate', terms: { native: input.realFee }, + nativeSum: input.realFee, }; } + if (input.fee !== undefined && input.realFee !== undefined) { + result.excessFee = input.fee - input.realFee; + } + return result; } @@ -179,6 +190,7 @@ function explainGaslessTransferFee({ diesel }: ApiFeeWithDiesel) { [dieselKey]: diesel.amount, native: diesel.remainingFee, }, + nativeSum: diesel.nativeAmount + diesel.remainingFee, }, realFee: { precision: 'approximate', @@ -186,7 +198,9 @@ function explainGaslessTransferFee({ diesel }: ApiFeeWithDiesel) { [dieselKey]: dieselRealFee, native: nativeRealFee, }, + nativeSum: diesel.realFee, }, + excessFee: diesel.nativeAmount + diesel.remainingFee - diesel.realFee, } satisfies ExplainedTransferFee; } diff --git a/src/util/fee/types.ts b/src/util/fee/types.ts index fb10f733..8077acfb 100644 --- a/src/util/fee/types.ts +++ b/src/util/fee/types.ts @@ -1,16 +1,22 @@ +/** + * The BigInt assumes the token amount expressed in the minimal units of that token. The other types assume + * human-readable numbers with decimal point. + */ +export type FeeValue = number | string | bigint; + /** * If any field value is 0, it should be ignored */ -export type FeeTerms = { +export type FeeTerms = { /** The fee part paid in the transferred token */ - token?: bigint; + token?: T; /** The fee part paid in the chain's native token */ - native?: bigint; + native?: T; /** * The fee part paid in stars. * The BigInt assumes 0 decimal places (i.e. the number is equal to the visible number of stars). */ - stars?: bigint; + stars?: T; }; export type FeePrecision = 'exact' | 'approximate' | 'lessThan'; diff --git a/src/util/ledger/index.ts b/src/util/ledger/index.ts index 5c133ddd..6778806b 100644 --- a/src/util/ledger/index.ts +++ b/src/util/ledger/index.ts @@ -360,7 +360,7 @@ export async function submitLedgerStake( accountId: string, amount: bigint, state: ApiStakingState, - fee?: bigint, + realFee?: bigint, ) { const address = await callApi('fetchAddress', accountId, 'ton'); @@ -375,7 +375,7 @@ export async function submitLedgerStake( toAddress: state.pool, amount: amount + TON_GAS.stakeNominators, comment: STAKE_COMMENT, - fee, + realFee, }, TONCOIN.slug, localTransactionParams); break; } @@ -392,6 +392,7 @@ export async function submitLedgerStake( password: '', toAddress: LIQUID_POOL, amount: amount + TON_GAS.stakeLiquid, + realFee, }, TONCOIN.slug, localTransactionParams, payload); break; } @@ -417,6 +418,7 @@ export async function submitLedgerStake( amount, data: StakingPool.stakePayload(period), forwardAmount: TON_GAS.stakeJettonsForward, + realFee, }, TONCOIN.slug, localTransactionParams); break; } @@ -429,7 +431,7 @@ export async function submitLedgerStake( return result; } -export async function submitLedgerUnstake(accountId: string, state: ApiStakingState, amount: bigint) { +export async function submitLedgerUnstake(accountId: string, state: ApiStakingState, amount: bigint, realFee?: bigint) { const { network } = parseAccountId(accountId); const address = (await callApi('fetchAddress', accountId, 'ton'))!; @@ -445,6 +447,7 @@ export async function submitLedgerUnstake(accountId: string, state: ApiStakingSt toAddress: poolAddress, amount: TON_GAS.unstakeNominators, comment: UNSTAKE_COMMENT, + realFee, }, TONCOIN.slug, localTransactionParams); break; } @@ -470,6 +473,7 @@ export async function submitLedgerUnstake(accountId: string, state: ApiStakingSt password: '', toAddress: tokenWalletAddress!, amount: TON_GAS.unstakeLiquid, + realFee, }, TONCOIN.slug, localTransactionParams, payload); break; } @@ -485,6 +489,7 @@ export async function submitLedgerUnstake(accountId: string, state: ApiStakingSt password: '', toAddress: stakeWalletAddress, amount: TON_GAS.unstakeJettons, + realFee, }, TONCOIN.slug, localTransactionParams, payload); break; } @@ -496,7 +501,7 @@ export async function submitLedgerUnstake(accountId: string, state: ApiStakingSt export function submitLedgerStakingClaim( accountId: string, state: ApiJettonStakingState, - fee?: bigint, + realFee?: bigint, ) { const payload: TonPayloadFormat = { type: 'unsafe', @@ -508,7 +513,7 @@ export function submitLedgerStakingClaim( amount: TON_GAS.claimJettons, password: '', toAddress: state.stakeWalletAddress, - fee, + realFee, }, TONCOIN.slug, undefined, payload); } @@ -522,7 +527,7 @@ export async function submitLedgerTransfer( accountId, tokenAddress, comment, - fee, + realFee, data, forwardAmount, } = options; @@ -601,7 +606,7 @@ export async function submitLedgerTransfer( fromAddress: fromAddress!, toAddress: normalizedAddress, comment, - fee: fee!, + fee: realFee ?? 0n, slug, ...localTransactionParams, }, @@ -624,10 +629,10 @@ export async function submitLedgerNftTransfer(options: { toAddress: string; comment?: string; nft?: ApiNft; - fee?: bigint; + realFee?: bigint; }) { const { - accountId, nftAddress, comment, nft, fee, + accountId, nftAddress, comment, nft, realFee, } = options; let { toAddress } = options; const { network } = parseAccountId(accountId); @@ -697,7 +702,7 @@ export async function submitLedgerNftTransfer(options: { fromAddress: fromAddress!, toAddress: options.toAddress, comment, - fee: fee!, + fee: realFee ?? 0n, slug: TONCOIN.slug, type: 'nftTransferred', nft, diff --git a/src/util/resolveModalTransitionName.ts b/src/util/resolveSlideTransitionName.ts similarity index 72% rename from src/util/resolveModalTransitionName.ts rename to src/util/resolveSlideTransitionName.ts index 40c6642b..9389a3b1 100644 --- a/src/util/resolveModalTransitionName.ts +++ b/src/util/resolveSlideTransitionName.ts @@ -1,5 +1,5 @@ import { IS_ANDROID, IS_IOS } from './windowEnvironment'; -export default function resolveModalTransitionName() { +export default function resolveSlideTransitionName() { return IS_ANDROID ? 'slideFadeAndroid' : IS_IOS ? 'slideLayers' : 'slideFade'; } diff --git a/src/util/windowEnvironment.ts b/src/util/windowEnvironment.ts index 57799c31..dab64d06 100644 --- a/src/util/windowEnvironment.ts +++ b/src/util/windowEnvironment.ts @@ -76,3 +76,4 @@ export function setScrollbarWidthProperty() { } export const REM = parseInt(getComputedStyle(document.documentElement).fontSize, 10); +export const STICKY_CARD_INTERSECTION_THRESHOLD = -3.75 * REM; diff --git a/src/util/windowSize.ts b/src/util/windowSize.ts index 4f253556..3671ca61 100644 --- a/src/util/windowSize.ts +++ b/src/util/windowSize.ts @@ -104,6 +104,7 @@ function patchSafeAreaProperty() { // WebKit has issues with this property on page load // https://bugs.webkit.org/show_bug.cgi?id=191872 setTimeout(() => { + currentWindowSize = updateSizes(); const { safeAreaTop, safeAreaBottom } = currentWindowSize; if (!Number.isNaN(safeAreaTop) && safeAreaTop > 0) { diff --git a/tests/init.js b/tests/init.js index 4b32ec89..0968d58e 100644 --- a/tests/init.js +++ b/tests/init.js @@ -54,3 +54,13 @@ Object.defineProperty(global, 'IntersectionObserver', { } }, }); + +Object.defineProperty(global, 'indexedDB', { + writable: true, + configurable: true, + value: { + open() { + return {}; + }, + }, +});