diff --git a/changelogs/3.3.3.txt b/changelogs/3.3.3.txt new file mode 100644 index 00000000..619f4cd5 --- /dev/null +++ b/changelogs/3.3.3.txt @@ -0,0 +1 @@ +Bug fixes and performance improvements diff --git a/jest.config.js b/jest.config.js index eac97690..3f00d9f7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,7 @@ +const babelConfig = require('./babel.config'); + module.exports = { - setupFilesAfterEnv: ['./tests/init.js'], + setupFilesAfterEnv: ['./tests/init.ts'], moduleNameMapper: { '\\.(css|scss|jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|tgs)$': '/tests/staticFileMock.js', @@ -11,10 +13,15 @@ module.exports = { ], testEnvironment: 'jest-environment-jsdom', transform: { - '\\.(jsx?|tsx?)$': 'babel-jest', + '\\.(jsx?|tsx?)$': ['babel-jest', { + ...babelConfig, + plugins: [...babelConfig.plugins, 'babel-plugin-transform-import-meta'], + }], '\\.txt$': 'jest-raw-loader', }, transformIgnorePatterns: [ - '/node_modules/(?!(axios)/)', + '/node_modules/(?!(axios|@capgo)/)', ], + // Fixes https://github.com/jestjs/jest/issues/11617 (expected to be fixed properly in Jest 30.0.0) + maxWorkers: 1, }; diff --git a/mobile/plugins/native-bottom-sheet/README.md b/mobile/plugins/native-bottom-sheet/README.md index 176ca4dc..f2c77808 100644 --- a/mobile/plugins/native-bottom-sheet/README.md +++ b/mobile/plugins/native-bottom-sheet/README.md @@ -26,6 +26,7 @@ npx cap sync * [`closeSelf(...)`](#closeself) * [`toggleSelfFullSize(...)`](#toggleselffullsize) * [`openInMain(...)`](#openinmain) +* [`isShown()`](#isshown) * [`addListener('delegate', ...)`](#addlistenerdelegate) * [`addListener('move', ...)`](#addlistenermove) * [`addListener('openInMain', ...)`](#addlisteneropeninmain) @@ -178,6 +179,17 @@ openInMain(options: { key: BottomSheetKeys; }) => Promise -------------------- +### isShown() + +```typescript +isShown() => Promise<{ value: boolean; }> +``` + +**Returns:** Promise<{ value: boolean; }> + +-------------------- + + ### addListener('delegate', ...) ```typescript @@ -241,6 +253,6 @@ addListener(eventName: 'openInMain', handler: (options: { key: BottomSheetKeys; #### BottomSheetKeys -'initial' | 'receive' | 'invoice' | 'transfer' | 'swap' | 'stake' | 'unstake' | 'staking-info' | 'vesting-info' | 'vesting-confirm' | 'transaction-info' | 'swap-activity' | 'backup' | 'add-account' | 'settings' | 'qr-scanner' | 'dapp-connect' | 'dapp-transfer' | 'disclaimer' | 'backup-warning' | 'onramp-widget' +'initial' | 'receive' | 'invoice' | 'transfer' | 'swap' | 'stake' | 'unstake' | 'staking-info' | 'staking-claim' | 'vesting-info' | 'vesting-confirm' | 'transaction-info' | 'swap-activity' | 'backup' | 'add-account' | 'settings' | 'qr-scanner' | 'dapp-connect' | 'dapp-transfer' | 'disclaimer' | 'backup-warning' | 'onramp-widget' diff --git a/mobile/plugins/native-bottom-sheet/dist/docs.json b/mobile/plugins/native-bottom-sheet/dist/docs.json index 1d60e76d..932141bd 100644 --- a/mobile/plugins/native-bottom-sheet/dist/docs.json +++ b/mobile/plugins/native-bottom-sheet/dist/docs.json @@ -181,6 +181,16 @@ ], "slug": "openinmain" }, + { + "name": "isShown", + "signature": "() => Promise<{ value: boolean; }>", + "parameters": [], + "returns": "Promise<{ value: boolean; }>", + "tags": [], + "docs": "", + "complexTypes": [], + "slug": "isshown" + }, { "name": "addListener", "signature": "(eventName: 'delegate', handler: (options: { key: BottomSheetKeys; globalJson: string; }) => void) => Promise & PluginListenerHandle", @@ -312,6 +322,10 @@ "text": "'staking-info'", "complexTypes": [] }, + { + "text": "'staking-claim'", + "complexTypes": [] + }, { "text": "'vesting-info'", "complexTypes": [] diff --git a/mobile/plugins/native-bottom-sheet/dist/esm/definitions.d.ts b/mobile/plugins/native-bottom-sheet/dist/esm/definitions.d.ts index a7935842..ed9b9d63 100644 --- a/mobile/plugins/native-bottom-sheet/dist/esm/definitions.d.ts +++ b/mobile/plugins/native-bottom-sheet/dist/esm/definitions.d.ts @@ -1,4 +1,4 @@ -import { PluginListenerHandle } from '@capacitor/core'; +import type { PluginListenerHandle } from '@capacitor/core'; export declare type BottomSheetKeys = 'initial' | 'receive' | 'invoice' | 'transfer' | 'swap' | 'stake' | 'unstake' | 'staking-info' | 'staking-claim' | 'vesting-info' | 'vesting-confirm' | 'transaction-info' | 'swap-activity' | 'backup' | 'add-account' | 'settings' | 'qr-scanner' | 'dapp-connect' | 'dapp-transfer' | 'disclaimer' | 'backup-warning' | 'onramp-widget'; export interface BottomSheetPlugin { prepare(): Promise; @@ -29,6 +29,9 @@ export interface BottomSheetPlugin { openInMain(options: { key: BottomSheetKeys; }): Promise; + isShown(): Promise<{ + value: boolean; + }>; addListener(eventName: 'delegate', handler: (options: { key: BottomSheetKeys; globalJson: string; diff --git a/mobile/plugins/native-bottom-sheet/dist/esm/definitions.js.map b/mobile/plugins/native-bottom-sheet/dist/esm/definitions.js.map index 847e234b..b7fc7ef3 100644 --- a/mobile/plugins/native-bottom-sheet/dist/esm/definitions.js.map +++ b/mobile/plugins/native-bottom-sheet/dist/esm/definitions.js.map @@ -1 +1 @@ -{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["import { PluginListenerHandle } from '@capacitor/core';\n\nexport type BottomSheetKeys =\n 'initial'\n | 'receive'\n | 'invoice'\n | 'transfer'\n | 'swap'\n | 'stake'\n | 'unstake'\n | 'staking-info'\n | 'vesting-info'\n | 'vesting-confirm'\n | 'transaction-info'\n | 'swap-activity'\n | 'backup'\n | 'add-account'\n | 'settings'\n | 'qr-scanner'\n | 'dapp-connect'\n | 'dapp-transfer'\n | 'disclaimer'\n | 'backup-warning'\n | 'onramp-widget';\n\nexport interface BottomSheetPlugin {\n prepare(): Promise;\n\n applyScrollPatch(): Promise;\n\n clearScrollPatch(): Promise;\n\n disable(): Promise;\n\n enable(): Promise;\n\n hide(): Promise;\n\n show(): Promise;\n\n delegate(options: { key: BottomSheetKeys, globalJson: string }): Promise;\n\n release(options: { key: BottomSheetKeys | '*' }): Promise;\n\n openSelf(options: { key: BottomSheetKeys, height: string, backgroundColor: string }): Promise;\n\n closeSelf(options: { key: BottomSheetKeys }): Promise;\n\n toggleSelfFullSize(options: { isFullSize: boolean }): Promise;\n\n openInMain(options: { key: BottomSheetKeys }): Promise;\n\n addListener(\n eventName: 'delegate',\n handler: (options: { key: BottomSheetKeys, globalJson: string }) => void,\n ): Promise & PluginListenerHandle;\n\n addListener(\n eventName: 'move',\n handler: () => void,\n ): Promise & PluginListenerHandle;\n\n\n addListener(\n eventName: 'openInMain',\n handler: (options: { key: BottomSheetKeys }) => void,\n ): Promise & PluginListenerHandle;\n}\n"]} \ No newline at end of file +{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["import type { PluginListenerHandle } from '@capacitor/core';\n\nexport type BottomSheetKeys =\n 'initial'\n | 'receive'\n | 'invoice'\n | 'transfer'\n | 'swap'\n | 'stake'\n | 'unstake'\n | 'staking-info'\n | 'staking-claim'\n | 'vesting-info'\n | 'vesting-confirm'\n | 'transaction-info'\n | 'swap-activity'\n | 'backup'\n | 'add-account'\n | 'settings'\n | 'qr-scanner'\n | 'dapp-connect'\n | 'dapp-transfer'\n | 'disclaimer'\n | 'backup-warning'\n | 'onramp-widget';\n\nexport interface BottomSheetPlugin {\n prepare(): Promise;\n\n applyScrollPatch(): Promise;\n\n clearScrollPatch(): Promise;\n\n disable(): Promise;\n\n enable(): Promise;\n\n hide(): Promise;\n\n show(): Promise;\n\n delegate(options: { key: BottomSheetKeys, globalJson: string }): Promise;\n\n release(options: { key: BottomSheetKeys | '*' }): Promise;\n\n openSelf(options: { key: BottomSheetKeys, height: string, backgroundColor: string }): Promise;\n\n closeSelf(options: { key: BottomSheetKeys }): Promise;\n\n toggleSelfFullSize(options: { isFullSize: boolean }): Promise;\n\n openInMain(options: { key: BottomSheetKeys }): Promise;\n\n isShown(): Promise<{ value: boolean }>;\n\n addListener(\n eventName: 'delegate',\n handler: (options: { key: BottomSheetKeys, globalJson: string }) => void,\n ): Promise & PluginListenerHandle;\n\n addListener(\n eventName: 'move',\n handler: () => void,\n ): Promise & PluginListenerHandle;\n\n\n addListener(\n eventName: 'openInMain',\n handler: (options: { key: BottomSheetKeys }) => void,\n ): Promise & PluginListenerHandle;\n}\n"]} \ No newline at end of file diff --git a/mobile/plugins/native-bottom-sheet/ios/Plugin/BottomSheetPlugin.m b/mobile/plugins/native-bottom-sheet/ios/Plugin/BottomSheetPlugin.m index eacb45e5..ef04c266 100644 --- a/mobile/plugins/native-bottom-sheet/ios/Plugin/BottomSheetPlugin.m +++ b/mobile/plugins/native-bottom-sheet/ios/Plugin/BottomSheetPlugin.m @@ -17,4 +17,5 @@ CAP_PLUGIN_METHOD(enable, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(hide, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(show, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(isShown, CAPPluginReturnPromise); ) diff --git a/mobile/plugins/native-bottom-sheet/ios/Plugin/BottomSheetPlugin.swift b/mobile/plugins/native-bottom-sheet/ios/Plugin/BottomSheetPlugin.swift index b0297c3b..1943e802 100644 --- a/mobile/plugins/native-bottom-sheet/ios/Plugin/BottomSheetPlugin.swift +++ b/mobile/plugins/native-bottom-sheet/ios/Plugin/BottomSheetPlugin.swift @@ -289,6 +289,10 @@ public class BottomSheetPlugin: CAPPlugin, FloatingPanelControllerDelegate { } } + @objc func isShown(_ call: CAPPluginCall) { + call.resolve(["value": fpc.state != .hidden]) + } + // Extra security level, potentially redundant private func ensureLocalOrigin() { DispatchQueue.main.sync { [self] in diff --git a/mobile/plugins/native-bottom-sheet/src/definitions.ts b/mobile/plugins/native-bottom-sheet/src/definitions.ts index af6befae..6f6a8a5c 100644 --- a/mobile/plugins/native-bottom-sheet/src/definitions.ts +++ b/mobile/plugins/native-bottom-sheet/src/definitions.ts @@ -1,4 +1,4 @@ -import { PluginListenerHandle } from '@capacitor/core'; +import type { PluginListenerHandle } from '@capacitor/core'; export type BottomSheetKeys = 'initial' @@ -9,6 +9,7 @@ export type BottomSheetKeys = | 'stake' | 'unstake' | 'staking-info' + | 'staking-claim' | 'vesting-info' | 'vesting-confirm' | 'transaction-info' @@ -50,6 +51,8 @@ export interface BottomSheetPlugin { openInMain(options: { key: BottomSheetKeys }): Promise; + isShown(): Promise<{ value: boolean }>; + addListener( eventName: 'delegate', handler: (options: { key: BottomSheetKeys, globalJson: string }) => void, diff --git a/package-lock.json b/package-lock.json index ab6c54c0..d4e4a4d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mytonwallet", - "version": "3.3.2", + "version": "3.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mytonwallet", - "version": "3.3.2", + "version": "3.3.3", "license": "GPL-3.0-or-later", "dependencies": { "@awesome-cordova-plugins/core": "6.9.0", @@ -95,6 +95,7 @@ "@webpack-cli/serve": "2.0.5", "autoprefixer": "10.4.20", "babel-loader": "9.2.1", + "babel-plugin-transform-import-meta": "2.3.2", "before-build-webpack": "0.2.15", "browserlist": "1.0.1", "concurrently": "7.6.0", @@ -743,11 +744,14 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.25.7", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/highlight": "^7.25.7", + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", "picocolors": "^1.0.0" }, "engines": { @@ -1088,7 +1092,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.7", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "dev": true, "license": "MIT", "engines": { @@ -1096,7 +1102,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.7", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "dev": true, "license": "MIT", "engines": { @@ -1136,26 +1144,14 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/highlight": { - "version": "7.25.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.25.7", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/parser": { - "version": "7.25.7", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz", + "integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.25.7" + "@babel/types": "^7.26.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -2821,13 +2817,15 @@ } }, "node_modules/@babel/template": { - "version": "7.25.7", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", + "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.7", - "@babel/parser": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2851,13 +2849,14 @@ } }, "node_modules/@babel/types": { - "version": "7.25.7", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.7.tgz", + "integrity": "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.7", - "@babel/helper-validator-identifier": "^7.25.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -8306,6 +8305,20 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/babel-plugin-transform-import-meta": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-import-meta/-/babel-plugin-transform-import-meta-2.3.2.tgz", + "integrity": "sha512-902o4GiQqI1GqAXfD5rEoz0PJamUfJ3VllpdWaNsFTwdaNjFSFHawvBO+cp5K2j+g2h3bZ4lnM1Xb6yFYGihtA==", + "dev": true, + "license": "BSD", + "dependencies": { + "@babel/template": "^7.25.9", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "@babel/core": "^7.10.0" + } + }, "node_modules/babel-plugin-transform-react-remove-prop-types": { "version": "0.4.24", "dev": true, @@ -24126,14 +24139,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "dev": true, @@ -24316,7 +24321,9 @@ } }, "node_modules/tslib": { - "version": "2.6.2", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, "node_modules/tsutils": { diff --git a/package.json b/package.json index 0ceaf7d6..5cab53be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mytonwallet", - "version": "3.3.2", + "version": "3.3.3", "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": { @@ -120,6 +120,7 @@ "@webpack-cli/serve": "2.0.5", "autoprefixer": "10.4.20", "babel-loader": "9.2.1", + "babel-plugin-transform-import-meta": "2.3.2", "before-build-webpack": "0.2.15", "browserlist": "1.0.1", "concurrently": "7.6.0", diff --git a/public/version.txt b/public/version.txt index 47725433..619b5376 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -3.3.2 +3.3.3 diff --git a/src/api/chains/ton/swap.ts b/src/api/chains/ton/swap.ts index 6b074d28..53763590 100644 --- a/src/api/chains/ton/swap.ts +++ b/src/api/chains/ton/swap.ts @@ -16,6 +16,7 @@ import { assert } from '../../../util/assert'; import { fromDecimal } from '../../../util/decimals'; import { logDebugError } from '../../../util/logs'; import { pause } from '../../../util/schedulers'; +import { isTokenTransferPayload } from '../../../util/ton/transfer'; import { parseTxId } from './util'; import { parsePayloadBase64 } from './util/metadata'; import { resolveTokenWalletAddress, toBase64Address } from './util/tonCore'; @@ -77,7 +78,7 @@ export async function validateDexSwapTransfers( assert(mainTransfer.toAddress === token.tokenAddress); } else { assert(mainTransfer.toAddress === walletAddress); - assert(['tokens:transfer', 'tokens:transfer-non-standard'].includes(parsedPayload.type)); + assert(isTokenTransferPayload(parsedPayload)); ({ amount: tokenAmount, destination } = parsedPayload as ApiTokensTransferPayload); assert(tokenAmount <= maxAmount); @@ -94,7 +95,7 @@ export async function validateDexSwapTransfers( assert(feeTransfer.amount + mainTransfer.amount < maxTonAmount); assert(feeTransfer.toAddress === walletAddress); - assert(['tokens:transfer', 'tokens:transfer-non-standard'].includes(feePayload.type)); + assert(isTokenTransferPayload(feePayload)); const { amount: tokenFeeAmount, destination: feeDestination } = feePayload as ApiTokensTransferPayload; diff --git a/src/api/chains/ton/util/apiV3.ts b/src/api/chains/ton/util/apiV3.ts index 6e6f5818..1ecac0be 100644 --- a/src/api/chains/ton/util/apiV3.ts +++ b/src/api/chains/ton/util/apiV3.ts @@ -102,7 +102,7 @@ function parseRawTransaction(network: ApiNetwork, rawTx: any, addressBook: Addre now, lt, hash, - total_fees: fee, + total_fees: totalFees, description: { compute_ph: { exit_code: exitCode, @@ -118,11 +118,16 @@ function parseRawTransaction(network: ApiNetwork, rawTx: any, addressBook: Addre if (!msgs.length) return []; + const oneMsgFee = BigInt(totalFees) / BigInt(msgs.length); + return msgs.map((msg, i) => { - const { source, destination, value } = msg; + const { + source, destination, value, fwd_fee: fwdFee, + } = msg; const fromAddress = addressBook[source].user_friendly; const toAddress = addressBook[destination].user_friendly; const normalizedAddress = toBase64Address(isIncoming ? source : destination, true, network); + const fee = oneMsgFee + BigInt(fwdFee ?? 0); return omitUndefined({ txId: msgs.length > 1 ? `${txId}:${i + 1}` : txId, @@ -132,7 +137,7 @@ function parseRawTransaction(network: ApiNetwork, rawTx: any, addressBook: Addre toAddress, amount: isIncoming ? BigInt(value) : -BigInt(value), slug: TONCOIN.slug, - fee: BigInt(fee), + fee, inMsgHash, normalizedAddress, shouldHide: exitCode ? true : undefined, diff --git a/src/api/common/other.ts b/src/api/common/other.ts index 5be53a35..16d71062 100644 --- a/src/api/common/other.ts +++ b/src/api/common/other.ts @@ -5,8 +5,13 @@ let clientId: string | undefined; export async function getClientId() { clientId = await storage.getItem('clientId'); + if (!clientId) { clientId = Buffer.from(randomBytes(10)).toString('hex'); + + const referrer = await storage.getItem('referrer'); + if (referrer) clientId = `${clientId}:${referrer}`; + await storage.setItem('clientId', clientId); } return clientId; diff --git a/src/api/methods/init.ts b/src/api/methods/init.ts index ef058eef..f66ca16a 100644 --- a/src/api/methods/init.ts +++ b/src/api/methods/init.ts @@ -6,6 +6,7 @@ import chains from '../chains'; import { connectUpdater, startStorageMigration } from '../common/helpers'; import { setEnvironment } from '../environment'; import { addHooks } from '../hooks'; +import { storage } from '../storages'; import * as tonConnect from '../tonConnect'; import * as tonConnectSse from '../tonConnect/sse'; import * as methods from '.'; @@ -44,4 +45,8 @@ export default async function init(onUpdate: OnApiUpdate, args: ApiInitArgs) { if (environment.isSseSupported) { void tonConnectSse.resetupSseConnection(); } + + if (args.referrer && !(await storage.getItem('referrer'))) { + void storage.setItem('referrer', args.referrer); + } } diff --git a/src/api/storages/types.ts b/src/api/storages/types.ts index 35c5d3c3..0eb7dc87 100644 --- a/src/api/storages/types.ts +++ b/src/api/storages/types.ts @@ -26,6 +26,7 @@ export type StorageKey = 'accounts' | 'currentAccountId' | 'clientId' | 'baseCurrency' +| 'referrer' // For extension | 'dapps' | 'dappMethods:lastAccountId' diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 288b7dfa..d9b31656 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -17,6 +17,7 @@ export interface ApiInitArgs { isNativeBottomSheet: boolean; isIosApp: boolean; isAndroidApp: boolean; + referrer?: string; } export interface ApiToken { diff --git a/src/components/AppLocked.tsx b/src/components/AppLocked.tsx index d09dc5f5..18ea141a 100644 --- a/src/components/AppLocked.tsx +++ b/src/components/AppLocked.tsx @@ -12,8 +12,8 @@ import { import { selectIsHardwareAccount } from '../global/selectors'; import buildClassName from '../util/buildClassName'; import { vibrateOnSuccess } from '../util/capacitor'; +import { stopEvent } from '../util/domEvents'; import { createSignal } from '../util/signals'; -import stopEvent from '../util/stopEvent'; import { IS_DELEGATED_BOTTOM_SHEET, IS_DELEGATING_BOTTOM_SHEET, IS_ELECTRON } from '../util/windowEnvironment'; import { callApi } from '../api'; @@ -98,11 +98,22 @@ function AppLocked({ ); const [passwordError, setPasswordError] = useState(''); + const handleActivity = useLastCallback(() => { + if (IS_DELEGATED_BOTTOM_SHEET) { + submitAppLockActivityEvent(); + return; + } + lastActivityTime.current = Date.now(); + }); + + const handleActivityThrottled = useThrottledCallback(handleActivity, [handleActivity], WINDOW_EVENTS_LATENCY); + const afterUnlockCallback = useLastCallback(() => { hideUi(); setSlideForBiometricAuth(SLIDES.button); getInAppBrowser()?.show(); clearIsPinAccepted(); + handleActivity(); setIsManualLockActive({ isActive: undefined, shouldHideBiometrics: undefined }); if (IS_DELEGATING_BOTTOM_SHEET) void BottomSheet.show(); }); @@ -150,16 +161,6 @@ function AppLocked({ const handlePasswordChange = useLastCallback(() => setPasswordError('')); - const handleActivity = useLastCallback(() => { - if (IS_DELEGATED_BOTTOM_SHEET) { - submitAppLockActivityEvent(); - return; - } - lastActivityTime.current = Date.now(); - }); - - const handleActivityThrottled = useThrottledCallback(handleActivity, [handleActivity], WINDOW_EVENTS_LATENCY); - useEffectOnce(() => { for (const eventName of ACTIVATION_EVENT_NAMES) { window.addEventListener(eventName, handleActivityThrottled, ACTIVATION_EVENT_OPTIONS); diff --git a/src/components/common/TokenSelector.module.scss b/src/components/common/TokenSelector.module.scss index d9bab809..2354db95 100644 --- a/src/components/common/TokenSelector.module.scss +++ b/src/components/common/TokenSelector.module.scss @@ -248,7 +248,7 @@ } .tokenPriceContainer { - align-items: flex-end; + text-align: end; } .tokenName, diff --git a/src/components/common/TokenSelector.tsx b/src/components/common/TokenSelector.tsx index f56c8195..6fe80d5b 100644 --- a/src/components/common/TokenSelector.tsx +++ b/src/components/common/TokenSelector.tsx @@ -9,7 +9,7 @@ import React, { } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; -import type { ApiBaseCurrency } from '../../api/types'; +import type { ApiBaseCurrency, ApiChain } from '../../api/types'; import { type AssetPairs, SettingsState, type UserSwapToken, type UserToken, } from '../../global/types'; @@ -19,6 +19,7 @@ import { } from '../../config'; import { selectAvailableUserForSwapTokens, + selectCurrentAccount, selectIsMultichainAccount, selectPopularTokens, selectSwapTokens, @@ -64,6 +65,7 @@ interface StateProps { baseCurrency?: ApiBaseCurrency; isLoading?: boolean; isMultichain: boolean; + availableChains?: { [K in ApiChain]?: unknown }; } interface OwnProps { @@ -84,13 +86,14 @@ enum SearchState { Empty, } -const EMPTY_ARRAY: Token[] = []; +const EMPTY_ARRAY: never[] = []; +const EMPTY_OBJECT = {}; function TokenSelector({ token, - userTokens, - swapTokens, - popularTokens: popularTokensProp, + userTokens = EMPTY_ARRAY, + swapTokens = EMPTY_ARRAY, + popularTokens: popularTokensProp = EMPTY_ARRAY, shouldFilter, isInsideSettings, baseCurrency, @@ -101,8 +104,9 @@ function TokenSelector({ onBack, onClose, shouldHideMyTokens, - shouldHideNotSupportedTokens, + shouldHideNotSupportedTokens = false, isMultichain, + availableChains = EMPTY_OBJECT, }: OwnProps & StateProps) { const { importToken, @@ -152,47 +156,34 @@ function TokenSelector({ return filterAndSortTokens(tokens, isMultichain, tokenInSlug, pairsBySlug); }, [pairsBySlug, tokenInSlug, isMultichain]); - const allUnimportedTonTokens = useMemo(() => { - return (swapTokens ?? EMPTY_ARRAY).filter( - (popularToken) => 'chain' in popularToken && popularToken.chain === 'ton', - ); - }, [swapTokens]); - - const popularTokens = useMemo(() => { - if (shouldHideNotSupportedTokens) { - return popularTokensProp?.filter( - (popularToken) => 'chain' in popularToken && popularToken.chain === 'ton', - ); - } + const allTokens = useMemo( + () => filterSupportedTokens(swapTokens, availableChains, shouldHideNotSupportedTokens), + [swapTokens, shouldHideNotSupportedTokens, availableChains], + ); - return popularTokensProp; - }, [popularTokensProp, shouldHideNotSupportedTokens]); + const popularTokens = useMemo( + () => filterSupportedTokens(popularTokensProp, availableChains, shouldHideNotSupportedTokens), + [popularTokensProp, shouldHideNotSupportedTokens, availableChains], + ); const { userTokensWithFilter, popularTokensWithFilter, swapTokensWithFilter } = useMemo(() => { - const currentUserTokens = userTokens ?? EMPTY_ARRAY; - const currentSwapTokens = swapTokens ?? EMPTY_ARRAY; - const currentPopularTokens = popularTokens ?? EMPTY_ARRAY; if (!shouldFilter) { return { - userTokensWithFilter: currentUserTokens, - popularTokensWithFilter: currentPopularTokens, - swapTokensWithFilter: currentSwapTokens, + userTokensWithFilter: userTokens, + popularTokensWithFilter: popularTokens, + swapTokensWithFilter: swapTokens, }; } - const filteredPopularTokens = filterTokens(currentPopularTokens); - const filteredUserTokens = filterTokens(currentUserTokens); - const filteredSwapTokens = filterTokens(currentSwapTokens); - return { - userTokensWithFilter: filteredUserTokens, - popularTokensWithFilter: filteredPopularTokens, - swapTokensWithFilter: filteredSwapTokens, + userTokensWithFilter: filterTokens(userTokens), + popularTokensWithFilter: filterTokens(popularTokens), + swapTokensWithFilter: filterTokens(swapTokens), }; }, [filterTokens, popularTokens, shouldFilter, swapTokens, userTokens]); const filteredTokenList = useMemo(() => { - const tokensToFilter = isInsideSettings ? allUnimportedTonTokens : swapTokensWithFilter; + const tokensToFilter = isInsideSettings ? allTokens : swapTokensWithFilter; const untrimmedSearchValue = searchValue.toLowerCase(); const lowerCaseSearchValue = untrimmedSearchValue.trim(); @@ -258,7 +249,7 @@ function TokenSelector({ return Number(b.amount - a.amount); }); - }, [allUnimportedTonTokens, isInsideSettings, searchValue, swapTokensWithFilter]); + }, [allTokens, isInsideSettings, searchValue, swapTokensWithFilter]); const resetSearch = () => { setSearchValue(''); @@ -356,11 +347,9 @@ function TokenSelector({ } function renderToken(currentToken: Token) { - const blockchain = 'chain' in currentToken ? currentToken.chain : 'ton'; - const isAvailable = !shouldFilter || currentToken.canSwap; const descriptionText = isAvailable - ? getChainNetworkName(blockchain) + ? getChainNetworkName(currentToken.chain) : lang('Unavailable'); const handleClick = isAvailable ? () => handleTokenClick(currentToken) : undefined; @@ -560,6 +549,7 @@ export default memo(withGlobal((global): StateProps => { const popularTokens = selectPopularTokens(global); const swapTokens = selectSwapTokens(global); const isMultichain = selectIsMultichainAccount(global, global.currentAccountId!); + const availableChains = selectCurrentAccount(global)?.addressByChain; return { baseCurrency, @@ -571,6 +561,7 @@ export default memo(withGlobal((global): StateProps => { popularTokens, swapTokens, isMultichain, + availableChains, }; })(TokenSelector)); @@ -601,3 +592,14 @@ function compareTokens(a: TokenSortFactors, b: TokenSortFactors) { } return b.nameMatchLength - a.nameMatchLength; } + +function filterSupportedTokens( + tokens: T[], + availableChains: { [K in ApiChain]?: unknown }, + isFilterActive: boolean, +): T[] { + if (!isFilterActive) { + return tokens; + } + return tokens.filter((token) => token.chain in availableChains); +} diff --git a/src/components/dapps/DappLedgerWarning.tsx b/src/components/dapps/DappLedgerWarning.tsx index 3c98ef79..af262576 100644 --- a/src/components/dapps/DappLedgerWarning.tsx +++ b/src/components/dapps/DappLedgerWarning.tsx @@ -4,10 +4,11 @@ import React, { import { getActions, withGlobal } from '../../global'; import type { ApiDapp } from '../../api/types'; -import { type Account, TransferState, type UserToken } from '../../global/types'; +import { type Account, TransferState } from '../../global/types'; +import { TONCOIN } from '../../config'; import renderText from '../../global/helpers/renderText'; -import { selectNetworkAccounts } from '../../global/selectors'; +import { selectCurrentToncoinBalance, selectNetworkAccounts } from '../../global/selectors'; import { toDecimal } from '../../util/decimals'; import { formatCurrency } from '../../util/formatNumber'; @@ -20,20 +21,17 @@ import DappInfo from './DappInfo'; import modalStyles from '../ui/Modal.module.scss'; import styles from './Dapp.module.scss'; -interface OwnProps { - tonToken: UserToken; -} - interface StateProps { currentAccount?: Account; + toncoinBalance: bigint; dapp?: ApiDapp; } function DappLedgerWarning({ - tonToken, currentAccount, + toncoinBalance, dapp, -}: OwnProps & StateProps) { +}: StateProps) { const { cancelDappTransfer, setDappTransferScreen } = getActions(); const lang = useLang(); @@ -47,7 +45,7 @@ function DappLedgerWarning({
{currentAccount?.title}
-
{formatCurrency(toDecimal(tonToken.amount), tonToken.symbol)}
+
{formatCurrency(toDecimal(toncoinBalance), TONCOIN.symbol)}
((global): StateProps => { +export default memo(withGlobal((global): StateProps => { const { dapp } = global.currentDappTransfer; const accounts = selectNetworkAccounts(global); return { currentAccount: accounts?.[global.currentAccountId!], + toncoinBalance: selectCurrentToncoinBalance(global), dapp, }; })(DappLedgerWarning)); diff --git a/src/components/dapps/DappTransfer.tsx b/src/components/dapps/DappTransfer.tsx index e7e68287..f24fa5c2 100644 --- a/src/components/dapps/DappTransfer.tsx +++ b/src/components/dapps/DappTransfer.tsx @@ -1,80 +1,103 @@ import React, { memo } from '../../lib/teact/teact'; -import type { ApiDappTransfer, ApiParsedPayload } from '../../api/types'; -import type { UserToken } from '../../global/types'; +import type { ApiToken } from '../../api/types'; +import type { ExtendedDappTransfer } from '../../global/types'; import { TONCOIN } from '../../config'; import { BIGINT_PREFIX } from '../../util/bigint'; import buildClassName from '../../util/buildClassName'; import { toDecimal } from '../../util/decimals'; -import { formatCurrency, formatCurrencySimple } from '../../util/formatNumber'; -import { DEFAULT_DECIMALS, NFT_TRANSFER_AMOUNT } from '../../api/chains/ton/constants'; +import { formatCurrency } from '../../util/formatNumber'; +import { getDappTransferActualToAddress, isNftTransferPayload, isTokenTransferPayload } from '../../util/ton/transfer'; +import { DEFAULT_DECIMALS } from '../../api/chains/ton/constants'; import useFlag from '../../hooks/useFlag'; import useLang from '../../hooks/useLang'; +import NftInfo from '../transfer/NftInfo'; import AmountWithFeeTextField from '../ui/AmountWithFeeTextField'; +import Fee from '../ui/Fee'; import InteractiveTextField from '../ui/InteractiveTextField'; import styles from './Dapp.module.scss'; interface OwnProps { - tonToken: UserToken; - transaction: ApiDappTransfer; - fee?: bigint; - tokens?: UserToken[]; + transaction: ExtendedDappTransfer; + tokensBySlug: Record; } const FRACTION_DIGITS = 2; -function DappTransfer({ - tonToken, - transaction, - fee, - tokens, -}: OwnProps) { +function DappTransfer({ transaction, tokensBySlug }: OwnProps) { const lang = useLang(); const [isPayloadExpanded, expandPayload] = useFlag(false); - const isNftTransfer = transaction.payload?.type === 'nft:transfer'; - const shouldRenderPayloadWarning = transaction.payload?.type === 'unknown'; + const tonAmount = transaction.amount + (transaction.fee ?? 0n); + + function renderAmount() { + if (isNftTransferPayload(transaction.payload)) { + return ( + <> +
{lang('Fee')}
+
+ +
+ + ); + } + + if (isTokenTransferPayload(transaction.payload)) { + const { slug: tokenSlug, amount: tokenAmount } = transaction.payload; + const token = tokensBySlug[tokenSlug]; + const decimals = token?.decimals ?? DEFAULT_DECIMALS; + const symbol = token?.symbol ?? ''; + + return ( + } + className={styles.dataField} + labelClassName={styles.label} + /> + ); + } - function renderFeeForNft() { return ( - <> -
{lang('Fee')}
-
- ≈ {formatCurrencySimple(NFT_TRANSFER_AMOUNT + (fee ?? 0n), '')} - {TONCOIN.symbol} -
- + + : undefined + } + className={styles.dataField} + labelClassName={styles.label} + /> ); } - function renderPayload(payload: ApiParsedPayload, rawPayload?: string) { + function renderPayload() { + const { payload, rawPayload } = transaction; + + if (!payload || isNftTransferPayload(payload) || isTokenTransferPayload(payload)) { + return undefined; + } + switch (payload.type) { case 'comment': return payload.comment; - case 'tokens:transfer-non-standard': - case 'tokens:transfer': { - const { - slug: tokenSlug, - amount: tokenAmount, - destination, - } = payload; - const token = tokens?.find(({ slug }) => slug === tokenSlug); - const decimals = token?.decimals ?? DEFAULT_DECIMALS; - const symbol = token?.symbol ?? ''; - - return lang('$dapp_transfer_tokens_payload', { - amount: formatCurrency(toDecimal(tokenAmount, decimals), symbol, FRACTION_DIGITS), - address: destination, - }); - } - case 'tokens:burn': { const { slug: tokenSlug, amount } = payload; - const token = tokens?.find(({ slug }) => slug === tokenSlug); + const token = tokensBySlug[tokenSlug]; const decimals = token?.decimals ?? DEFAULT_DECIMALS; const symbol = token?.symbol ?? ''; @@ -150,8 +173,8 @@ function DappTransfer({ } } - function renderPayloadLabel(payload: ApiParsedPayload) { - switch (payload.type) { + function renderPayloadLabel() { + switch (transaction.payload?.type) { case 'comment': return lang('Comment'); case 'unknown': @@ -161,39 +184,33 @@ function DappTransfer({ } } + const payloadElement = renderPayload(); + return ( <> + {isNftTransferPayload(transaction.payload) && }

{lang('Receiving Address')}

- {isNftTransfer ? renderFeeForNft() : ( - - )} + {renderAmount()} - {transaction.payload && !isNftTransfer && ( + {payloadElement && ( <> -

{renderPayloadLabel(transaction.payload)}

+

{renderPayloadLabel()}

- {renderPayload(transaction.payload, transaction.rawPayload)} + {payloadElement} {!isPayloadExpanded && (
{lang('View')}
)}
- {shouldRenderPayloadWarning && ( + {transaction.isDangerous && (
{lang('$hardware_payload_warning')}
)} diff --git a/src/components/dapps/DappTransferInitial.tsx b/src/components/dapps/DappTransferInitial.tsx index 80fe7ef1..7577ec27 100644 --- a/src/components/dapps/DappTransferInitial.tsx +++ b/src/components/dapps/DappTransferInitial.tsx @@ -4,23 +4,27 @@ import React, { } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; -import type { ApiBaseCurrency, ApiDapp, ApiDappTransfer } from '../../api/types'; -import type { Account, UserToken } from '../../global/types'; - -import { SHORT_FRACTION_DIGITS } from '../../config'; -import { Big } from '../../lib/big.js'; -import { selectCurrentAccountTokens, selectNetworkAccounts } from '../../global/selectors'; +import type { ApiBaseCurrency, ApiDapp, ApiToken } from '../../api/types'; +import type { Account, ExtendedDappTransfer } from '../../global/types'; +import type { Big } from '../../lib/big.js'; + +import { TONCOIN } from '../../config'; +import { + selectCurrentDappTransferExtendedTransactions, + selectCurrentDappTransferTotalAmounts, + selectCurrentToncoinBalance, + selectNetworkAccounts, +} from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import { toDecimal } from '../../util/decimals'; import { formatCurrency, getShortCurrencySymbol } from '../../util/formatNumber'; import { shortenAddress } from '../../util/shortenAddress'; +import { getDappTransferActualToAddress, isNftTransferPayload, isTokenTransferPayload } from '../../util/ton/transfer'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useLang from '../../hooks/useLang'; -import NftInfo from '../transfer/NftInfo'; import Button from '../ui/Button'; -import InteractiveTextField from '../ui/InteractiveTextField'; import DappInfo from './DappInfo'; import DappTransfer from './DappTransfer'; @@ -30,86 +34,48 @@ import styles from './Dapp.module.scss'; import scamImg from '../../assets/scam.svg'; interface OwnProps { - tonToken: UserToken; onClose?: NoneToVoidFunction; } interface StateProps { currentAccount?: Account; - transactions?: ApiDappTransfer[]; - fee?: bigint; + toncoinBalance: bigint; + transactions?: ExtendedDappTransfer[]; + totalAmountsBySlug: Record; + totalCost: number; dapp?: ApiDapp; isLoading?: boolean; - tokens?: UserToken[]; + tokensBySlug: Record; baseCurrency?: ApiBaseCurrency; } function DappTransferInitial({ - tonToken, currentAccount, + toncoinBalance, transactions, - fee, + totalAmountsBySlug, + totalCost, dapp, isLoading, - tokens, + tokensBySlug, onClose, baseCurrency, }: OwnProps & StateProps) { const { showDappTransfer, submitDappTransferConfirm } = getActions(); - const shortBaseSymbol = getShortCurrencySymbol(baseCurrency); const lang = useLang(); const isSingleTransaction = transactions?.length === 1; const renderingTransactions = useCurrentOrPrev(transactions, true); - const isNftTransfer = renderingTransactions?.[0].payload?.type === 'nft:transfer'; - const nft = isNftTransfer && 'nft' in renderingTransactions![0].payload! - ? renderingTransactions[0].payload.nft - : undefined; const hasScamAddresses = useMemo(() => { return renderingTransactions?.some(({ isScam }) => isScam); }, [renderingTransactions]); - const totalAmountText = useMemo(() => { - const feeDecimal = fee ? toDecimal(fee) : '0'; - let tonAmount = Big(feeDecimal); - let cost = 0; - - const bySymbol: Record = (renderingTransactions ?? []).reduce((acc, transaction) => { - const { payload, amount } = transaction; - const amountDecimal = toDecimal(amount); - - tonAmount = tonAmount.plus(amountDecimal); - cost += Number(amountDecimal) * (tonToken.price ?? 0); - - if (payload?.type === 'tokens:transfer' || payload?.type === 'tokens:transfer-non-standard') { - const { slug: tokenSlug, amount: tokenAmount } = payload; - - const token = tokens?.find(({ slug }) => tokenSlug === slug); - if (token) { - const { decimals, symbol, price } = token; - const tokenAmountDecimal = toDecimal(tokenAmount, decimals); - - acc[symbol] = (acc[symbol] ?? Big(0)).plus(tokenAmountDecimal); - cost += Number(tokenAmountDecimal) * price; - } - } - - return acc; - }, {} as Record); - - const text = Object.entries(bySymbol).reduce((acc, [symbol, amountBig]) => { - return `${acc} + ${formatCurrency(amountBig.toString(), symbol, SHORT_FRACTION_DIGITS)}`; - }, formatCurrency(tonAmount.toString(), tonToken.symbol, SHORT_FRACTION_DIGITS)); - - return `${text} (${formatCurrency(cost, shortBaseSymbol)})`; - }, [renderingTransactions, fee, tokens, tonToken, shortBaseSymbol]); - function renderDapp() { return (
{currentAccount?.title}
-
{formatCurrency(toDecimal(tonToken.amount), tonToken.symbol)}
+
{formatCurrency(toDecimal(toncoinBalance), TONCOIN.symbol)}
); } - function renderTransactionRow(transaction: ApiDappTransfer, i: number) { + function renderTransactionRow(transaction: ExtendedDappTransfer, i: number) { const { payload } = transaction; + const tonAmount = transaction.amount + (transaction.fee ?? 0n); let extraText: string = ''; - if (payload?.type === 'nft:transfer') { + if (isNftTransferPayload(payload)) { extraText = '1 NFT + '; - } else if (payload?.type === 'tokens:transfer' || payload?.type === 'tokens:transfer-non-standard') { + } else if (isTokenTransferPayload(payload)) { const { slug: tokenSlug, amount } = payload; - const token = tokens?.find(({ slug }) => tokenSlug === slug); + const token = tokensBySlug[tokenSlug]; if (token) { const { decimals, symbol } = token; - extraText = `${formatCurrency(toDecimal(amount, decimals), symbol, SHORT_FRACTION_DIGITS)} + `; + extraText = `${formatCurrency(toDecimal(amount, decimals), symbol)} + `; } } @@ -157,12 +122,12 @@ function DappTransferInitial({ {transaction.isScam && {lang('Scam')}} {extraText} - {formatCurrency(toDecimal(transaction.amount), tonToken.symbol, SHORT_FRACTION_DIGITS)} + {formatCurrency(toDecimal(tonAmount), TONCOIN.symbol)} {' '} {lang('$transaction_to', { - address: shortenAddress(transaction.toAddress), + address: shortenAddress(getDappTransferActualToAddress(transaction)), })} @@ -171,6 +136,12 @@ function DappTransferInitial({ } function renderTransactions() { + const hasDangerous = (renderingTransactions ?? []).some(({ isDangerous }) => isDangerous); + + const totalAmountsText = Object.entries(totalAmountsBySlug) + .map(([tokenSlug, amount]) => formatCurrency(amount, tokensBySlug[tokenSlug]?.symbol ?? '')) + .join(' + '); + return ( <>

{lang('$many_transactions', renderingTransactions?.length, 'i')}

@@ -180,7 +151,12 @@ function DappTransferInitial({ {lang('Total Amount')} - +
+ {totalAmountsText} ({formatCurrency(totalCost, getShortCurrencySymbol(baseCurrency))}) +
+ {hasDangerous && ( +
{lang('$hardware_payload_warning')}
+ )} ); } @@ -193,8 +169,6 @@ function DappTransferInitial({
{renderDapp()} - {isNftTransfer && } -
{isSingleTransaction ? renderTransaction() : renderTransactions()}
@@ -217,23 +191,21 @@ function DappTransferInitial({ } export default memo(withGlobal((global): StateProps => { - const { - transactions, - fee, - isLoading, - dapp, - } = global.currentDappTransfer; + const { isLoading, dapp } = global.currentDappTransfer; const { baseCurrency } = global.settings; const accounts = selectNetworkAccounts(global); + const { bySlug: totalAmountsBySlug, cost: totalCost } = selectCurrentDappTransferTotalAmounts(global); return { currentAccount: accounts?.[global.currentAccountId!], - transactions, - fee, + toncoinBalance: selectCurrentToncoinBalance(global), + transactions: selectCurrentDappTransferExtendedTransactions(global), + totalAmountsBySlug, + totalCost, dapp, isLoading, - tokens: selectCurrentAccountTokens(global), + tokensBySlug: global.tokenInfo.bySlug, baseCurrency, }; })(DappTransferInitial)); diff --git a/src/components/dapps/DappTransferModal.tsx b/src/components/dapps/DappTransferModal.tsx index 7f9ced4d..86eaf9d1 100644 --- a/src/components/dapps/DappTransferModal.tsx +++ b/src/components/dapps/DappTransferModal.tsx @@ -1,16 +1,15 @@ -import React, { - memo, useEffect, useMemo, useState, -} from '../../lib/teact/teact'; +import React, { memo, useEffect, useState } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; -import type { GlobalState, HardwareConnectState, UserToken } from '../../global/types'; +import type { ApiToken } from '../../api/types'; +import type { ExtendedDappTransfer, GlobalState, HardwareConnectState } from '../../global/types'; import { TransferState } from '../../global/types'; -import { ANIMATED_STICKER_SMALL_SIZE_PX, IS_CAPACITOR, TONCOIN } from '../../config'; -import { selectCurrentAccountTokens } from '../../global/selectors'; +import { IS_CAPACITOR } from '../../config'; +import { selectCurrentDappTransferExtendedTransactions } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import resolveSlideTransitionName from '../../util/resolveSlideTransitionName'; -import { ANIMATED_STICKERS_PATHS } from '../ui/helpers/animatedAssets'; +import { isNftTransferPayload } from '../../util/ton/transfer'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useLang from '../../hooks/useLang'; @@ -19,7 +18,6 @@ import useModalTransitionKeys from '../../hooks/useModalTransitionKeys'; import LedgerConfirmOperation from '../ledger/LedgerConfirmOperation'; import LedgerConnect from '../ledger/LedgerConnect'; -import AnimatedIconWithPreview from '../ui/AnimatedIconWithPreview'; import Button from '../ui/Button'; import Modal from '../ui/Modal'; import ModalHeader from '../ui/ModalHeader'; @@ -34,7 +32,8 @@ import styles from './Dapp.module.scss'; interface StateProps { currentDappTransfer: GlobalState['currentDappTransfer']; - tokens?: UserToken[]; + transactions?: ExtendedDappTransfer[]; + tokensBySlug: Record; hardwareState?: HardwareConnectState; isLedgerConnected?: boolean; isTonAppConnected?: boolean; @@ -44,13 +43,13 @@ interface StateProps { function DappTransferModal({ currentDappTransfer: { dapp, - transactions, isLoading, viewTransactionOnIdx, state, error, }, - tokens, + transactions, + tokensBySlug, hardwareState, isLedgerConnected, isTonAppConnected, @@ -66,16 +65,14 @@ function DappTransferModal({ } = getActions(); const lang = useLang(); - const tonToken = useMemo(() => tokens?.find(({ slug }) => slug === TONCOIN.slug), [tokens])!; const isOpen = state !== TransferState.None; const [forceFullNative, setForceFullNative] = useState(false); const { renderingKey, nextKey, updateNextKey } = useModalTransitionKeys(state, isOpen); const renderingTransactions = useCurrentOrPrev(transactions, true); - const isNftTransfer = renderingTransactions?.[0].payload?.type === 'nft:transfer'; const isDappLoading = dapp === undefined; - const withPayloadWarning = renderingTransactions?.[0].payload?.type === 'unknown'; + const withPayloadWarning = (renderingTransactions ?? []).some(({ isDangerous }) => isDangerous); useEffect(() => { setForceFullNative(isOpen && (withPayloadWarning || renderingKey === TransferState.Password)); @@ -100,28 +97,17 @@ function DappTransferModal({ updateNextKey(); }); - function renderSingleTransaction(isActive: boolean) { + function renderSubTransaction() { const transaction = viewTransactionOnIdx !== undefined ? transactions?.[viewTransactionOnIdx] : undefined; return ( <> - +
- - {Boolean(transaction) && ( )}
@@ -189,8 +175,11 @@ function DappTransferModal({ {isDappLoading ? renderWaitForConnection() : ( <> - - + + )} @@ -206,11 +195,11 @@ function DappTransferModal({ return ( <> - + ); case TransferState.Confirm: - return renderSingleTransaction(isActive); + return renderSubTransaction(); case TransferState.Password: return renderPassword(isActive); case TransferState.ConnectHardware: @@ -270,7 +259,8 @@ export default memo(withGlobal((global): StateProps => { return { currentDappTransfer: global.currentDappTransfer, - tokens: selectCurrentAccountTokens(global), + transactions: selectCurrentDappTransferExtendedTransactions(global), + tokensBySlug: global.tokenInfo.bySlug, hardwareState, isLedgerConnected, isTonAppConnected, diff --git a/src/components/explore/Category.tsx b/src/components/explore/Category.tsx index 8d677f3a..9ec3ea29 100644 --- a/src/components/explore/Category.tsx +++ b/src/components/explore/Category.tsx @@ -48,7 +48,9 @@ function Category({ category, sites }: OwnProps) { type="button" className={buildClassName(styles.site, styles.scalable)} onClick={() => { - openUrl(site.url, site.isExternal, site.name, getHostnameFromUrl(site.url)); + void openUrl( + site.url, { isExternal: site.isExternal, title: site.name, subtitle: getHostnameFromUrl(site.url) }, + ); }} > { - const matchedUrl = ORIGIN_REPLACEMENTS_BY_ORIGIN[url]; - - if (matchedUrl || isTelegramUrl(url)) { - await openUrl(matchedUrl, true); - } else { - await openUrl(url); - } + await openUrl(ORIGIN_REPLACEMENTS_BY_ORIGIN[url] || url); setTimeout(() => void updateDappLastOpenedAt({ origin }), RERENDER_DAPPS_FEED_DELAY_MS); }); diff --git a/src/components/explore/Explore.tsx b/src/components/explore/Explore.tsx index 61e1680e..44f3eaca 100644 --- a/src/components/explore/Explore.tsx +++ b/src/components/explore/Explore.tsx @@ -10,9 +10,9 @@ import { selectCurrentAccountState } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import { vibrate } from '../../util/capacitor'; import captureEscKeyListener from '../../util/captureEscKeyListener'; +import { stopEvent } from '../../util/domEvents'; 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 { @@ -213,7 +213,7 @@ function Explore({ addSiteToBrowserHistory({ url }); } - void openUrl(url, isExternal, title, getHostnameFromUrl(url)); + void openUrl(url, { isExternal, title, subtitle: getHostnameFromUrl(url) }); } const handleSiteClick = useLastCallback(( diff --git a/src/components/explore/Site.tsx b/src/components/explore/Site.tsx index 32b4ad55..9b6eb75a 100644 --- a/src/components/explore/Site.tsx +++ b/src/components/explore/Site.tsx @@ -26,7 +26,7 @@ function Site({ }: OwnProps) { function handleClick() { void vibrate(); - void openUrl(url, isExternal, name, getHostnameFromUrl(url)); + void openUrl(url, { isExternal, title: name, subtitle: getHostnameFromUrl(url) }); } return ( diff --git a/src/components/explore/hooks/useDappBridge.ts b/src/components/explore/hooks/useDappBridge.ts index 1336f7d0..f71ef737 100644 --- a/src/components/explore/hooks/useDappBridge.ts +++ b/src/components/explore/hooks/useDappBridge.ts @@ -18,7 +18,7 @@ import { CONNECT_EVENT_ERROR_CODES, SEND_TRANSACTION_ERROR_CODES } from '../../. import { TONCONNECT_PROTOCOL_VERSION } from '../../../config'; import { logDebugError } from '../../../util/logs'; import { tonConnectGetDeviceInfo } from '../../../util/tonConnectEnvironment'; -import { IS_DELEGATED_BOTTOM_SHEET, IS_DELEGATING_BOTTOM_SHEET } from '../../../util/windowEnvironment'; +import { IS_DELEGATED_BOTTOM_SHEET } from '../../../util/windowEnvironment'; import { callApi } from '../../../api'; import { useWebViewBridge } from './useWebViewBridge'; @@ -70,9 +70,6 @@ export function useDappBridge({ verifyConnectRequest(request); await inAppBrowserRef.current?.hide(); - if (IS_DELEGATING_BOTTOM_SHEET) { - await BottomSheet.enable(); - } openLoadingOverlay(); @@ -85,10 +82,6 @@ export function useDappBridge({ closeLoadingOverlay(); - if (IS_DELEGATING_BOTTOM_SHEET) { - await BottomSheet.disable(); - } - inAppBrowserRef.current?.show(); setRequestId(requestId + 1); @@ -99,9 +92,6 @@ export function useDappBridge({ return response; } catch (err: any) { logDebugError('useDAppBridge:connect', err); - if (IS_DELEGATING_BOTTOM_SHEET) { - await BottomSheet.disable(); - } inAppBrowserRef.current?.show(); if ('event' in err && 'id' in err && 'payload' in err) { @@ -176,18 +166,12 @@ export function useDappBridge({ switch (request.method) { case 'sendTransaction': { await inAppBrowserRef.current?.hide(); - if (IS_DELEGATING_BOTTOM_SHEET) { - await BottomSheet.enable(); - } const callResponse = await callApi( 'tonConnect_sendTransaction', dappRequest, request, ); - if (IS_DELEGATING_BOTTOM_SHEET) { - await BottomSheet.disable(); - } if (IS_DELEGATED_BOTTOM_SHEET) { void BottomSheet.applyScrollPatch(); } @@ -208,17 +192,11 @@ export function useDappBridge({ case 'signData': { await inAppBrowserRef.current?.hide(); - if (IS_DELEGATING_BOTTOM_SHEET) { - await BottomSheet.enable(); - } const callResponse = await callApi( 'tonConnect_signData', dappRequest, request, ); - if (IS_DELEGATING_BOTTOM_SHEET) { - await BottomSheet.disable(); - } inAppBrowserRef.current?.show(); return callResponse!; @@ -235,9 +213,6 @@ export function useDappBridge({ } } catch (err: any) { logDebugError('useDAppBridge:send', err); - if (IS_DELEGATING_BOTTOM_SHEET) { - await BottomSheet.disable(); - } inAppBrowserRef.current?.show(); return { diff --git a/src/components/explore/hooks/useWebViewBridge.ts b/src/components/explore/hooks/useWebViewBridge.ts index d99e2478..eee56569 100644 --- a/src/components/explore/hooks/useWebViewBridge.ts +++ b/src/components/explore/hooks/useWebViewBridge.ts @@ -6,7 +6,10 @@ import type { WebViewBridgeMessage } from '../helpers'; import { openDeeplinkOrUrl } from '../../../util/deeplink'; import { getInjectableJsMessage, objectToInjection, WebViewBridgeMessageType } from '../helpers'; -export type CustomInAppBrowserObject = Omit & { hide: () => Promise }; +export type CustomInAppBrowserObject = Omit & { + hide(): Promise; + close(): Promise; +}; type UseWebViewBridgeReturnType = [ string, diff --git a/src/components/main/sections/Card/AccountButton.tsx b/src/components/main/sections/Card/AccountButton.tsx index be72788d..7ce58774 100644 --- a/src/components/main/sections/Card/AccountButton.tsx +++ b/src/components/main/sections/Card/AccountButton.tsx @@ -5,8 +5,8 @@ import type { Account } from '../../../../global/types'; import buildClassName from '../../../../util/buildClassName'; import buildStyle from '../../../../util/buildStyle'; +import { stopEvent } from '../../../../util/domEvents'; import { shortenAddress } from '../../../../util/shortenAddress'; -import stopEvent from '../../../../util/stopEvent'; import useCardCustomization from '../../../../hooks/useCardCustomization'; import useLang from '../../../../hooks/useLang'; diff --git a/src/components/main/sections/Card/CardAddress.tsx b/src/components/main/sections/Card/CardAddress.tsx index 81cf148d..68b90e74 100644 --- a/src/components/main/sections/Card/CardAddress.tsx +++ b/src/components/main/sections/Card/CardAddress.tsx @@ -71,7 +71,7 @@ function CardAddress({ }); const handleExplorerClick = useLastCallback((e: React.MouseEvent, chain: ApiChain, address: string) => { - openUrl(getExplorerAddressUrl(chain, address, isTestnet)!); + void openUrl(getExplorerAddressUrl(chain, address, isTestnet)!); closeMenu(); }); diff --git a/src/components/main/sections/Content/NftCollectionHeader.tsx b/src/components/main/sections/Content/NftCollectionHeader.tsx index c916b97f..dde6f6bf 100644 --- a/src/components/main/sections/Content/NftCollectionHeader.tsx +++ b/src/components/main/sections/Content/NftCollectionHeader.tsx @@ -137,14 +137,14 @@ function NftCollectionHeader({ case 'getgems': { const getgemsBaseUrl = isTestnet ? GETGEMS_BASE_TESTNET_URL : GETGEMS_BASE_MAINNET_URL; - openUrl(`${getgemsBaseUrl}collection/${renderedNft?.collectionAddress}`); + void openUrl(`${getgemsBaseUrl}collection/${renderedNft?.collectionAddress}`); break; } case 'tonExplorer': { const url = getExplorerNftCollectionUrl(renderedNft?.collectionAddress, isTestnet); if (url) { - openUrl(url); + void openUrl(url); } break; } diff --git a/src/components/main/sections/Content/NftMenu.tsx b/src/components/main/sections/Content/NftMenu.tsx index a9a6a129..05927f9b 100644 --- a/src/components/main/sections/Content/NftMenu.tsx +++ b/src/components/main/sections/Content/NftMenu.tsx @@ -6,7 +6,7 @@ import type { IAnchorPosition } from '../../../../global/types'; import { selectCurrentAccountSettings, selectCurrentAccountState } from '../../../../global/selectors'; import buildClassName from '../../../../util/buildClassName'; -import stopEvent from '../../../../util/stopEvent'; +import { stopEvent } from '../../../../util/domEvents'; import useLang from '../../../../hooks/useLang'; import useLastCallback from '../../../../hooks/useLastCallback'; diff --git a/src/components/main/sections/Content/Nfts.tsx b/src/components/main/sections/Content/Nfts.tsx index 7ac0f56f..a66c95d8 100644 --- a/src/components/main/sections/Content/Nfts.tsx +++ b/src/components/main/sections/Content/Nfts.tsx @@ -8,8 +8,8 @@ import renderText from '../../../../global/helpers/renderText'; import { selectCurrentAccountState } from '../../../../global/selectors'; import buildClassName from '../../../../util/buildClassName'; import captureEscKeyListener from '../../../../util/captureEscKeyListener'; +import { stopEvent } from '../../../../util/domEvents'; import { openUrl } from '../../../../util/openUrl'; -import stopEvent from '../../../../util/stopEvent'; import { getHostnameFromUrl } from '../../../../util/url'; import { ANIMATED_STICKERS_PATHS } from '../../../ui/helpers/animatedAssets'; @@ -89,7 +89,7 @@ function Nfts({ function handleTonDiamondsClick(e: React.MouseEvent) { stopEvent(e); - void openUrl(TON_DIAMONDS_URL, undefined, TON_DIAMONDS_TITLE, getHostnameFromUrl(TON_DIAMONDS_URL)); + void openUrl(TON_DIAMONDS_URL, { title: TON_DIAMONDS_TITLE, subtitle: getHostnameFromUrl(TON_DIAMONDS_URL) }); } if (nfts === undefined) { diff --git a/src/components/main/sections/Warnings/SecurityWarning.tsx b/src/components/main/sections/Warnings/SecurityWarning.tsx index a62b4ffe..57f4fae2 100644 --- a/src/components/main/sections/Warnings/SecurityWarning.tsx +++ b/src/components/main/sections/Warnings/SecurityWarning.tsx @@ -25,7 +25,7 @@ function SecurityWarning({ isSecurityWarningHidden }: StateProps) { const lang = useLang(); function handleClick() { - openUrl(MYTONWALLET_PROMO_URL); + void openUrl(MYTONWALLET_PROMO_URL); } const handleClose = useLastCallback((e: React.MouseEvent) => { diff --git a/src/components/mediaViewer/helpers/ghostAnimation.ts b/src/components/mediaViewer/helpers/ghostAnimation.ts index 5ef292f4..2681c1d5 100644 --- a/src/components/mediaViewer/helpers/ghostAnimation.ts +++ b/src/components/mediaViewer/helpers/ghostAnimation.ts @@ -3,8 +3,8 @@ import { MediaType } from '../../../global/types'; import { ANIMATION_END_DELAY, MOBILE_SCREEN_MAX_WIDTH } from '../../../config'; import { requestMutation } from '../../../lib/fasterdom/fasterdom'; import { applyStyles } from '../../../util/animation'; +import { stopEvent } from '../../../util/domEvents'; import { isElementInViewport } from '../../../util/isElementInViewport'; -import stopEvent from '../../../util/stopEvent'; import { REM } from '../../../util/windowEnvironment'; import windowSize from '../../../util/windowSize'; diff --git a/src/components/mediaViewer/hooks/useNftMenu.ts b/src/components/mediaViewer/hooks/useNftMenu.ts index 0db6cb1d..a6829880 100644 --- a/src/components/mediaViewer/hooks/useNftMenu.ts +++ b/src/components/mediaViewer/hooks/useNftMenu.ts @@ -117,7 +117,7 @@ export default function useNftMenu({ clearAccentColorFromNft, } = getActions(); - const handleMenuItemSelect = useLastCallback(async (value: string) => { + const handleMenuItemSelect = useLastCallback((value: string) => { const { isTestnet } = getGlobal().settings; switch (value) { @@ -134,7 +134,7 @@ export default function useNftMenu({ case 'tonExplorer': { const url = getExplorerNftUrl(nft!.address, isTestnet)!; - openUrl(url); + void openUrl(url); break; } @@ -144,14 +144,14 @@ export default function useNftMenu({ ? `${getgemsBaseUrl}collection/${nft!.collectionAddress}/${nft!.address}` : `${getgemsBaseUrl}nft/${nft!.address}`; - openUrl(getgemsUrl); + void openUrl(getgemsUrl); break; } case 'tondns': { const url = `https://dns.ton.org/#${(nft!.name || '').replace(/\.ton$/i, '')}`; - openUrl(url); + void openUrl(url); break; } diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index d00887e8..3c395f79 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -295,15 +295,15 @@ function Settings({ }); function handleClickInstallApp() { - openUrl('https://mytonwallet.io/get', true); + void openUrl('https://mytonwallet.io/get', { isExternal: true }); } function handleClickInstallOnDesktop() { - openUrl('https://mytonwallet.io/get/desktop', true); + void openUrl('https://mytonwallet.io/get/desktop', { isExternal: true }); } function handleClickInstallOnMobile() { - openUrl('https://mytonwallet.io/get/mobile', true); + void openUrl('https://mytonwallet.io/get/mobile', { isExternal: true }); } const handleAddLedgerWallet = useLastCallback(() => { diff --git a/src/components/staking/StakingInfoContent.tsx b/src/components/staking/StakingInfoContent.tsx index 0da40d3e..3e330a53 100644 --- a/src/components/staking/StakingInfoContent.tsx +++ b/src/components/staking/StakingInfoContent.tsx @@ -88,7 +88,7 @@ function StakingInfoContent({ const { id: stakingId, tokenSlug, - balance: amount = 0n, + balance: amount, annualYield = 0, isUnstakeRequested, } = stakingState ?? {}; @@ -100,7 +100,7 @@ function StakingInfoContent({ const { decimals, symbol } = token ?? {}; const lang = useLang(); - const isLoading = !amount; + const isLoading = amount === undefined; const hasHistory = Boolean(stakingHistory?.length); const { shouldRender: shouldRenderSpinner, @@ -139,8 +139,12 @@ function StakingInfoContent({ startStakingClaim(); }); - const stakingResult = toBig(amount).round(SHORT_FRACTION_DIGITS).toString(); - const balanceResult = toBig(amount).mul((annualYield / 100) + 1).round(SHORT_FRACTION_DIGITS).toString(); + let stakingResult = '0'; + let balanceResult = '0'; + if (amount) { + stakingResult = toBig(amount).round(SHORT_FRACTION_DIGITS).toString(); + balanceResult = toBig(amount).mul((annualYield / 100) + 1).round(SHORT_FRACTION_DIGITS).toString(); + } const dropDownItems = useMemo(() => { if (!tokenBySlug || !states) { diff --git a/src/components/swap/SwapInitial.tsx b/src/components/swap/SwapInitial.tsx index b6d13e67..ae5f6687 100644 --- a/src/components/swap/SwapInitial.tsx +++ b/src/components/swap/SwapInitial.tsx @@ -81,7 +81,6 @@ function SwapInitial({ amountOut, errorType, isEstimating, - shouldEstimate, networkFee, realNetworkFee, priceImpact = 0, @@ -220,7 +219,7 @@ function SwapInitial({ ); const handleEstimateSwap = useLastCallback(() => { - if ((!isActive || isBackgroundModeActive()) && !shouldEstimate) return; + if ((!isActive || isBackgroundModeActive()) && !isEstimating) return; estimateSwap(); }); @@ -241,13 +240,13 @@ function SwapInitial({ }, [tokenInSlug, tokenOutSlug]); useEffect(() => { - if (shouldEstimate) { + if (isEstimating) { handleEstimateSwap(); } const intervalId = setInterval(handleEstimateSwap, ESTIMATE_REQUEST_INTERVAL); return () => clearInterval(intervalId); - }, [shouldEstimate]); + }, [isEstimating]); useEffect(() => { const shouldForceUpdate = accountId !== accountIdPrev; diff --git a/src/components/transfer/NftInfo.tsx b/src/components/transfer/NftInfo.tsx index 178840ac..bcc29307 100644 --- a/src/components/transfer/NftInfo.tsx +++ b/src/components/transfer/NftInfo.tsx @@ -40,7 +40,7 @@ function NftInfo({ event.stopPropagation(); const url = getExplorerNftUrl(nft!.address, getGlobal().settings.isTestnet)!; - openUrl(url); + void openUrl(url); }; const handleClick = useLastCallback(() => { diff --git a/src/components/transfer/TransferInitial.tsx b/src/components/transfer/TransferInitial.tsx index b548034e..7a88d40b 100644 --- a/src/components/transfer/TransferInitial.tsx +++ b/src/components/transfer/TransferInitial.tsx @@ -30,6 +30,7 @@ import { readClipboardContent } from '../../util/clipboard'; import { SECOND } from '../../util/dateFormat'; import { fromDecimal, toBig, toDecimal } from '../../util/decimals'; import dns from '../../util/dns'; +import { stopEvent } from '../../util/domEvents'; import { explainApiTransferFee, getMaxTransferAmount, isBalanceSufficientForTransfer, } from '../../util/fee/transferFee'; @@ -37,7 +38,6 @@ import { formatCurrency, getShortCurrencySymbol } from '../../util/formatNumber' import { isValidAddressOrDomain } from '../../util/isValidAddressOrDomain'; import { debounce } from '../../util/schedulers'; import { shortenAddress } from '../../util/shortenAddress'; -import stopEvent from '../../util/stopEvent'; import getChainNetworkIcon from '../../util/swap/getChainNetworkIcon'; import { IS_ANDROID, IS_FIREFOX, IS_TOUCH_ENV } from '../../util/windowEnvironment'; import { ASSET_LOGO_PATHS } from '../ui/helpers/assetLogos'; diff --git a/src/components/ui/IconWithTooltip.tsx b/src/components/ui/IconWithTooltip.tsx index 846625a8..88f903f9 100644 --- a/src/components/ui/IconWithTooltip.tsx +++ b/src/components/ui/IconWithTooltip.tsx @@ -6,7 +6,7 @@ import React, { import type { EmojiIcon } from './Emoji'; import buildClassName from '../../util/buildClassName'; -import stopEvent from '../../util/stopEvent'; +import { stopEvent } from '../../util/domEvents'; import { IS_TOUCH_ENV, REM } from '../../util/windowEnvironment'; import useFlag from '../../hooks/useFlag'; diff --git a/src/components/ui/InAppBrowser.tsx b/src/components/ui/InAppBrowser.tsx index 9fe8b873..a3dd2c57 100644 --- a/src/components/ui/InAppBrowser.tsx +++ b/src/components/ui/InAppBrowser.tsx @@ -7,8 +7,10 @@ import type { CustomInAppBrowserObject } from '../explore/hooks/useWebViewBridge import { ANIMATION_LEVEL_DEFAULT } from '../../config'; import buildClassName from '../../util/buildClassName'; import { INAPP_BROWSER_OPTIONS } from '../../util/capacitor'; +import { listenOnce } from '../../util/domEvents'; import { compact } from '../../util/iteratees'; import { logDebugError } from '../../util/logs'; +import { waitFor } from '../../util/schedulers'; import { getHostnameFromUrl } from '../../util/url'; import { IS_DELEGATING_BOTTOM_SHEET, IS_IOS, IS_IOS_APP } from '../../util/windowEnvironment'; @@ -27,8 +29,10 @@ interface StateProps { animationLevel?: number; } +// The maximum time the in-app browser will take to close (and a little more as a safe margin) +const CLOSE_MAX_DURATION = 900; + let inAppBrowser: Cordova['InAppBrowser'] | undefined; -let hideCompletionResolves: (() => void)[] | undefined; function InAppBrowser({ title, subtitle, url, theme, animationLevel, @@ -52,11 +56,6 @@ function InAppBrowser({ logDebugError('inAppBrowser error', err); }); - const handleHideCompletion = useLastCallback(() => { - for (const resolve of hideCompletionResolves || []) { resolve(); } - hideCompletionResolves = undefined; - }); - const handleBrowserClose = useLastCallback(async () => { if (IS_DELEGATING_BOTTOM_SHEET) { await BottomSheet.enable(); @@ -65,7 +64,7 @@ function InAppBrowser({ disconnect(); inAppBrowser.removeEventListener('loaderror', handleError); inAppBrowser.removeEventListener('message', onMessage); - inAppBrowser.removeEventListener('hidecompletion', handleHideCompletion); + inAppBrowser.removeEventListener('exit', handleBrowserClose); inAppBrowser = undefined; // eslint-disable-next-line no-null/no-null inAppBrowserRef.current = null; @@ -73,6 +72,10 @@ function InAppBrowser({ }); const openBrowser = useLastCallback(async () => { + if (IS_DELEGATING_BOTTOM_SHEET && !(await BottomSheet.isShown()).value) { + await BottomSheet.disable(); + } + const browserTitle = !title && url ? getHostnameFromUrl(url) : title; const browserSubtitle = subtitle === browserTitle ? undefined : subtitle; @@ -98,19 +101,38 @@ function InAppBrowser({ return new Promise((resolve) => { originalHide?.(); // On iOS, the animation takes some time. We have to ensure it's completed. - if (IS_IOS_APP) { - if (!hideCompletionResolves) hideCompletionResolves = []; - hideCompletionResolves?.push(resolve); + if (inAppBrowser && IS_IOS_APP) { + listenOnce(inAppBrowser, 'hidecompletion', () => resolve()); } else { resolve(); } }); }; + const originalClose = inAppBrowser.close; + inAppBrowser.close = () => new Promise((resolve) => { + originalClose(); + + if (inAppBrowser) { + // The `waitFor` is a hack necessary to ensure the browser is fully in the closed state when the promise + // resolves. This solves a bug: if a push notification, that opens a modal, was clicked while the in-app browser + // was open, the browser would close, but the modal wouldn't open. + listenOnce(inAppBrowser, 'exit', async () => { + await waitFor(() => !inAppBrowser, 15, 20); + resolve(); + }); + + // A backup for cases when the `closeBrowser` call doesn't cause the browser to close and fire the `exit` event. + // It happens if `close` is called when the browser is hidden (for example, when a TON connect modal is open + // from inside the browser). + setTimeout(resolve, CLOSE_MAX_DURATION); + } else { + resolve(); + } + }); inAppBrowserRef.current = inAppBrowser; inAppBrowser.addEventListener('loaderror', handleError); inAppBrowser.addEventListener('message', onMessage); inAppBrowser.addEventListener('exit', handleBrowserClose); - inAppBrowser.addEventListener('hidecompletion', handleHideCompletion); inAppBrowser.show(); }); diff --git a/src/components/ui/Menu.tsx b/src/components/ui/Menu.tsx index df55df66..df5c47c9 100644 --- a/src/components/ui/Menu.tsx +++ b/src/components/ui/Menu.tsx @@ -3,7 +3,7 @@ import React, { beginHeavyAnimation, useEffect, useRef } from '../../lib/teact/t import buildClassName from '../../util/buildClassName'; import captureEscKeyListener from '../../util/captureEscKeyListener'; -import stopEvent from '../../util/stopEvent'; +import { stopEvent } from '../../util/domEvents'; import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps'; import useHistoryBack from '../../hooks/useHistoryBack'; diff --git a/src/components/ui/Modal.module.scss b/src/components/ui/Modal.module.scss index 67394171..7b569e57 100644 --- a/src/components/ui/Modal.module.scss +++ b/src/components/ui/Modal.module.scss @@ -51,10 +51,6 @@ } } -.delegatingToNative { - display: none; -} - .slideUpAnimation { --transition: 500ms cubic-bezier(0.3, 0.8, 0.2, 1); diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index d008bbbf..fae80800 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -102,15 +102,7 @@ function Modal({ useHideBrowser(isOpen, isCompact); - const animationDuration = isPortrait ? CLOSE_DURATION_PORTRAIT : CLOSE_DURATION; - const { shouldRender, transitionClassNames } = useShowTransition( - isOpen, - onCloseAnimationEnd, - undefined, - false, - undefined, - animationDuration + ANIMATION_END_DELAY, - ); + const animationDuration = (isPortrait ? CLOSE_DURATION_PORTRAIT : CLOSE_DURATION) + ANIMATION_END_DELAY; const isSlideUp = !isCompact && isPortrait; @@ -142,27 +134,9 @@ function Modal({ useEffect(() => (isOpen && modalRef.current ? trapFocus(modalRef.current) : undefined), [isOpen]); useLayoutEffect(() => ( - isOpen ? beginHeavyAnimation(animationDuration + ANIMATION_END_DELAY) : undefined + isOpen ? beginHeavyAnimation(animationDuration) : undefined ), [animationDuration, isOpen]); - useEffect(() => { - if (!IS_TOUCH_ENV || !isOpen || !isPortrait || !isSlideUp || IS_DELEGATED_BOTTOM_SHEET) { - return undefined; - } - - return captureEvents(modalRef.current!, { - excludedClosestSelector: '.capture-scroll', - onSwipe: (e: Event, direction: SwipeDirection) => { - if (direction === SwipeDirection.Down && getCanCloseModal(swipeDownDateRef, e.target as HTMLElement)) { - onClose(); - return true; - } - - return false; - }, - }); - }, [isOpen, isPortrait, isSlideUp, onClose]); - // Make sure to hide browser before presenting modals const [isBrowserHidden, setIsBrowserHidden] = useState(false); useEffect(() => { @@ -190,6 +164,33 @@ function Modal({ noResetFullNativeOnBlur, ); + useEffect(() => { + if (!IS_TOUCH_ENV || !isOpen || !isPortrait || !isSlideUp || IS_DELEGATED_BOTTOM_SHEET || isDelegatingToNative) { + return undefined; + } + + return captureEvents(modalRef.current!, { + excludedClosestSelector: '.capture-scroll', + onSwipe: (e: Event, direction: SwipeDirection) => { + if (direction === SwipeDirection.Down && getCanCloseModal(swipeDownDateRef, e.target as HTMLElement)) { + onClose(); + return true; + } + + return false; + }, + }); + }, [isOpen, isPortrait, isSlideUp, isDelegatingToNative, onClose]); + + const { shouldRender, transitionClassNames } = useShowTransition( + isOpen && !isDelegatingToNative, + onCloseAnimationEnd, + undefined, + false, + undefined, + animationDuration, + ); + if (!shouldRender) { return undefined; } @@ -225,7 +226,6 @@ function Modal({ isCompact && styles.compact, isCompact && 'is-compact-modal', forceBottomSheet && styles.forceBottomSheet, - isDelegatingToNative && styles.delegatingToNative, ); const backdropFullClass = buildClassName(styles.backdrop, noBackdrop && styles.noBackdrop); diff --git a/src/components/ui/UpdateAvailable.tsx b/src/components/ui/UpdateAvailable.tsx index 31b8812b..9fb899ff 100644 --- a/src/components/ui/UpdateAvailable.tsx +++ b/src/components/ui/UpdateAvailable.tsx @@ -42,7 +42,7 @@ function UpdateAvailable({ isAppUpdateAvailable, newAppVersion, isAppUpdateRequi return; } - void openUrl(getUrl(newAppVersion), true); + void openUrl(getUrl(newAppVersion), { isExternal: true }); }; if (!shouldRender) { diff --git a/src/config.ts b/src/config.ts index f5a6f15e..2606e8d3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -200,6 +200,7 @@ export const LIQUID_POOL = process.env.LIQUID_POOL || 'EQD2_4d91M4TVbEBVyBF8J1Uw export const LIQUID_JETTON = process.env.LIQUID_JETTON || 'EQCqC6EhRJ_tpWngKxL6dV0k6DSnRUrs9GSVkLbfdCqsj6TE'; export const STAKING_MIN_AMOUNT = ONE_TON; export const NOMINATORS_STAKING_MIN_AMOUNT = 10_000n * ONE_TON; +export const MIN_ACTIVE_STAKING_REWARDS = 100_000_000n; // 0.1 MY export const TONCONNECT_PROTOCOL_VERSION = 2; export const TONCONNECT_WALLET_JSBRIDGE_KEY = 'mytonwallet'; diff --git a/src/global/actions/api/initial.ts b/src/global/actions/api/initial.ts index 7bcc8930..526d866b 100644 --- a/src/global/actions/api/initial.ts +++ b/src/global/actions/api/initial.ts @@ -12,6 +12,7 @@ addActionHandler('initApi', async (global, actions) => { isNativeBottomSheet: IS_DELEGATED_BOTTOM_SHEET, isIosApp: IS_IOS_APP, isAndroidApp: IS_ANDROID_APP, + referrer: new URLSearchParams(window.location.search).get('r') ?? undefined, }); await callApi('waitDataPreload'); diff --git a/src/global/actions/api/staking.ts b/src/global/actions/api/staking.ts index 01a6e4b0..402ba3b1 100644 --- a/src/global/actions/api/staking.ts +++ b/src/global/actions/api/staking.ts @@ -11,6 +11,7 @@ import { pause } from '../../../util/schedulers'; import { IS_DELEGATED_BOTTOM_SHEET } from '../../../util/windowEnvironment'; import { callApi } from '../../../api'; import { ApiHardwareBlindSigningNotEnabled, ApiUserRejectsError } from '../../../api/errors'; +import { closeAllOverlays } from '../../helpers/misc'; import { getIsActiveStakingState } from '../../helpers/staking'; import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { @@ -382,7 +383,10 @@ addActionHandler('openAnyAccountStakingInfo', async (global, actions, { accountI return; } - await switchAccount(global, accountId, network); + await Promise.all([ + closeAllOverlays(), + switchAccount(global, accountId, network), + ]); actions.changeCurrentStaking({ stakingId }); actions.openStakingInfo(); diff --git a/src/global/actions/api/swap.test.ts b/src/global/actions/api/swap.test.ts new file mode 100644 index 00000000..dd0905a7 --- /dev/null +++ b/src/global/actions/api/swap.test.ts @@ -0,0 +1,150 @@ +import type { GlobalState } from '../../types'; +import type { SwapEstimateResult } from './swap'; +import { SwapInputSource, SwapState } from '../../types'; + +import { TONCOIN, TRX } from '../../../config'; +import { getGlobal, setGlobal } from '../../index'; +import { clearCurrentSwap, updateCurrentSwap } from '../../reducers'; +import { estimateSwapConcurrently } from './swap'; + +describe('estimateSwapConcurrently', () => { + beforeEach(() => { + setGlobal(updateCurrentSwap(clearCurrentSwap(getGlobal()), { + state: SwapState.Initial, + isEstimating: false, + })); + }); + + const estimationResultMock: SwapEstimateResult = { + networkFee: '0.1', + realNetworkFee: '0.05', + }; + + it.each([ + ['initial', SwapState.Initial], + ['password', SwapState.Password], + ['address input', SwapState.Blockchain], + ])( + 'estimates visibly on the %p screen', + async () => { + const initialGlobal = updateCurrentSwap(getGlobal(), { isEstimating: true }); + setGlobal(initialGlobal); + let estimateCallCount = 0; + + await estimateSwapConcurrently((argGlobal, shouldStop) => { + expect(argGlobal).toEqual(getGlobal()); // The provided global should be up-to-date + expect(getGlobal()).toEqual(initialGlobal); // The spinner should be kept + expect(shouldStop()).toBe(false); // The `estimate` function shouldn't be asked to stop + estimateCallCount++; + return estimationResultMock; + }); + + expect(estimateCallCount).toBe(1); + expect(getGlobal()).toEqual(updateCurrentSwap(initialGlobal, { + ...estimationResultMock, + isEstimating: false, // The spinner should disappear + })); + }, + ); + + it('keeps the spinner, ignores the result and tells the `estimate` function to stop,' + + ' if the form input changes during estimation', async () => { + const input1 = { + tokenInSlug: TONCOIN.slug, + tokenOutSlug: TRX.slug, + amountIn: '1', + inputSource: SwapInputSource.In, + } satisfies Partial; + const input2 = { + ...input1, + tokenInSlug: TRX.slug, + tokenOutSlug: TONCOIN.slug, + } satisfies Partial; + + const initialGlobal = getGlobal(); + setGlobal(updateCurrentSwap(initialGlobal, input1)); + + await estimateSwapConcurrently((_, shouldStop) => { + setGlobal(updateCurrentSwap(getGlobal(), input2)); + expect(shouldStop()).toBe(true); + return estimationResultMock; + }); + + expect(getGlobal()).toEqual(updateCurrentSwap(initialGlobal, { + ...input2, + isEstimating: true, + })); + }); + + it('doesn\'t estimate and keeps the spinner if there is another estimation in progress', async () => { + const initialGlobal = updateCurrentSwap(getGlobal(), { isEstimating: true }); + setGlobal(initialGlobal); + + await estimateSwapConcurrently(async () => { + const estimateFn = jest.fn(); + + await estimateSwapConcurrently(estimateFn); + + expect(estimateFn).not.toHaveBeenCalled(); + expect(getGlobal()).toEqual(initialGlobal); + + return estimationResultMock; + }); + + // The first estimation should reset the spinner (because the input hasn't changed) + expect(getGlobal()).toEqual(updateCurrentSwap(initialGlobal, { + ...estimationResultMock, + isEstimating: false, + })); + }); + + it('keeps the spinner if estimation has been rate-limited', async () => { + const initialGlobal = updateCurrentSwap(getGlobal(), { isEstimating: true }); + setGlobal(initialGlobal); + + await estimateSwapConcurrently(() => 'rateLimited'); + + expect(getGlobal()).toEqual(initialGlobal); + }); + + it('doesn\'t enable the spinner', async () => { + const initialGlobal = getGlobal(); + + await estimateSwapConcurrently((_global, shouldStop) => { + expect(getGlobal()).toEqual(initialGlobal); + expect(shouldStop()).toBe(false); + return estimationResultMock; + }); + + expect(getGlobal()).toEqual(updateCurrentSwap(initialGlobal, estimationResultMock)); + }); + + describe.each([ + ['password', SwapState.Password], + ['address input', SwapState.Blockchain], + ])('hidden estimation on the %p screen', (_stateName, state) => { + it('doesn\'t start estimation', async () => { + const initialGlobal = updateCurrentSwap(getGlobal(), { state }); + setGlobal(initialGlobal); + const estimateFn = jest.fn(); + + await estimateSwapConcurrently(estimateFn); + + expect(estimateFn).not.toHaveBeenCalled(); + expect(getGlobal()).toEqual(initialGlobal); + }); + + it('ignores the result and tells the `estimate` function to stop,' + + ' if the estimation started before that screen', async () => { + const initialGlobal = getGlobal(); + + await estimateSwapConcurrently((_global, shouldStop) => { + setGlobal(updateCurrentSwap(getGlobal(), { state })); + expect(shouldStop()).toBe(true); + return estimationResultMock; + }); + + expect(getGlobal()).toEqual(updateCurrentSwap(initialGlobal, { state })); + }); + }); +}); diff --git a/src/global/actions/api/swap.ts b/src/global/actions/api/swap.ts index b4a03dea..ef547ef2 100644 --- a/src/global/actions/api/swap.ts +++ b/src/global/actions/api/swap.ts @@ -922,7 +922,7 @@ function convertTransferFeesToSwapFees( return { networkFee, realNetworkFee }; } -type SwapEstimateResult = Partial | 'rateLimited' | void; +export type SwapEstimateResult = Partial | 'rateLimited' | undefined; let isEstimatingSwap = false; @@ -933,23 +933,16 @@ let isEstimatingSwap = false; * You may call the `shouldStop` function to check whether it makes sense to continue estimating (because the result * is likely to be ignored). If `shouldStop` returns true, `estimate` may return any value (it will be ignored). */ -async function estimateSwapConcurrently( +export async function estimateSwapConcurrently( estimate: ( global: GlobalState, shouldStop: () => boolean, ) => SwapEstimateResult | Promise, ) { - let initialGlobal = getGlobal(); + const initialGlobal = getGlobal(); if (shouldAvoidSwapEstimation(initialGlobal)) return; - // Turning on the loading indicator even if another "hidden" estimation is already in progress. - // Keeping the `shouldEstimate` equal `true` in the state to handle the state properly after `estimate` finishes. - if (initialGlobal.currentSwap.shouldEstimate && !initialGlobal.currentSwap.isEstimating) { - initialGlobal = updateCurrentSwap(initialGlobal, { isEstimating: true }); - setGlobal(initialGlobal); - } - // There should be only 1 swap estimation at a time. A timer in SwapInitial will trigger another estimation attempt. if (isEstimatingSwap) { return; @@ -981,7 +974,6 @@ async function estimateSwapConcurrently( setGlobal(updateCurrentSwap(finalGlobal, { isEstimating: false, - shouldEstimate: false, ...(shouldAvoidSwapEstimation(finalGlobal) ? undefined : swapUpdate), })); } finally { diff --git a/src/global/actions/apiUpdates/initial.ts b/src/global/actions/apiUpdates/initial.ts index ec7cc706..be564db4 100644 --- a/src/global/actions/apiUpdates/initial.ts +++ b/src/global/actions/apiUpdates/initial.ts @@ -71,6 +71,10 @@ addActionHandler('apiUpdate', (global, actions, update) => { global = updateAccountStaking(global, accountId, { stakingId: stateWithBiggestBalance.id, }); + } else if (shouldUseNominators && stateById.nominators) { + global = updateAccountStaking(global, accountId, { + stakingId: stateById.nominators.id, + }); } } @@ -199,7 +203,7 @@ addActionHandler('apiUpdate', (global, actions, update) => { } case 'openUrl': { - openUrl(update.url, update.isExternal, update.title, update.subtitle); + void openUrl(update.url, { isExternal: update.isExternal, title: update.title, subtitle: update.subtitle }); break; } diff --git a/src/global/actions/ui/dapps.ts b/src/global/actions/ui/dapps.ts index 772c3f01..5b1a90c1 100644 --- a/src/global/actions/ui/dapps.ts +++ b/src/global/actions/ui/dapps.ts @@ -2,11 +2,16 @@ import { TransferState } from '../../types'; import { BROWSER_HISTORY_LIMIT } from '../../../config'; import { unique } from '../../../util/iteratees'; +import { callActionInMain } from '../../../util/multitab'; +import { openUrl } from '../../../util/openUrl'; +import { IS_DELEGATED_BOTTOM_SHEET } from '../../../util/windowEnvironment'; +import { closeAllOverlays } from '../../helpers/misc'; import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { clearDappConnectRequestError, updateCurrentAccountState, updateCurrentDappTransfer, } from '../../reducers'; import { selectAccount, selectCurrentAccountState } from '../../selectors'; +import { switchAccount } from '../api/auth'; addActionHandler('clearDappConnectRequestError', (global) => { global = clearDappConnectRequestError(global); @@ -51,8 +56,15 @@ addActionHandler('clearDappTransferError', (global) => { setGlobal(global); }); -addActionHandler('openBrowser', (global, actions, { url, title, subtitle }) => { - global = { ...global, currentBrowserOptions: { url, title, subtitle } }; +addActionHandler('openBrowser', (global, actions, { + url, title, subtitle, +}) => { + global = { + ...global, + currentBrowserOptions: { + url, title, subtitle, + }, + }; setGlobal(global); }); @@ -105,3 +117,20 @@ addActionHandler('openSiteCategory', (global, actions, { id }) => { addActionHandler('closeSiteCategory', (global) => { return updateCurrentAccountState(global, { currentSiteCategoryId: undefined }); }); + +addActionHandler('switchAccountAndOpenUrl', async (global, actions, payload) => { + if (IS_DELEGATED_BOTTOM_SHEET) { + callActionInMain('switchAccountAndOpenUrl', payload); + return; + } + + await Promise.all([ + // The browser is closed before opening the new URL, because otherwise the browser won't apply the new + // parameters from `payload`. It's important to wait for `closeAllOverlays` to finish, because until the in-app + // browser is closed, it won't open again. + closeAllOverlays(), + payload.accountId && switchAccount(global, payload.accountId, payload.network), + ]); + + openUrl(payload.url, payload); +}); diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index 9e919ecd..04790b27 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -28,6 +28,7 @@ import { vibrateOnSuccess } from '../../../util/capacitor'; import { parseDeeplinkTransferParams, processDeeplink } from '../../../util/deeplink'; import { getCachedImageUrl } from '../../../util/getCachedImageUrl'; import getIsAppUpdateNeeded from '../../../util/getIsAppUpdateNeeded'; +import { omit } from '../../../util/iteratees'; import { getTranslation } from '../../../util/langProvider'; import { onLedgerTabClose, openLedgerTab } from '../../../util/ledger/tab'; import { callActionInMain, callActionInNative } from '../../../util/multitab'; @@ -41,7 +42,7 @@ import { IS_DELEGATING_BOTTOM_SHEET, } from '../../../util/windowEnvironment'; import { callApi } from '../../../api'; -import { parsePlainAddressQr } from '../../helpers/misc'; +import { closeAllOverlays, parsePlainAddressQr } from '../../helpers/misc'; import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { clearCurrentSwap, @@ -91,7 +92,10 @@ addActionHandler('showAnyAccountTx', async (global, actions, { txId, accountId, return; } - await switchAccount(global, accountId, network); + await Promise.all([ + closeAllOverlays(), + switchAccount(global, accountId, network), + ]); actions.showActivityInfo({ id: txId }); }); @@ -102,7 +106,10 @@ addActionHandler('showAnyAccountTokenActivity', async (global, actions, { slug, return; } - await switchAccount(global, accountId, network); + await Promise.all([ + closeAllOverlays(), + switchAccount(global, accountId, network), + ]); actions.showTokenActivity({ slug }); }); @@ -587,14 +594,25 @@ addActionHandler('handleQrCode', async (global, actions, { data }) => { const { currentTransfer, currentSwap } = global.currentQrScan || {}; if (currentTransfer) { - const linkParams = parseDeeplinkTransferParams(data); - const toAddress = linkParams?.toAddress ?? data; - setGlobal(setCurrentTransferAddress(updateCurrentTransfer(global, currentTransfer), toAddress)); + const transferParams = parseDeeplinkTransferParams(data, global); + if (transferParams) { + if ('error' in transferParams) { + actions.showError({ error: transferParams.error }); + // Not returning on error is intentional + } + setGlobal(updateCurrentTransfer(global, { + ...currentTransfer, + ...omit(transferParams, ['error']), + })); + } else { + // Assuming that the QR code content is a plain wallet address + setGlobal(setCurrentTransferAddress(updateCurrentTransfer(global, currentTransfer), data)); + } return; } if (currentSwap) { - const linkParams = parseDeeplinkTransferParams(data); + const linkParams = parseDeeplinkTransferParams(data, global); const toAddress = linkParams?.toAddress ?? data; setGlobal(updateCurrentSwap(global, { ...currentSwap, toAddress })); return; @@ -733,7 +751,7 @@ addActionHandler('clearAccountLoading', (global) => { addActionHandler('authorizeDiesel', (global) => { const address = selectCurrentAccount(global)!.addressByChain.ton; setGlobal(updateCurrentAccountState(global, { isDieselAuthorizationStarted: true })); - openUrl(`https://t.me/${BOT_USERNAME}?start=auth-${address}`, true); + void openUrl(`https://t.me/${BOT_USERNAME}?start=auth-${address}`); }); addActionHandler('submitAppLockActivityEvent', () => { @@ -812,11 +830,6 @@ addActionHandler('closeAnyModal', () => { closeModal(); }); -addActionHandler('closeAllOverlays', (global, actions) => { - actions.closeAnyModal(); - actions.closeMediaViewer(); -}); - addActionHandler('openExplore', (global) => { return { ...global, isExploreOpen: true }; }); diff --git a/src/global/helpers/misc.ts b/src/global/helpers/misc.ts index 2933b9f2..4a258a81 100644 --- a/src/global/helpers/misc.ts +++ b/src/global/helpers/misc.ts @@ -3,8 +3,11 @@ import type { GlobalState } from '../types'; import { isValidAddressOrDomain } from '../../util/isValidAddressOrDomain'; import { getChainBySlug, getNativeToken } from '../../util/tokens'; +import { getActions } from '../index'; import { selectCurrentAccount } from '../selectors'; +import { getInAppBrowser } from '../../components/ui/InAppBrowser'; + /** * Parses the transfer parameters from the given QR content, assuming it's a plain address. * Returns `undefined` if this is not a valid address or the account doesn't have the corresponding wallet. @@ -35,3 +38,9 @@ function getChainFromAddress(address: string, availableChains: Record state.type === 'nominators' && state.balance); - const hasLiquidStake = states.some((state) => state.type === 'liquid' && state.balance); + const hasNominatorsStake = states.some((state) => state.type === 'nominators' && getIsActiveStakingState(state)); + const hasLiquidStake = states.some((state) => state.type === 'liquid' && getIsActiveStakingState(state)); if (shouldUseNominators && !hasLiquidStake) { states = states.filter((state) => state.type !== 'liquid'); @@ -40,12 +41,16 @@ export function getStakingStateStatus(state: ApiStakingState): StakingStateStatu if (state.isUnstakeRequested) { return 'unstakeRequested'; } - if (!state.balance) { - return 'inactive'; + if (getIsActiveStakingState(state)) { + return 'active'; } - return 'active'; + return 'inactive'; } export function getIsActiveStakingState(state: ApiStakingState) { - return Boolean(state.balance || state.isUnstakeRequested); + return Boolean( + state.balance + || state.isUnstakeRequested + || ('unclaimedRewards' in state && state.unclaimedRewards > MIN_ACTIVE_STAKING_REWARDS), + ); } diff --git a/src/global/helpers/swap.ts b/src/global/helpers/swap.ts index 8579e244..339b875e 100644 --- a/src/global/helpers/swap.ts +++ b/src/global/helpers/swap.ts @@ -4,9 +4,9 @@ import { SwapInputSource, SwapState } from '../types'; export function shouldAvoidSwapEstimation(global: GlobalState) { // For a better UX, we should leave the fees and the other swap data intact during swap confirmation (for example, // to avoid switching from/to gasless mode). - // `shouldEstimate` forces estimation, because normally it means that there was a swap parameter change that + // `isEstimating` forces estimation, because by design it means that there was a swap parameter change that // invalidates the current swap estimation. - return !global.currentSwap.shouldEstimate && ( + return !global.currentSwap.isEstimating && ( global.currentSwap.state === SwapState.Blockchain || global.currentSwap.state === SwapState.Password ); diff --git a/src/global/reducers/swap.ts b/src/global/reducers/swap.ts index 1a81246c..499f2023 100644 --- a/src/global/reducers/swap.ts +++ b/src/global/reducers/swap.ts @@ -33,10 +33,7 @@ export function updateCurrentSwap( } if (doesSwapChangeRequireEstimation(global, newGlobal)) { - newGlobal = rawUpdateCurrentSwap(newGlobal, { - shouldEstimate: true, - isEstimating: true, // Setting this is not necessary, but it allows to avoid one state update through the UI - }); + newGlobal = rawUpdateCurrentSwap(newGlobal, { isEstimating: true }); } } diff --git a/src/global/selectors/dapp.ts b/src/global/selectors/dapp.ts new file mode 100644 index 00000000..d3b1a683 --- /dev/null +++ b/src/global/selectors/dapp.ts @@ -0,0 +1,76 @@ +import type { ApiDappTransfer, ApiTokenWithPrice } from '../../api/types'; +import type { ExtendedDappTransfer, GlobalState } from '../types'; + +import { TONCOIN } from '../../config'; +import { Big } from '../../lib/big.js'; +import { bigintDivideToNumber } from '../../util/bigint'; +import { toDecimal } from '../../util/decimals'; +import memoize from '../../util/memoize'; +import { + getDappTransferActualToAddress, + isTokenTransferPayload, + isTransferPayloadDangerous, +} from '../../util/ton/transfer'; + +const selectExtendedDappTransactionsMemoized = memoize(( + transactions: ApiDappTransfer[] | undefined, + totalNetworkFee: bigint | undefined, +) => { + if (!transactions) { + return transactions; + } + + const feePerTransfer = totalNetworkFee && bigintDivideToNumber(totalNetworkFee, transactions.length || 1); + + return transactions.map((transfer) => ({ + ...transfer, + fee: feePerTransfer, + realToAddress: getDappTransferActualToAddress(transfer), + isDangerous: isTransferPayloadDangerous(transfer.payload), + })); +}); + +export function selectCurrentDappTransferExtendedTransactions(global: GlobalState) { + const { transactions, fee } = global.currentDappTransfer; + return selectExtendedDappTransactionsMemoized(transactions, fee); +} + +const selectCurrentDappTransferTotalAmountsMemoized = memoize(( + transactions: ApiDappTransfer[] | undefined, + totalNetworkFee: bigint | undefined, + tokensBySlug: Record, +) => { + const amountBySlug: Record = { + [TONCOIN.slug]: Big(toDecimal(totalNetworkFee ?? 0n, TONCOIN.decimals)), + }; + + for (const { payload, amount } of transactions ?? []) { + const amountDecimal = toDecimal(amount, TONCOIN.decimals); + amountBySlug[TONCOIN.slug] = amountBySlug[TONCOIN.slug].add(amountDecimal); + + if (isTokenTransferPayload(payload)) { + const { slug: tokenSlug, amount: tokenAmount } = payload; + const token = tokensBySlug[tokenSlug]; + + if (token) { + const tokenAmountDecimal = toDecimal(tokenAmount, token.decimals); + amountBySlug[tokenSlug] = (amountBySlug[tokenSlug] ?? Big(0)).add(tokenAmountDecimal); + } + } + } + + const totalCost = Object.entries(amountBySlug).reduce((sum, [tokenSlug, amount]) => { + const price = tokensBySlug[tokenSlug]?.quote.price ?? 0; + return sum + amount.toNumber() * price; + }, 0); + + return { + bySlug: amountBySlug, + cost: totalCost, + }; +}); + +export function selectCurrentDappTransferTotalAmounts(global: GlobalState) { + const { transactions, fee } = global.currentDappTransfer; + return selectCurrentDappTransferTotalAmountsMemoized(transactions, fee, global.tokenInfo.bySlug); +} diff --git a/src/global/selectors/index.ts b/src/global/selectors/index.ts index c97b8961..52e989d0 100644 --- a/src/global/selectors/index.ts +++ b/src/global/selectors/index.ts @@ -1,4 +1,5 @@ export * from './accounts'; +export * from './dapp'; export * from './tokens'; export * from './activities'; export * from './swap'; diff --git a/src/global/types.ts b/src/global/types.ts index 6c7947ca..14156ef5 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -403,6 +403,16 @@ export interface NftTransfer { collectionName?: string; } +/** A dapp transfer prepared to be shown in the UI */ +export interface ExtendedDappTransfer extends ApiDappTransfer { + /** The network fee portion of that transfer. Always measured in TON. Undefined means that it's unknown. */ + fee?: bigint; + /** The actual address where the transfer will go */ + realToAddress: string; + /** Whether the transfer should be treated with cautiousness, because its payload is unclear */ + isDangerous: boolean; +} + export type GlobalState = { DEBUG_capturedId?: number; @@ -490,8 +500,10 @@ export type GlobalState = { errorType?: SwapErrorType; shouldResetOnClose?: boolean; isLoading?: boolean; - // When set to `true`, the next swap estimation will show the loading indicator and will block the form - shouldEstimate?: boolean; + /** + * When is `true`, does several things: shows the estimating indicator in the UI, blocks the form submission, and + * instructs the UI and the actions to perform an estimation regardless. + */ isEstimating?: boolean; inputSource?: SwapInputSource; toAddress?: string; @@ -648,6 +660,7 @@ export type GlobalState = { url: string; title?: string; subtitle?: string; + keepNBSOpen?: boolean; }; currentQrScan?: { @@ -848,7 +861,6 @@ export interface ActionPayloads { closeExplore: undefined; closeAnyModal: undefined; - closeAllOverlays: undefined; submitSignature: { password: string }; clearSignatureError: undefined; cancelSignature: undefined; @@ -965,10 +977,18 @@ export interface ActionPayloads { addSiteToBrowserHistory: { url: string }; removeSiteFromBrowserHistory: { url: string }; - openBrowser: { url: string; title?: string; subtitle?: string }; + openBrowser: { url: string; title?: string; subtitle?: string; keepNBSOpen?: boolean }; closeBrowser: undefined; openSiteCategory: { id: number }; closeSiteCategory: undefined; + switchAccountAndOpenUrl: { + accountId?: string; + network?: ApiNetwork; + url: string; + isExternal?: boolean; + title?: string; + subtitle?: string; + }; apiUpdateDappConnect: ApiUpdateDappConnect; apiUpdateDappSendTransaction: ApiUpdateDappSendTransactions; diff --git a/src/hooks/usePreventPinchZoomGesture.ts b/src/hooks/usePreventPinchZoomGesture.ts index 937391bb..dc157e56 100644 --- a/src/hooks/usePreventPinchZoomGesture.ts +++ b/src/hooks/usePreventPinchZoomGesture.ts @@ -1,6 +1,6 @@ import { useEffect } from '../lib/teact/teact'; -import stopEvent from '../util/stopEvent'; +import { stopEvent } from '../util/domEvents'; import { IS_IOS, IS_PWA, IS_TOUCH_ENV } from '../util/windowEnvironment'; const metaViewport = document.querySelector('meta[name="viewport"]'); diff --git a/src/i18n/de.yaml b/src/i18n/de.yaml index 0508ca09..2a1f5d4b 100644 --- a/src/i18n/de.yaml +++ b/src/i18n/de.yaml @@ -285,7 +285,6 @@ Install our native app or browser extension.: Installieren Sie unsere native App Scam: Betrug Scam comment is hidden.: Betrügerischer Kommentar ist versteckt. Display: Anzeigen -$dapp_transfer_tokens_payload: Senden von %amount% von Ihrem Konto an %address% Connected Dapps: Verbundene Dapps Disconnect All Dapps: Alle Dapps trennen Disconnect: Trennen diff --git a/src/i18n/en.yaml b/src/i18n/en.yaml index 63242077..bb427bb8 100644 --- a/src/i18n/en.yaml +++ b/src/i18n/en.yaml @@ -285,7 +285,6 @@ Install our native app or browser extension.: Install our native app or browser Scam: Scam Scam comment is hidden.: Scam comment is hidden. Display: Display -$dapp_transfer_tokens_payload: Sending %amount% from your account to %address% Connected Dapps: Connected Dapps Disconnect All Dapps: Disconnect All Dapps Disconnect: Disconnect diff --git a/src/i18n/es.yaml b/src/i18n/es.yaml index 1957c4e3..19d88bff 100644 --- a/src/i18n/es.yaml +++ b/src/i18n/es.yaml @@ -285,7 +285,6 @@ Install our native app or browser extension.: Instala una aplicación nativa o u Scam: Estafa Scam comment is hidden.: El comentario estafa está oculto. Display: Mostrar -$dapp_transfer_tokens_payload: Enviando %amount% desde su monedero a %address% Nested Transaction: Transacción anidada Connected Dapps: Dapps conectadas Disconnect All Dapps: Desconectar todas las Dapps diff --git a/src/i18n/pl.yaml b/src/i18n/pl.yaml index 255c94f0..d3b8b737 100644 --- a/src/i18n/pl.yaml +++ b/src/i18n/pl.yaml @@ -287,7 +287,6 @@ Install our native app or browser extension.: Zainstaluj naszą natywną aplikac Scam: Oszustwo Scam comment is hidden.: Komentarz o oszustwie jest ukryty. Display: Wyświetl -$dapp_transfer_tokens_payload: Wysyłanie %amount% z twojego konta na %address% Connected Dapps: Połączone aplikacje Disconnect All Dapps: Rozłącz wszystkie aplikacje Disconnect: Rozłącz diff --git a/src/i18n/ru.yaml b/src/i18n/ru.yaml index ec0c7ef1..a98c76cc 100644 --- a/src/i18n/ru.yaml +++ b/src/i18n/ru.yaml @@ -283,7 +283,6 @@ Install our native app or browser extension.: Установите наше пр Scam: Скам Scam comment is hidden.: Комментарий скрыт. Display: Показать -$dapp_transfer_tokens_payload: Перевод %amount% с вашего аккаунта на %address% Nested Transaction: Вложенная транзакция Connected Dapps: Подключённые приложения Disconnect All Dapps: Отключить все приложения diff --git a/src/i18n/th.yaml b/src/i18n/th.yaml index 3e29b9ec..52b01fb0 100644 --- a/src/i18n/th.yaml +++ b/src/i18n/th.yaml @@ -285,7 +285,6 @@ Install our native app or browser extension.: ติดตั้งแอปห Scam: สแกม Scam comment is hidden.: หมายเหตุหลอกลวงถูกซ่อนอยู่. Display: แสดง -$dapp_transfer_tokens_payload: กำลังส่ง %amount% จากบัญชีของคุณไปยัง %address% Connected Dapps: dApps ที่เชื่อมต่อแล้ว Disconnect All Dapps: ยกเลิกการเชื่อมต่อทั้งหมด Disconnect: ยกเลิกการเชื่อมต่อ diff --git a/src/i18n/tr.yaml b/src/i18n/tr.yaml index 815eadbc..59c0010c 100644 --- a/src/i18n/tr.yaml +++ b/src/i18n/tr.yaml @@ -285,7 +285,6 @@ Install our native app or browser extension.: Yerel uygulamamızı veya tarayıc Scam: Sahtekarlık Scam comment is hidden.: Scam yorumu gizlendi. Display: Görüntüle -$dapp_transfer_tokens_payload: Hesabınızdan %adres%'e %amount% gönderiliyor Connected Dapps: Bağlı Dapp'ler Disconnect All Dapps: Tüm Dapp'lerin bağlantısını kesin Disconnect: Bağlantıyı kes diff --git a/src/i18n/uk.yaml b/src/i18n/uk.yaml index c3ffca44..6878a640 100644 --- a/src/i18n/uk.yaml +++ b/src/i18n/uk.yaml @@ -284,7 +284,6 @@ Install our native app or browser extension.: Встановіть наш дод Scam: Скам Scam comment is hidden.: Коментар приховано. Display: Показати -$dapp_transfer_tokens_payload: Переказ %amount% з вашого акаунта на %address% Nested Transaction: Вкладена транзакція Connected Dapps: Підключені додатки Disconnect All Dapps: Відключити всі додатки diff --git a/src/i18n/zh-Hans.yaml b/src/i18n/zh-Hans.yaml index 39ed8b61..547c2664 100644 --- a/src/i18n/zh-Hans.yaml +++ b/src/i18n/zh-Hans.yaml @@ -277,7 +277,6 @@ Install our native app or browser extension.: 通过安装浏览器扩展程序 Scam: 骗局 Scam comment is hidden.: 诈骗评论被隐藏。 Display: 展示 -$dapp_transfer_tokens_payload: 从您的账户发送 %amount% 到 %address% Nested Transaction: 嵌套事务 Connected Dapps: 连接的 Dapps Disconnect All Dapps: 断开所有 Dapps diff --git a/src/i18n/zh-Hant.yaml b/src/i18n/zh-Hant.yaml index 115b954f..35bbe796 100644 --- a/src/i18n/zh-Hant.yaml +++ b/src/i18n/zh-Hant.yaml @@ -277,7 +277,6 @@ Install our native app or browser extension.: 通過安裝瀏覽器擴展程序 Scam: 騙局 Scam comment is hidden.: 詐騙評論被隱藏。 Display: 展示 -$dapp_transfer_tokens_payload: 從您的帳戶發送 %amount% 到 %address% Nested Transaction: 嵌套事務 Connected Dapps: 連接的 Dapps Disconnect All Dapps: 斷開所有 Dapps diff --git a/src/util/capacitor/notifications.ts b/src/util/capacitor/notifications.ts index 7a0113fb..1ace2c23 100644 --- a/src/util/capacitor/notifications.ts +++ b/src/util/capacitor/notifications.ts @@ -10,30 +10,42 @@ import { selectAccountIdByAddress } from '../../global/selectors'; import { selectNotificationTonAddressesSlow } from '../../global/selectors/notifications'; import { callApi } from '../../api'; import { MINUTE } from '../../api/constants'; +import { pick } from '../iteratees'; import { logDebugError } from '../logs'; import { getCapacitorPlatform } from './platform'; -interface BaseMessageData { - address: string; -} - -interface ShowTxMessageData extends BaseMessageData { +interface ShowTxMessageData { action: 'swap' | 'nativeTx'; + address: string; txId: string; } -interface StakingMessageData extends BaseMessageData { +interface StakingMessageData { action: 'staking'; + address: string; stakingType: ApiStakingType; stakingId: string; logId: string; } -interface OpenActivityMessageData extends BaseMessageData { +interface OpenActivityMessageData { action: 'jettonTx'; + address: string; slug: string; } -type MessageData = StakingMessageData | OpenActivityMessageData | ShowTxMessageData; + +interface OpenUrlMessageData { + action: 'openUrl'; + // The wallet address that should be switched to before opening the URL + address?: string; + url: string; + // For the following parameters, see the `openUrl` options + isExternal?: boolean; + title?: string; + subtitle?: string; +} + +type MessageData = StakingMessageData | OpenActivityMessageData | ShowTxMessageData | OpenUrlMessageData; let nextUpdatePushNotifications = 0; @@ -83,22 +95,25 @@ function handlePushNotificationActionPerformed(notification: ActionPerformed) { showAnyAccountTx, showAnyAccountTokenActivity, openAnyAccountStakingInfo, - closeAllOverlays, + switchAccountAndOpenUrl, } = getActions(); const global = getGlobal(); const notificationData = notification.notification.data as MessageData; const { action, address } = notificationData; - const accountId = selectAccountIdByAddress( - global, - 'ton', - address, - ); + const accountId = address === undefined ? undefined : selectAccountIdByAddress(global, 'ton', address); + const network = 'mainnet'; - if (!accountId) return; + if (action === 'openUrl') { + switchAccountAndOpenUrl({ + accountId, + network, + ...pick(notificationData, ['url', 'isExternal', 'title', 'subtitle']), + }); + return; + } - const network = 'mainnet'; + if (!accountId) return; - closeAllOverlays(); if (action === 'nativeTx' || action === 'swap') { const { txId } = notificationData; showAnyAccountTx({ accountId, txId, network }); diff --git a/src/util/deeplink/index.ts b/src/util/deeplink/index.ts index d725c12a..23cf33df 100644 --- a/src/util/deeplink/index.ts +++ b/src/util/deeplink/index.ts @@ -1,6 +1,6 @@ import { getActions, getGlobal } from '../../global'; -import type { ActionPayloads } from '../../global/types'; +import type { ActionPayloads, GlobalState } from '../../global/types'; import { ActiveTab, ContentTab } from '../../global/types'; import { @@ -60,7 +60,7 @@ export function openDeeplinkOrUrl(url: string, isExternal = false, isFromInAppBr if (isTonDeeplink(url) || isTonConnectDeeplink(url) || isSelfDeeplink(url)) { void processDeeplink(url, isFromInAppBrowser); } else { - void openUrl(url, isExternal); + void openUrl(url, { isExternal }); } } @@ -85,9 +85,6 @@ export function isTonDeeplink(url: string) { // Returns `true` if the link has been processed, ideally resulting to a UI action async function processTonDeeplink(url: string): Promise { - const params = parseTonDeeplink(url); - if (!params) return false; - await waitRender(); const actions = getActions(); @@ -96,6 +93,39 @@ async function processTonDeeplink(url: string): Promise { return false; } + const startTransferParams = parseTonDeeplink(url, global); + + if (!startTransferParams) { + return false; + } + + if ('error' in startTransferParams) { + actions.showError({ error: startTransferParams.error }); + return true; + } + + actions.startTransfer({ + isPortrait: getIsPortrait(), + ...startTransferParams, + }); + + if (getIsLandscape()) { + actions.setLandscapeActionsActiveTabIndex({ index: ActiveTab.Transfer }); + } + + return true; +} + +/** + * Parses a TON deeplink and checks whether the transfer can be initiated. + * Returns `undefined` if the URL is not a TON deeplink. + * If there is `error` in the result, there is a problem with the deeplink (the string is to translate via `lang`). + * Otherwise, returned the parsed transfer parameters. + */ +export function parseTonDeeplink(url: string, global: GlobalState) { + const params = rawParseTonDeeplink(url); + if (!params) return undefined; + const { toAddress, amount, @@ -108,8 +138,7 @@ async function processTonDeeplink(url: string): Promise { const verifiedAddress = isValidAddressOrDomain(toAddress, 'ton') ? toAddress : undefined; - const startTransferParams: ActionPayloads['startTransfer'] = { - isPortrait: getIsPortrait(), + const transferParams: Omit, 'isPortrait'> & { error?: string } = { toAddress: verifiedAddress, tokenSlug: TONCOIN.slug, amount, @@ -124,46 +153,32 @@ async function processTonDeeplink(url: string): Promise { : undefined; if (!globalToken) { - actions.showError({ - error: '$unknown_token_address', - }); - return true; - } - const accountToken = selectAccountTokenBySlug(global, globalToken.slug); - - if (!accountToken) { - actions.showError({ - error: '$dont_have_required_token', - }); - return true; + transferParams.error = '$unknown_token_address'; + } else { + const accountToken = selectAccountTokenBySlug(global, globalToken.slug); + + if (!accountToken) { + transferParams.error = '$dont_have_required_token'; + } else { + transferParams.tokenSlug = globalToken.slug; + } } - - startTransferParams.tokenSlug = globalToken.slug; } if (nftAddress) { const accountNft = selectCurrentAccountNftByAddress(global, nftAddress); if (!accountNft) { - actions.showError({ - error: '$dont_have_required_nft', - }); - return true; + transferParams.error = '$dont_have_required_nft'; + } else { + transferParams.nfts = [accountNft]; } - - startTransferParams.nfts = [accountNft]; } - actions.startTransfer(omitUndefined(startTransferParams)); - - if (getIsLandscape()) { - actions.setLandscapeActionsActiveTabIndex({ index: ActiveTab.Transfer }); - } - - return true; + return omitUndefined(transferParams); } -export function parseTonDeeplink(value?: string) { +function rawParseTonDeeplink(value?: string) { if (typeof value !== 'string' || !isTonDeeplink(value) || !value.includes('/transfer/')) { return undefined; } @@ -221,7 +236,7 @@ async function processTonConnectDeeplink(url: string, isFromInAppBrowser = false closeLoadingOverlay(); if (returnUrl) { - openUrl(returnUrl, !isFromInAppBrowser); + void openUrl(returnUrl, { isExternal: !isFromInAppBrowser }); } return true; @@ -345,8 +360,11 @@ export async function processSelfDeeplink(deeplink: string): Promise { return false; } -// Parses only transfer params from the deeplink. Returns `undefined` if it's not a transfer deeplink. -export function parseDeeplinkTransferParams(url: string) { +/** + * Parses a deeplink and checks whether the transfer can be initiated. + * See `parseTonDeeplink` for information about the returned values. + */ +export function parseDeeplinkTransferParams(url: string, global: GlobalState) { let tonDeeplink = url; if (isSelfDeeplink(url)) { @@ -363,7 +381,7 @@ export function parseDeeplinkTransferParams(url: string) { } } - return parseTonDeeplink(tonDeeplink); + return parseTonDeeplink(tonDeeplink, global); } function convertSelfDeeplinkToSelfUrl(deeplink: string) { diff --git a/src/util/domEvents.ts b/src/util/domEvents.ts new file mode 100644 index 00000000..12f17f63 --- /dev/null +++ b/src/util/domEvents.ts @@ -0,0 +1,18 @@ +import type React from '../lib/teact/teact'; + +export const stopEvent = (e: React.UIEvent | Event | React.FormEvent) => { + e.stopPropagation(); + e.preventDefault(); +}; + +export function listenOnce( + target: Pick, + name: string, + handler: (event: T) => void, +) { + const handleEvent = (event: Event) => { + target.removeEventListener(name, handleEvent); + handler(event as T); + }; + target.addEventListener(name, handleEvent); +} diff --git a/src/util/openUrl.ts b/src/util/openUrl.ts index 56fe333d..e1b367c0 100644 --- a/src/util/openUrl.ts +++ b/src/util/openUrl.ts @@ -2,10 +2,17 @@ import { AppLauncher } from '@capacitor/app-launcher'; import { getActions } from '../global'; import { IS_CAPACITOR } from '../config'; +import { isTelegramUrl } from './url'; -export async function openUrl(url: string, isExternal?: boolean, title?: string, subtitle?: string) { - if (IS_CAPACITOR && !isExternal && url.startsWith('http')) { - getActions().openBrowser({ url, title, subtitle }); +export async function openUrl( + url: string, options?: { isExternal?: boolean; title?: string; subtitle?: string }, +) { + if (IS_CAPACITOR && !options?.isExternal && url.startsWith('http') && !isTelegramUrl(url)) { + getActions().openBrowser({ + url, + title: options?.title, + subtitle: options?.subtitle, + }); } else { const couldOpenApp = IS_CAPACITOR && await openAppSafe(url); if (!couldOpenApp) { @@ -14,9 +21,11 @@ export async function openUrl(url: string, isExternal?: boolean, title?: string, } } -export function handleOpenUrl(e: React.MouseEvent) { +export function handleOpenUrl( + e: React.MouseEvent, +) { e.preventDefault(); - openUrl(e.currentTarget.href); + void openUrl(e.currentTarget.href); } async function openAppSafe(url: string) { diff --git a/src/util/stopEvent.ts b/src/util/stopEvent.ts deleted file mode 100644 index 65e03f0f..00000000 --- a/src/util/stopEvent.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type React from '../lib/teact/teact'; - -const stopEvent = (e: React.UIEvent | Event | React.FormEvent) => { - e.stopPropagation(); - e.preventDefault(); -}; - -export default stopEvent; diff --git a/src/util/ton/transfer.ts b/src/util/ton/transfer.ts new file mode 100644 index 00000000..8304dafc --- /dev/null +++ b/src/util/ton/transfer.ts @@ -0,0 +1,32 @@ +import type { + ApiDappTransfer, + ApiNftTransferPayload, + ApiParsedPayload, + ApiTokensTransferNonStandardPayload, + ApiTokensTransferPayload, +} from '../../api/types'; + +export function isNftTransferPayload(payload: ApiParsedPayload | undefined): payload is ApiNftTransferPayload { + return payload?.type === 'nft:transfer'; +} + +export function isTokenTransferPayload( + payload: ApiParsedPayload | undefined, +): payload is ApiTokensTransferPayload | ApiTokensTransferNonStandardPayload { + return payload?.type === 'tokens:transfer' || payload?.type === 'tokens:transfer-non-standard'; +} + +export function getDappTransferActualToAddress(transfer: ApiDappTransfer) { + // This function implementation is not complete. That is, other transfer types may have another actual "to" address. + if (isNftTransferPayload(transfer.payload)) { + return transfer.payload.newOwner; + } + if (isTokenTransferPayload(transfer.payload)) { + return transfer.payload.destination; + } + return transfer.toAddress; +} + +export function isTransferPayloadDangerous(payload: ApiParsedPayload | undefined) { + return payload?.type === 'unknown'; +} diff --git a/tests/init.js b/tests/init.ts similarity index 86% rename from tests/init.js rename to tests/init.ts index 0968d58e..d1b1fea7 100644 --- a/tests/init.js +++ b/tests/init.ts @@ -28,8 +28,8 @@ Object.defineProperty(window, 'matchMedia', { }); Object.defineProperty(global.Element.prototype, 'innerText', { - get() { - const el = this.cloneNode(true); // can skip if mutability isn't a concern + get(this: Element) { + const el = this.cloneNode(true) as typeof this; // can skip if mutability isn't a concern el.querySelectorAll('script,style') .forEach((s) => s.remove()); return el.textContent; @@ -64,3 +64,6 @@ Object.defineProperty(global, 'indexedDB', { }, }, }); + +// Importing dynamically, because the file execution fails without the above mocks +import('./initGlobal'); diff --git a/tests/initGlobal.ts b/tests/initGlobal.ts new file mode 100644 index 00000000..a20aaf19 --- /dev/null +++ b/tests/initGlobal.ts @@ -0,0 +1,5 @@ +import { setGlobal } from '../src/global'; +import { INITIAL_STATE } from '../src/global/initialState'; +import { cloneDeep } from '../src/util/iteratees'; + +setGlobal(cloneDeep(INITIAL_STATE));