diff --git a/.github/workflows/statoscope-comment.jora b/.github/workflows/statoscope-comment.jora index 1a8a0cad..6df16a03 100644 --- a/.github/workflows/statoscope-comment.jora +++ b/.github/workflows/statoscope-comment.jora @@ -36,7 +36,7 @@ $getSizeByChunks: => files.(getAssetSize($$, true)).reduce(=> size + $$, 0); } }, validation: { - $messages: resolveInputFile().compilations.[hash].(hash.validation_getItems()); + $messages: $after.compilations.[hash].(hash.validation_getItems()); $messages, total: $messages.size() } diff --git a/.github/workflows/statoscope-comment.js b/.github/workflows/statoscope-comment.js index 4965c849..7ab56298 100644 --- a/.github/workflows/statoscope-comment.js +++ b/.github/workflows/statoscope-comment.js @@ -6,5 +6,5 @@ module.exports = ({ initialSize, bundleSize, validation, prNumber}) => `**📦 S **🕵️ Validation errors:** ${validation.total > 0 ? validation.total : '✅'} -Full Statoscope report could be found [here️](https://deploy-preview-${prNumber}--mytonwallet-e5kxpi8iga.netlify.app/report.html) +Full Statoscope report could be found [here️](https://deploy-preview-${prNumber}--mytonwallet-e5kxpi8iga.netlify.app/statoscope-report.html) / [diff](https://deploy-preview-${prNumber}--mytonwallet-e5kxpi8iga.netlify.app/statoscope-report.html#diff) `; diff --git a/.github/workflows/upload-main-stats.yml b/.github/workflows/statoscope-upload-reference-statistics.yml similarity index 64% rename from .github/workflows/upload-main-stats.yml rename to .github/workflows/statoscope-upload-reference-statistics.yml index 967b3f66..3309c335 100644 --- a/.github/workflows/upload-main-stats.yml +++ b/.github/workflows/statoscope-upload-reference-statistics.yml @@ -1,4 +1,4 @@ -name: Upload main stats +name: Statoscope upload reference statistics on: push: @@ -12,7 +12,7 @@ jobs: node-version: [18.x] steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: persist-credentials: false - name: Reconfigure git to use HTTPS authentication @@ -24,8 +24,8 @@ jobs: - name: Install run: npm ci - name: Build - run: npm run build:production; cp ./public/build-stats.json ./reference.json - - uses: actions/upload-artifact@v2 + run: npm run build:production; cp ./public/statoscope-build-statistics.json ./statoscope-reference.json + - uses: actions/upload-artifact@v3 with: - name: main-stats - path: ./reference.json + name: statoscope-reference + path: ./statoscope-reference.json diff --git a/.github/workflows/statoscope.yml b/.github/workflows/statoscope.yml index b90d16e6..8dd428ee 100644 --- a/.github/workflows/statoscope.yml +++ b/.github/workflows/statoscope.yml @@ -57,20 +57,22 @@ jobs: node_modules key: ${{ github.sha }} - name: Build - run: npm run build:production; cp public/build-stats.json input.json - - name: Download reference stats + run: npm run build:production + - name: Download reference statistics uses: dawidd6/action-download-artifact@v2 with: - workflow: upload-main-stats.yml + workflow: statoscope-upload-reference-statistics.yml workflow_conclusion: success - name: main-stats + name: statoscope-reference path: ./ continue-on-error: true + - name: Prepare statoscope input + run: cp public/statoscope-build-statistics.json input.json; mv statoscope-reference.json reference.json - name: Validate run: npm run statoscope:validate-diff - name: Query stats if: "github.event_name == 'pull_request'" - run: cat .github/workflows/statoscope-comment.jora | npx --no-install @statoscope/cli query --input input.json --input reference.json > result.json + run: cat .github/workflows/statoscope-comment.jora | npx --no-install @statoscope/cli query --input input.json --input reference.json > statoscope-result.json - name: Hide bot comments uses: int128/hide-comment-action@v1 - name: Comment PR @@ -78,5 +80,7 @@ jobs: uses: actions/github-script@v6.0.0 with: script: | - const createStatoscopeComment = require('./dev/createStatoscopeComment'); + const createStatoscopeComment = require('./dev/statoscopeCreateComment'); await createStatoscopeComment({ github, context, core }) + - name: Cleanup + run: rm input.json; rm reference.json diff --git a/.gitignore b/.gitignore index 0982c478..f717c9b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ node_modules dist dist-electron -MyTonWallet.zip +MyTonWallet-chrome.zip MyTonWallet-firefox.zip +MyTonWallet-opera.zip .cache .env* !.env.example @@ -13,9 +14,10 @@ dev/perf/screenshot* .DS_store .DS_Store test-results -public/build-stats.json -public/reference.json +public/statoscope-build-statistics.json +public/statoscope-reference.json public/statoscope-report.html +public/statoscope-master-reference.json trash/ coverage/ src/i18n/en.json diff --git a/README.md b/README.md index 3fcd5053..35c1770e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,16 @@ The wallet is **self-custodial and safe**. The developers **do not** have access to funds, browser history or any other information. We focus on **speed**, **size** and **attention to detail**. We try to avoid using third-party libraries to ensure maximum reliability and safety, and also to lower the bundle size. +## Table of contents + +- [Requirements](#requirements) +- [Local Setup](#local-setup) +- [Dev Mode](#dev-mode) +- [Installing App on Linux](#installing-app-on-linux) +- [Electron](./docs/electron.md) +- [Verifying GPG Signatures](./docs/gpg-check.md) +- [Support Us](#support-us) + ## Requirements Ready to build on **macOS** and **Linux**. @@ -29,86 +39,10 @@ npm i npm run dev ``` -## Electron - -Electron allows to build native application, that can be installed and run on Windows, macOS and Linux. - -### Installing app on Linux +## Installing App on Linux In order for the application to work correctly and be displayed in the Linux menu, you need to install the AppImage via [AppImageLauncher](https://github.com/TheAssassin/AppImageLauncher) -#### NPM scripts - -- `npm run dev:electron` - -Run Electron in development mode, concurrently starts 3 processes with watch for changes: main (main Electron process), renderer (FE code) and Webpack for Electron (compiles main Electron process from TypeScript). - -- `npm run electron:webpack` - -The main process code for Electron, which includes preload functionality, is written in TypeScript and is compiled using the `webpack-electron.config.js` configuration to generate JavaScript code. - -- `npm run electron:build` - -Prepare renderer (FE code) build, compile Electron main process code, install and build native dependencies, is used before packaging or publishing. - -- `npm run electron:staging` - -Create packages for macOS, Windows and Linux in `dist-electron` folders with `APP_ENV` as `staging` (allows to open DevTools, includes sourcemaps and does not minify built JavaScript code), can be used for manual distribution and testing packaged application. - -- `npm run electron:production` - -Create packages for macOS, Windows and Linux in `dist-electron` folders with `APP_ENV` as `production` (disabled DevTools, minified built JavaScript code), can be used for manual distribution and testing packaged application. - -- `npm run deploy:electron` - -Create packages for macOS, Windows and Linux in `dist-electron` folder and publish release to GitHub, which allows supporting autoupdates. See [GitHub release workflow](#github-release) for more info. - -#### Code signing on MacOS - -To sign the code of your application, follow these steps: - -- Install certificates to `login` folder of your Keychain. -- Download and install `Developer ID - G2` certificate from the [Apple PKI](https://www.apple.com/certificateauthority/) page. -- Under the Keychain application, go to the private key associated with your developer certificate. Then do `key > Get Info > Access Control`. Down there, make sure your application (Xcode) is in the list `Always allow access by these applications` and make sure `Confirm before allowing access` is turned on. -- A valid and appropriate identity from your keychain will be automatically used when you publish your application. - -More info in the [official documentation](https://www.electronjs.org/docs/latest/tutorial/code-signing). - -#### Notarize on MacOS - -Application notarization is done automatically in [electron-builder](https://github.com/electron-userland/electron-builder/) module, which requires `APPLE_ID` and `APPLE_APP_SPECIFIC_PASSWORD` environment variables to be passed. - -How to obtain app-specific password: - -- Sign in to [appleid.apple.com](appleid.apple.com). -- In the "Sign-In and Security" section, select "App-Specific Passwords". -- Select "Generate an app-specific password" or select the Add button, then follow the steps on your screen. - -#### GitHub release - -##### GitHub access token - -In order to publish new release, you need to add GitHub access token to `.env`. Generate a GitHub access token by going to https://github.com/settings/tokens/new. The access token should have the repo scope/permission. Once you have the token, assign it to an environment variable: - -``` -# .env -GH_TOKEN="{YOUR_TOKEN_HERE}" -``` - -##### Publish settings - -Publish configuration in `electron-builder.yml` config file allows to set GitHub repository owner/name. - -##### Release workflow - -- Draft a new release on GitHub. Create new tag version with the value of `version` in your application `package.json`, and prefix it with `v`. “Release title” can be anything you want. - - For example, if your application `package.json` version is `1.0`, your draft’s tag version would be `v1.0`. - -- Save draft release -- Run `npm run electron:publish`, which will upload build artefacts to newly reated release. -- Once you are done, publish the release. GitHub will tag the latest commit. - ## Support Us If you like what we do, feel free to contribute by creating a pull request, or just support us using this TON wallet: `EQAIsixsrb93f9kDyplo_bK5OdgW5r0WCcIJZdGOUG1B282S`. We appreciate it a lot! diff --git a/deploy/copy_to_dist.sh b/deploy/copy_to_dist.sh index 028fc535..f22d8d0c 100755 --- a/deploy/copy_to_dist.sh +++ b/deploy/copy_to_dist.sh @@ -1,8 +1,6 @@ #!/usr/bin/env bash cp -R ./public/* ${1:-"dist"} -rm ./dist/statoscope-report.html -rm ./dist/build-stats.json cp ./src/lib/rlottie/rlottie-wasm.js ${1:-"dist"} cp ./src/lib/rlottie/rlottie-wasm.wasm ${1:-"dist"} diff --git a/deploy/package_extension.sh b/deploy/package_extension.sh new file mode 100755 index 00000000..af31d84a --- /dev/null +++ b/deploy/package_extension.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -e + +if [ -z "$1" ]; then + echo "Missing argument: " + exit 1 +fi + +TARGET="$1" + +cp -R ./public/* ./src/lib/rlottie/rlottie-wasm.js ./src/lib/rlottie/rlottie-wasm.wasm ./dist/ + +rm -rf ./dist/statoscope-* \ + ./dist/get \ + ./dist/background-electron-dmg.tiff \ + ./dist/electron-entitlements.mac.plist \ + ./dist/icon-electron-* \ + ./dist/site.webmanifest + +rm -f "MyTonWallet-$TARGET.zip" + +zip -r -X "MyTonWallet-$TARGET.zip" dist/* diff --git a/dev/createStatoscopeComment.js b/dev/statoscopeCreateComment.js similarity index 67% rename from dev/createStatoscopeComment.js rename to dev/statoscopeCreateComment.js index 5b2bcf93..54903940 100644 --- a/dev/createStatoscopeComment.js +++ b/dev/statoscopeCreateComment.js @@ -1,11 +1,11 @@ /* eslint-env node */ const fs = require('fs'); -const createPrComment = require('./createPrComment'); +const createPrComment = require('./statoscopeCreatePrComment'); const template = require('../.github/workflows/statoscope-comment'); module.exports = async ({ github, context }) => { - const data = JSON.parse(fs.readFileSync('result.json', 'utf8')); + const data = JSON.parse(fs.readFileSync('statoscope-result.json', 'utf8')); data.prNumber = context.issue.number; const body = template(data); diff --git a/dev/createPrComment.js b/dev/statoscopeCreatePrComment.js similarity index 100% rename from dev/createPrComment.js rename to dev/statoscopeCreatePrComment.js diff --git a/docs/electron.md b/docs/electron.md new file mode 100644 index 00000000..edffd1ce --- /dev/null +++ b/docs/electron.md @@ -0,0 +1,75 @@ +# Electron + +Electron allows to build native application, that can be installed and run on Windows, macOS and Linux. + +## NPM scripts + +- `npm run dev:electron` + +Run Electron in development mode, concurrently starts 3 processes with watch for changes: main (main Electron process), renderer (FE code) and Webpack for Electron (compiles main Electron process from TypeScript). + +- `npm run electron:webpack` + +The main process code for Electron, which includes preload functionality, is written in TypeScript and is compiled using the `webpack-electron.config.js` configuration to generate JavaScript code. + +- `npm run electron:build` + +Prepare renderer (FE code) build, compile Electron main process code, install and build native dependencies, is used before packaging or publishing. + +- `npm run electron:staging` + +Create packages for macOS, Windows and Linux in `dist-electron` folders with `APP_ENV` as `staging` (allows to open DevTools, includes sourcemaps and does not minify built JavaScript code), can be used for manual distribution and testing packaged application. + +- `npm run electron:production` + +Create packages for macOS, Windows and Linux in `dist-electron` folders with `APP_ENV` as `production` (disabled DevTools, minified built JavaScript code), can be used for manual distribution and testing packaged application. + +- `npm run deploy:electron` + +Create packages for macOS, Windows and Linux in `dist-electron` folder and publish release to GitHub, which allows supporting autoupdates. See [GitHub release workflow](#github-release) for more info. + +## Code signing on MacOS + +To sign the code of your application, follow these steps: + +- Install certificates to `login` folder of your Keychain. +- Download and install `Developer ID - G2` certificate from the [Apple PKI](https://www.apple.com/certificateauthority/) page. +- Under the Keychain application, go to the private key associated with your developer certificate. Then do `key > Get Info > Access Control`. Down there, make sure your application (Xcode) is in the list `Always allow access by these applications` and make sure `Confirm before allowing access` is turned on. +- A valid and appropriate identity from your keychain will be automatically used when you publish your application. + +More info in the [official documentation](https://www.electronjs.org/docs/latest/tutorial/code-signing). + +## Notarize on MacOS + +Application notarization is done automatically in [electron-builder](https://github.com/electron-userland/electron-builder/) module, which requires `APPLE_ID` and `APPLE_APP_SPECIFIC_PASSWORD` environment variables to be passed. + +How to obtain app-specific password: + +- Sign in to [appleid.apple.com](appleid.apple.com). +- In the "Sign-In and Security" section, select "App-Specific Passwords". +- Select "Generate an app-specific password" or select the Add button, then follow the steps on your screen. + +## GitHub release + +### GitHub access token + +In order to publish new release, you need to add GitHub access token to `.env`. Generate a GitHub access token by going to https://github.com/settings/tokens/new. The access token should have the repo scope/permission. Once you have the token, assign it to an environment variable: + +``` +# .env +GH_TOKEN="{YOUR_TOKEN_HERE}" +``` + +### Publish settings + +Publish configuration in `electron-builder.yml` config file allows to set GitHub repository owner/name. + +### Release workflow + +- Draft a new release on GitHub. Create new tag version with the value of `version` in your application `package.json`, and prefix it with `v`. “Release title” can be anything you want. + + For example, if your application `package.json` version is `1.0`, your draft’s tag version would be `v1.0`. + +- Save draft release +- Run `npm run electron:publish`, which will upload build artefacts to newly reated release. +- Once you are done, publish the release. GitHub will tag the latest commit. diff --git a/docs/gpg-check.md b/docs/gpg-check.md new file mode 100644 index 00000000..5140c80c --- /dev/null +++ b/docs/gpg-check.md @@ -0,0 +1,44 @@ +# Verifying GPG signatures of MyTonWallet using macOS or Linux command line +This can be used to verify the authenticity of MyTonWallet binaries/sources. + +Download only from https://mytonwallet.app/get or https://github.com/mytonwalletorg/mytonwallet/releases and remember to check the gpg signature again every time you download a new version. + +## Obtain public GPG key for Mytonwallet Dev +In a terminal enter (or copy): + +```shell +gpg --keyserver keys.openpgp.org --recv-keys 9F14486135531F4127DF8CAD52978EAFD01FD271 +``` + +You should be able to substitute any public GPG keyserver if keys.openpgp.org is (temporarily) not working + +## Download MyTonWallet and signature file (.asc) +Download the `MyTonWallet-.dmg` (or `.exe` or `.AppImage` file). Download the signature file with the same name and extension `.asc`. + +## Verify GPG signature +Run the following command from the same directory you saved the files replacing with the one actually downloaded: + +```shell +gpg --verify .asc +``` + +The message should say: + +```shell +Good signature from "Mytonwallet Dev " +``` + +and + +```shell +Primary key fingerprint: 9F14 4861 3553 1F41 27DF 8CAD 5297 8EAF D01F D271 +``` + +You can ignore this: + +```shell +WARNING: This key is not certified with a trusted signature! +gpg: There is no indication that the signature belongs to the owner. +``` + +(It simply means you have not established a web of trust with other GPG users.) diff --git a/package-lock.json b/package-lock.json index 1c87e438..a8986ae5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mytonwallet", - "version": "1.15.2", + "version": "1.15.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mytonwallet", - "version": "1.15.2", + "version": "1.15.3", "license": "GPL-3.0-or-later", "dependencies": { "@ledgerhq/hw-transport-webhid": "^6.27.12", @@ -37,8 +37,8 @@ "@babel/register": "^7.21.0", "@peculiar/webcrypto": "^1.3.2", "@playwright/test": "^1.31.2", - "@statoscope/cli": "^5.26.2", - "@statoscope/webpack-plugin": "^5.26.2", + "@statoscope/cli": "^5.27.0", + "@statoscope/webpack-plugin": "^5.27.0", "@testing-library/jest-dom": "^5.16.5", "@tonconnect/protocol": "^2.2.5", "@types/bn.js": "^5.1.1", @@ -118,7 +118,6 @@ "stylelint-order": "^5.0.0", "typescript": "^5.0.2", "webpack": "^5.76.2", - "webpack-bundle-analyzer": "^4.8.0", "webpack-dev-server": "^4.13.1" }, "engines": { @@ -3981,11 +3980,6 @@ "fsevents": "2.3.2" } }, - "node_modules/@polka/url": { - "version": "1.0.0-next.21", - "dev": true, - "license": "MIT" - }, "node_modules/@react-dnd/asap": { "version": "4.0.1", "license": "MIT" @@ -4037,23 +4031,23 @@ } }, "node_modules/@statoscope/cli": { - "version": "5.26.2", - "resolved": "https://registry.npmjs.org/@statoscope/cli/-/cli-5.26.2.tgz", - "integrity": "sha512-Ft6L1fMsK3En8Hblbf1Lm6sTWWbFuiD33IXx3Wr4sD91DLAtpGXRKf52yki5Ys6h5xeQTkezyXIkOAAfQnbFbw==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/@statoscope/cli/-/cli-5.27.0.tgz", + "integrity": "sha512-AUauUc932Q5daSBPt9jjl8zLiCEk58zk6ZqMx9S++X8f9YZxI/fdSBeFWGwwlUZRaXmIWrQK6nvjbo4Yk5OA9w==", "dev": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.7", - "@statoscope/config": "5.22.0", + "@statoscope/config": "5.27.0", "@statoscope/helpers": "5.25.0", - "@statoscope/report-writer": "5.25.1", + "@statoscope/report-writer": "5.27.0", "@statoscope/stats": "5.14.1", - "@statoscope/stats-extension-custom-reports": "5.25.0", - "@statoscope/stats-validator": "5.22.0", - "@statoscope/stats-validator-reporter-console": "5.22.0", - "@statoscope/stats-validator-reporter-stats-report": "5.26.2", - "@statoscope/types": "5.22.0", - "@statoscope/webpack-model": "5.26.2", - "@statoscope/webpack-ui": "5.26.2", + "@statoscope/stats-extension-custom-reports": "5.27.0", + "@statoscope/stats-validator": "5.27.0", + "@statoscope/stats-validator-reporter-console": "5.27.0", + "@statoscope/stats-validator-reporter-stats-report": "5.27.0", + "@statoscope/types": "5.27.0", + "@statoscope/webpack-model": "5.27.0", + "@statoscope/webpack-ui": "5.27.0", "@types/yargs": "^17.0.10", "open": "^8.4.0", "yargs": "^17.5.1" @@ -4066,12 +4060,12 @@ } }, "node_modules/@statoscope/config": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@statoscope/config/-/config-5.22.0.tgz", - "integrity": "sha512-zGtvixqWVam0oL2bG60rQT3uZDmTOuPBDLtQ5WjSv2St7Nk0fbdEb1d6avm2QR26dJWj0eTIsmusM3pUrxJzCQ==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/@statoscope/config/-/config-5.27.0.tgz", + "integrity": "sha512-BsWja2WFDbqIweOl2x1dAvzNbQJ2FyEuL2KhKut/FTJUlsZYWm0OVdZjzdf1Ek7ZmLTLyWXWDdp9IF9yXmwIsQ==", "dev": true, "dependencies": { - "@statoscope/types": "5.22.0", + "@statoscope/types": "5.27.0", "chalk": "^4.1.2" } }, @@ -4198,9 +4192,9 @@ "dev": true }, "node_modules/@statoscope/report-writer": { - "version": "5.25.1", - "resolved": "https://registry.npmjs.org/@statoscope/report-writer/-/report-writer-5.25.1.tgz", - "integrity": "sha512-5rt2Zu9hfoZ0/zsrE1KaqdQCqLb2Fz6uRXzfaX7h5kgqoHlI24MxduTHJ3gHndBtACtvofT5r1F0G9zEXeWUyw==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/@statoscope/report-writer/-/report-writer-5.27.0.tgz", + "integrity": "sha512-h4Xyy2JFmaDUXBwevC6w5BI86OU0ZMYNyhty5AguWHRUAifOhEfemLHdvz/RJQ9gVjnqZ135omAtHaq6JMersw==", "dev": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.7", @@ -4225,57 +4219,57 @@ } }, "node_modules/@statoscope/stats-extension-custom-reports": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/@statoscope/stats-extension-custom-reports/-/stats-extension-custom-reports-5.25.0.tgz", - "integrity": "sha512-7hjYbP+SJQyjpS3JmOr+dk2+r8g9ZixFbHYGfDVkXdfqjaYLPGRdWpKc44uZB9t1epg10YSI3Hi1w7PRZ7esBg==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/@statoscope/stats-extension-custom-reports/-/stats-extension-custom-reports-5.27.0.tgz", + "integrity": "sha512-X8NscKMfWWCwBNC1enq1s+TAIvcwHwTt5i6sy21xZgrwkK8QQ/lCIqGVwKoCQ9dD9Ip3YRqmXndzqoHiOYfZww==", "dev": true, "dependencies": { "@statoscope/extensions": "5.14.1", "@statoscope/helpers": "5.25.0", "@statoscope/stats": "5.14.1", - "@statoscope/types": "5.22.0" + "@statoscope/types": "5.27.0" } }, "node_modules/@statoscope/stats-extension-package-info": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/@statoscope/stats-extension-package-info/-/stats-extension-package-info-5.25.0.tgz", - "integrity": "sha512-7pozFUq5pb9uAzyPXDW/fEIl/Ty0i/z4dhKbKS/c+/UDFIWBDo5WqUJY7Wa5u6XDc09ojICCpoysK86sdQxKFQ==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/@statoscope/stats-extension-package-info/-/stats-extension-package-info-5.27.0.tgz", + "integrity": "sha512-73u1yo/nAef8nh1bwAZVWSf2ubcNHgqcNeIz2hp9mZC7YGb/eh6mV1eai6T4NgmCYGLy7KxpA67KaE+4sWX4Ew==", "dev": true, "dependencies": { "@statoscope/helpers": "5.25.0" } }, "node_modules/@statoscope/stats-extension-stats-validation-result": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/@statoscope/stats-extension-stats-validation-result/-/stats-extension-stats-validation-result-5.25.0.tgz", - "integrity": "sha512-EBIXFINb3pD39oW89QDGuFR4ujxY9Ubik0HpxD+wh3Zw7ma4ZuTvByfT6I2P4v47lwLHG4J5cDT01eR93N0mYQ==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/@statoscope/stats-extension-stats-validation-result/-/stats-extension-stats-validation-result-5.27.0.tgz", + "integrity": "sha512-frkPBCGhZdGXf+uE5Yr/N4YQOljbChV6KcTW1x/YUtl98j7cdQMZA3jiS65nqjUsYUwjlzuLYqw67AHXI3hnyg==", "dev": true, "dependencies": { "@statoscope/extensions": "5.14.1", "@statoscope/helpers": "5.25.0", "@statoscope/stats": "5.14.1", - "@statoscope/types": "5.22.0" + "@statoscope/types": "5.27.0" } }, "node_modules/@statoscope/stats-validator": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@statoscope/stats-validator/-/stats-validator-5.22.0.tgz", - "integrity": "sha512-5XNFyHoIK4qGFD/vNIpE7cVCF6P2fVM/C4y5tqDZHdm8Qw8J0RXF6wivRLh/o1KEsqFxDNm5l2iodqNpV7A2Lg==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/@statoscope/stats-validator/-/stats-validator-5.27.0.tgz", + "integrity": "sha512-aPzdSQuEPmg7wsUmQftWDV01yoJkF8nwuYX7yLIh+F6WO+LMWBP153SxH+r7OTosu0YwvMw0gDCY+i47wZV1vA==", "dev": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.7", - "@statoscope/config": "5.22.0", + "@statoscope/config": "5.27.0", "@statoscope/stats": "5.14.1", - "@statoscope/types": "5.22.0" + "@statoscope/types": "5.27.0" } }, "node_modules/@statoscope/stats-validator-reporter-console": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@statoscope/stats-validator-reporter-console/-/stats-validator-reporter-console-5.22.0.tgz", - "integrity": "sha512-ktSog8BV73CLaqs+RN5wI7l+idPJYWWY5wyrJIKykKXNTWTyR1hpe+DQ1CNe4RQTZiKktMIdink46vUecfGYRw==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/@statoscope/stats-validator-reporter-console/-/stats-validator-reporter-console-5.27.0.tgz", + "integrity": "sha512-rKzE/Cz8229+1MKFWcM/0zSjdoGEWTFWbWQ2RCjETT/ytT0dngH7b8ignSKYz3sgfVpTaGs9SNVBoazCn67CWw==", "dev": true, "dependencies": { - "@statoscope/types": "5.22.0", + "@statoscope/types": "5.27.0", "chalk": "^4.1.2" } }, @@ -4350,63 +4344,63 @@ } }, "node_modules/@statoscope/stats-validator-reporter-stats-report": { - "version": "5.26.2", - "resolved": "https://registry.npmjs.org/@statoscope/stats-validator-reporter-stats-report/-/stats-validator-reporter-stats-report-5.26.2.tgz", - "integrity": "sha512-rli79P7OGFMVyG6iHp0eLln/Pkzo7rqxSH6dSFf/ls6ywi/tniXAWaiS0a/l1JA7xaUmfybfms7EmGAihxsABQ==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/@statoscope/stats-validator-reporter-stats-report/-/stats-validator-reporter-stats-report-5.27.0.tgz", + "integrity": "sha512-vdoBr5vSSbrByq3gLVy8Rh3rSDPJAzlVZnZY3ENSp9mYOJFWcKxgfAFQx1yT5QaprdKEriRvyzsXRTeuo2/rVQ==", "dev": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.7", - "@statoscope/report-writer": "5.25.1", + "@statoscope/report-writer": "5.27.0", "@statoscope/stats": "5.14.1", - "@statoscope/stats-extension-stats-validation-result": "5.25.0", - "@statoscope/types": "5.22.0", - "@statoscope/webpack-model": "5.26.2", - "@statoscope/webpack-ui": "5.26.2", + "@statoscope/stats-extension-stats-validation-result": "5.27.0", + "@statoscope/types": "5.27.0", + "@statoscope/webpack-model": "5.27.0", + "@statoscope/webpack-ui": "5.27.0", "open": "^8.4.0" } }, "node_modules/@statoscope/types": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@statoscope/types/-/types-5.22.0.tgz", - "integrity": "sha512-FHgunU7M95v7c71pvQ2nr8bWy1QcTDOHSnkoFQErORH9RmxlK9oRkoWrO8BJpnxa55m/9ZHjFVmpLF4SsVGHoA==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/@statoscope/types/-/types-5.27.0.tgz", + "integrity": "sha512-3BWUmpoRRHU/b6NiHqnFjDeKBAjrUiFVsZPPZONFeOtHlfRI1CoVeVkmPocCQHuk7JyTWuiEaOT5OBycOYlExg==", "dev": true, "dependencies": { "@statoscope/stats": "5.14.1" } }, "node_modules/@statoscope/webpack-model": { - "version": "5.26.2", - "resolved": "https://registry.npmjs.org/@statoscope/webpack-model/-/webpack-model-5.26.2.tgz", - "integrity": "sha512-b68Wjcf+6+9XBsHWKmHu5U7t+MEd+uo7BhHqI5Fp5IyWFl8GGwZb2KMfl0SLAqKI86P9q8i/u9dmtvDpwC+LRg==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/@statoscope/webpack-model/-/webpack-model-5.27.0.tgz", + "integrity": "sha512-tnQ4y7k7PM6oTUFt3tbqEDVWiI8JCAGjngoRgZUIGzR1ja9dQgVO6SR3r2uL5+FcPzsAcuxyoygpHl7DAH4Meg==", "dev": true, "dependencies": { "@statoscope/extensions": "5.14.1", "@statoscope/helpers": "5.25.0", "@statoscope/stats": "5.14.1", "@statoscope/stats-extension-compressed": "5.25.0", - "@statoscope/stats-extension-custom-reports": "5.25.0", - "@statoscope/stats-extension-package-info": "5.25.0", - "@statoscope/stats-extension-stats-validation-result": "5.25.0", - "@statoscope/types": "5.22.0", + "@statoscope/stats-extension-custom-reports": "5.27.0", + "@statoscope/stats-extension-package-info": "5.27.0", + "@statoscope/stats-extension-stats-validation-result": "5.27.0", + "@statoscope/types": "5.27.0", "md5": "^2.3.0" } }, "node_modules/@statoscope/webpack-plugin": { - "version": "5.26.2", - "resolved": "https://registry.npmjs.org/@statoscope/webpack-plugin/-/webpack-plugin-5.26.2.tgz", - "integrity": "sha512-QqB4znt1TKfI3tPHmGmF3xiru+qwIaNze8SlsN2oJFyEiaZnEB1+iIf6eaAlxIQUqTuLePWv2VQTiS3Dd1Xjtw==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/@statoscope/webpack-plugin/-/webpack-plugin-5.27.0.tgz", + "integrity": "sha512-swEi0jgosJlI0ixa3JIMuBunkq43ycJnQd3aT+t7bl5QlGYdpvU4FsTeKcvNrin1V1Vq2D4Zvf+vCagg+1tIlg==", "dev": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.7", - "@statoscope/report-writer": "5.25.1", + "@statoscope/report-writer": "5.27.0", "@statoscope/stats": "5.14.1", "@statoscope/stats-extension-compressed": "5.25.0", - "@statoscope/stats-extension-custom-reports": "5.25.0", - "@statoscope/types": "5.22.0", - "@statoscope/webpack-model": "5.26.2", - "@statoscope/webpack-stats-extension-compressed": "5.26.2", - "@statoscope/webpack-stats-extension-package-info": "5.26.2", - "@statoscope/webpack-ui": "5.26.2", + "@statoscope/stats-extension-custom-reports": "5.27.0", + "@statoscope/types": "5.27.0", + "@statoscope/webpack-model": "5.27.0", + "@statoscope/webpack-stats-extension-compressed": "5.27.0", + "@statoscope/webpack-stats-extension-package-info": "5.27.0", + "@statoscope/webpack-ui": "5.27.0", "open": "^8.4.0" }, "peerDependencies": { @@ -4414,40 +4408,40 @@ } }, "node_modules/@statoscope/webpack-stats-extension-compressed": { - "version": "5.26.2", - "resolved": "https://registry.npmjs.org/@statoscope/webpack-stats-extension-compressed/-/webpack-stats-extension-compressed-5.26.2.tgz", - "integrity": "sha512-gDkWs3r/gKS9lOzM0Pz77fRHQdI2ot84wM1WwHUXYQCmKvxMazA2828i35XWsKS2DHsThCQ2qyBe9QrpNqR4GA==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/@statoscope/webpack-stats-extension-compressed/-/webpack-stats-extension-compressed-5.27.0.tgz", + "integrity": "sha512-FXxvN9cYcig4bpb69lP7960CRiuDcwnaGgrIAZ7cYPu8vpCfUDadV2OMuL/EDfB4AWrqO5ytd6ZL+V79KCzyaA==", "dev": true, "dependencies": { "@statoscope/stats": "5.14.1", "@statoscope/stats-extension-compressed": "5.25.0", - "@statoscope/webpack-model": "5.26.2" + "@statoscope/webpack-model": "5.27.0" }, "peerDependencies": { "webpack": "^4.0.0 || ^5.0.0" } }, "node_modules/@statoscope/webpack-stats-extension-package-info": { - "version": "5.26.2", - "resolved": "https://registry.npmjs.org/@statoscope/webpack-stats-extension-package-info/-/webpack-stats-extension-package-info-5.26.2.tgz", - "integrity": "sha512-mrv0Wz+f5Kx9YN4w7IalCwckCRSk5HTTgiaj+M95V1BF1DAptTqwyB+h1DFyGRwxD6Upb/wxyiYJ+wLxey0yMw==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/@statoscope/webpack-stats-extension-package-info/-/webpack-stats-extension-package-info-5.27.0.tgz", + "integrity": "sha512-4sx6HqBEypO3PrW1lvsw2MsI7vujIkm96TFQg/uAIUVVgRKdunKfLxXL7q4ZRC9s0nGNQApyCQgr9TxN21ENoQ==", "dev": true, "dependencies": { "@statoscope/stats": "5.14.1", - "@statoscope/stats-extension-package-info": "5.25.0", - "@statoscope/webpack-model": "5.26.2" + "@statoscope/stats-extension-package-info": "5.27.0", + "@statoscope/webpack-model": "5.27.0" }, "peerDependencies": { "webpack": "^4.0.0 || ^5.0.0" } }, "node_modules/@statoscope/webpack-ui": { - "version": "5.26.2", - "resolved": "https://registry.npmjs.org/@statoscope/webpack-ui/-/webpack-ui-5.26.2.tgz", - "integrity": "sha512-01RYHyG2nrif9Y5i717EI6jUMqdypbrOMdqpNUBFlw2rmaEB5t21V35b5Vd0pZEgesKNijE3ULvP7EQ37jEbIg==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/@statoscope/webpack-ui/-/webpack-ui-5.27.0.tgz", + "integrity": "sha512-FIG84pD1RdBfgwEpNCUun+mK+pzRTyzLu7WqTsZRPisowyr1h0bPxXFpzwcDRhrGnIXBZO+kVX/hH3VOlvNkJw==", "dev": true, "dependencies": { - "@statoscope/types": "5.22.0" + "@statoscope/types": "5.27.0" } }, "node_modules/@testing-library/jest-dom": { @@ -5651,14 +5645,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "6.0.2", "dev": true, @@ -8854,8 +8840,9 @@ }, "node_modules/duplexer": { "version": "0.1.2", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -11607,8 +11594,9 @@ }, "node_modules/gzip-size": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", "dev": true, - "license": "MIT", "dependencies": { "duplexer": "^0.1.2" }, @@ -15890,14 +15878,6 @@ "node": ">=0.10.0" } }, - "node_modules/mrmime": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/ms": { "version": "2.1.2", "dev": true, @@ -16535,14 +16515,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/opener": { - "version": "1.5.2", - "dev": true, - "license": "(WTFPL OR MIT)", - "bin": { - "opener": "bin/opener-bin.js" - } - }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -18995,19 +18967,6 @@ "semver": "bin/semver.js" } }, - "node_modules/sirv": { - "version": "1.0.19", - "dev": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.20", - "mrmime": "^1.0.0", - "totalist": "^1.0.0" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/sisteransi": { "version": "1.0.5", "dev": true, @@ -20377,14 +20336,6 @@ "version": "4.0.0", "license": "ISC" }, - "node_modules/totalist": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -21043,113 +20994,6 @@ } } }, - "node_modules/webpack-bundle-analyzer": { - "version": "4.8.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@discoveryjs/json-ext": "0.5.7", - "acorn": "^8.0.4", - "acorn-walk": "^8.0.0", - "chalk": "^4.1.0", - "commander": "^7.2.0", - "gzip-size": "^6.0.0", - "lodash": "^4.17.20", - "opener": "^1.5.2", - "sirv": "^1.0.7", - "ws": "^7.3.1" - }, - "bin": { - "webpack-bundle-analyzer": "lib/bin/analyzer.js" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/chalk": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - }, - "node_modules/webpack-bundle-analyzer/node_modules/has-flag": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/supports-color": { - "version": "7.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/ws": { - "version": "7.5.9", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/webpack-cli": { "version": "5.1.1", "dev": true, diff --git a/package.json b/package.json index 8c3ed862..7534045c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mytonwallet", - "version": "1.15.2", + "version": "1.15.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": { @@ -8,13 +8,16 @@ "build": "webpack && bash ./deploy/copy_to_dist.sh", "build:staging": "cross-env APP_ENV=staging npm run build", "build:production": "npm run build", - "extension:dev": "cross-env ENV_EXTENSION=1 APP_ENV=development webpack --mode development && bash ./deploy/copy_to_dist.sh", - "extension-chrome:package": "cross-env ENV_EXTENSION=1 webpack && bash ./deploy/copy_to_dist.sh && rm -f MyTonWallet-chrome.zip && rm -f dist/reference.json && zip -r -X MyTonWallet-chrome.zip dist/*", + "extension:dev": "cross-env IS_EXTENSION=1 APP_ENV=development webpack --mode development && bash ./deploy/copy_to_dist.sh", + "extension-chrome:package": "cross-env IS_EXTENSION=1 webpack && bash ./deploy/package_extension.sh chrome", "extension-chrome:package:staging": "APP_ENV=staging npm run extension-chrome:package", "extension-chrome:package:production": "npm run extension-chrome:package", - "extension-firefox:package": "cross-env IS_FIREFOX_EXTENSION=1 ENV_EXTENSION=1 webpack && bash ./deploy/copy_to_dist.sh && rm -f MyTonWallet-firefox.zip && rm -f dist/reference.json && zip -r -X MyTonWallet-firefox.zip dist/*", + "extension-firefox:package": "cross-env IS_FIREFOX_EXTENSION=1 IS_EXTENSION=1 webpack && bash ./deploy/package_extension.sh firefox", "extension-firefox:package:staging": "cross-env APP_ENV=staging npm run extension-firefox:package", "extension-firefox:package:production": "npm run extension-firefox:package", + "extension-opera:package": "cross-env IS_OPERA_EXTENSION=1 IS_EXTENSION=1 webpack && bash ./deploy/package_extension.sh opera", + "extension-opera:package:staging": "cross-env APP_ENV=staging npm run extension-opera:package", + "extension-opera:package:production": "npm run extension-opera:package", "electron:dev": "npm run electron:webpack && IS_ELECTRON=true concurrently -n main,renderer,electron \"npm run electron:webpack -- --watch\" \"npm run dev\" \"electronmon dist/electron\"", "electron:webpack": "cross-env APP_ENV=$ENV webpack --config ./webpack-electron.config.ts", "electron:build": "IS_ELECTRON=true npm run build:$ENV && electron-builder install-app-deps && electron-rebuild && ENV=$ENV npm run electron:webpack", @@ -71,8 +74,8 @@ "@babel/register": "^7.21.0", "@peculiar/webcrypto": "^1.3.2", "@playwright/test": "^1.31.2", - "@statoscope/cli": "^5.26.2", - "@statoscope/webpack-plugin": "^5.26.2", + "@statoscope/cli": "^5.27.0", + "@statoscope/webpack-plugin": "^5.27.0", "@testing-library/jest-dom": "^5.16.5", "@tonconnect/protocol": "^2.2.5", "@types/bn.js": "^5.1.1", @@ -152,7 +155,6 @@ "stylelint-order": "^5.0.0", "typescript": "^5.0.2", "webpack": "^5.76.2", - "webpack-bundle-analyzer": "^4.8.0", "webpack-dev-server": "^4.13.1" }, "dependencies": { diff --git a/src/api/blockchains/ton/staking.ts b/src/api/blockchains/ton/staking.ts index 4f8a2a74..5850c5f2 100644 --- a/src/api/blockchains/ton/staking.ts +++ b/src/api/blockchains/ton/staking.ts @@ -13,7 +13,7 @@ import type { import { TON_TOKEN_SLUG } from '../../../config'; import { parseAccountId } from '../../../util/account'; import memoized from '../../../util/memoized'; -import { getTonWeb } from './util/tonweb'; +import { getTonWeb, toBase64Address } from './util/tonweb'; import { NominatorPool } from './contracts/NominatorPool'; import { fetchStoredAddress } from '../../common/accounts'; import { callBackendGet } from '../../common/backend'; @@ -76,7 +76,7 @@ export async function submitStake(accountId: string, password: string, amount: s accountId, password, TON_TOKEN_SLUG, - poolAddress, + toBase64Address(poolAddress), amount, STAKE_COMMENT, ); @@ -91,7 +91,7 @@ export async function submitUnstake(accountId: string, password: string) { accountId, password, TON_TOKEN_SLUG, - poolAddress, + toBase64Address(poolAddress), UNSTAKE_AMOUNT, UNSTAKE_COMMENT, ); diff --git a/src/api/blockchains/ton/transactions.ts b/src/api/blockchains/ton/transactions.ts index 77c24adb..a24eb324 100644 --- a/src/api/blockchains/ton/transactions.ts +++ b/src/api/blockchains/ton/transactions.ts @@ -22,9 +22,11 @@ import { decryptMessageComment, encryptMessageComment } from './util/encryption' import { parseWalletTransactionBody } from './util/metadata'; import { getTonClient, getTonWalletContract } from './util/tonCore'; import { + commentToBytes, fetchNewestTxId, fetchTransactions, getWalletPublicKey, + packBytesAsSnake, parseBase64, resolveTokenWalletAddress, toBase64Address, @@ -154,12 +156,20 @@ export async function checkTransactionDraft( } } + const account = await fetchStoredAccount(accountId); + const isLedger = !!account?.ledger; + + if (data && typeof data === 'string' && !isBase64Data && !isLedger) { + data = commentToBytes(data); + } + if (tokenSlug === TON_TOKEN_SLUG) { - const account = await fetchStoredAccount(accountId); - if (data && account?.ledger) { - if (typeof data !== 'string' || shouldEncrypt || !isValidLedgerComment(data)) { - return { ...result, error: ApiTransactionDraftError.UnsupportedHardwarePayload }; - } + if (data && isLedger && (typeof data !== 'string' || shouldEncrypt || !isValidLedgerComment(data))) { + return { ...result, error: ApiTransactionDraftError.UnsupportedHardwarePayload }; + } + + if (data instanceof Uint8Array) { + data = packBytesAsSnake(data); } } else { const address = await fetchStoredAddress(accountId); @@ -222,17 +232,23 @@ export async function submitTransfer( // Force default bounceable address for `waitTxComplete` to work properly const normalizedAddress = toBase64Address(toAddress); - if (typeof data === 'string' && isBase64Data) { - data = parseBase64(data); - } - - if (data && typeof data === 'string' && shouldEncrypt) { - const toPublicKey = (await getWalletPublicKey(network, toAddress))!; - data = await encryptMessageComment(data, publicKey, toPublicKey, secretKey, fromAddress); - encryptedComment = Buffer.from(data.slice(4)).toString('base64'); + if (data && typeof data === 'string') { + if (isBase64Data) { + data = parseBase64(data); + } else if (shouldEncrypt) { + const toPublicKey = (await getWalletPublicKey(network, toAddress))!; + data = await encryptMessageComment(data, publicKey, toPublicKey, secretKey, fromAddress); + encryptedComment = Buffer.from(data.slice(4)).toString('base64'); + } else { + data = commentToBytes(data); + } } - if (tokenSlug !== TON_TOKEN_SLUG) { + if (tokenSlug === TON_TOKEN_SLUG) { + if (data instanceof Uint8Array) { + data = packBytesAsSnake(data); + } + } else { ({ amount, toAddress, diff --git a/src/api/blockchains/ton/util/CustomHttpProvider.ts b/src/api/blockchains/ton/util/CustomHttpProvider.ts index d1cb0aee..fd48def7 100644 --- a/src/api/blockchains/ton/util/CustomHttpProvider.ts +++ b/src/api/blockchains/ton/util/CustomHttpProvider.ts @@ -1,12 +1,24 @@ import TonWeb from 'tonweb'; +import type { HttpProviderOptions } from 'tonweb/dist/types/providers/http-provider'; import { logDebugError } from '../../../../util/logs'; import { pause } from '../../../../util/schedulers'; +type Options = HttpProviderOptions & { + headers?: AnyLiteral; +}; + const ATTEMPTS = 5; const ERROR_PAUSE = 200; // 200 ms class CustomHttpProvider extends TonWeb.HttpProvider { + options: Options; + + constructor(host: string, options?: Options) { + super(host, options); + this.options = options ?? {}; + } + send(method: string, params: any): Promise { return this.sendRequest(this.host, { id: 1, jsonrpc: '2.0', method, params, @@ -19,6 +31,7 @@ class CustomHttpProvider extends TonWeb.HttpProvider { let lastStatusCode: number | undefined; const headers: AnyLiteral = { + ...this.options.headers, 'Content-Type': 'application/json', }; if (this.options.apiKey) { diff --git a/src/api/blockchains/ton/util/metadata.ts b/src/api/blockchains/ton/util/metadata.ts index c3bbcb45..e9c74746 100644 --- a/src/api/blockchains/ton/util/metadata.ts +++ b/src/api/blockchains/ton/util/metadata.ts @@ -50,9 +50,11 @@ export function parseJettonWalletMsgBody(body?: string) { if (slice.remainingBits > 32) { const forwardOpCode = slice.loadUint(32); if (forwardOpCode === OpCode.Comment) { - comment = slice.loadStringTail(); + const buffer = readSnakeBytes(slice); + comment = buffer.toString('utf-8'); } else if (forwardOpCode === OpCode.Encrypted) { - encryptedComment = slice.loadBuffer(slice.remainingBits / 8).toString('base64'); + const buffer = readSnakeBytes(slice); + encryptedComment = buffer.toString('base64'); } } } @@ -335,3 +337,18 @@ function dataToSlice(data: string | Buffer | Uint8Array): Slice { return new Slice(new BitReader(new BitString(buffer, 0, buffer.length * 8)), []); } + +export function readSnakeBytes(slice: Slice) { + let buffer = Buffer.alloc(0); + + while (slice.remainingBits > 8) { + buffer = Buffer.concat([buffer, slice.loadBuffer(slice.remainingBits / 8)]); + if (slice.remainingRefs) { + slice = slice.loadRef().beginParse(); + } else { + break; + } + } + + return buffer; +} diff --git a/src/api/blockchains/ton/util/tonapiio.ts b/src/api/blockchains/ton/util/tonapiio.ts index 2a75e4e5..a032a8b0 100644 --- a/src/api/blockchains/ton/util/tonapiio.ts +++ b/src/api/blockchains/ton/util/tonapiio.ts @@ -9,6 +9,7 @@ import { import type { ApiNetwork } from '../../../types'; import { logDebugError } from '../../../../util/logs'; +import { API_HEADERS } from '../../../environment'; const TONAPIIO_MAINNET_URL = process.env.TONAPIIO_MAINNET_URL || 'https://tonapi.io'; const TONAPIIO_TESTNET_URL = process.env.TONAPIIO_TESTNET_URL || 'https://testnet.tonapi.io'; @@ -16,9 +17,11 @@ const MAX_LIMIT = 1000; const configurationMainnet = new Configuration({ basePath: TONAPIIO_MAINNET_URL, + headers: API_HEADERS, }); const configurationTestnet = new Configuration({ basePath: TONAPIIO_TESTNET_URL, + headers: API_HEADERS, }); export const tonapiioByNetwork = { diff --git a/src/api/blockchains/ton/util/tonweb.ts b/src/api/blockchains/ton/util/tonweb.ts index fe978417..cb126755 100644 --- a/src/api/blockchains/ton/util/tonweb.ts +++ b/src/api/blockchains/ton/util/tonweb.ts @@ -17,7 +17,8 @@ import { import { logDebugError } from '../../../../util/logs'; import withCacheAsync from '../../../../util/withCacheAsync'; import { base64ToBytes, hexToBytes } from '../../../common/utils'; -import { JettonOpCode } from '../constants'; +import { API_HEADERS } from '../../../environment'; +import { JettonOpCode, OpCode } from '../constants'; import { parseTxId, stringifyTxId } from './index'; import CustomHttpProvider from './CustomHttpProvider'; @@ -26,12 +27,16 @@ const { Cell } = TonWeb.boc; const { Address } = TonWeb.utils; const { JettonMinter, JettonWallet } = TonWeb.token.jetton; +const TON_MAX_COMMENT_BYTES = 127; + const tonwebByNetwork = { mainnet: new TonWeb(new CustomHttpProvider(TONHTTPAPI_MAINNET_URL, { apiKey: TONHTTPAPI_MAINNET_API_KEY, + headers: API_HEADERS, })) as MyTonWeb, testnet: new TonWeb(new CustomHttpProvider(TONHTTPAPI_TESTNET_URL, { apiKey: TONHTTPAPI_TESTNET_API_KEY, + headers: API_HEADERS, })) as MyTonWeb, }; @@ -202,18 +207,9 @@ export function buildTokenTransferBody(params: TokenTransferBodyParams) { cell.bits.writeBit(false); // null custom_payload cell.bits.writeCoins(new BN(forwardAmount || '0')); - // A large comment must be placed in a separate cell - if (typeof forwardPayload === 'string') { - const buffer = Buffer.from(forwardPayload); - if (cell.bits.getFreeBits() < (buffer.length * 8) + 32 + 1) { - forwardPayload = new Cell(); - forwardPayload.bits.writeUint(0, 32); - forwardPayload.bits.writeBytes(buffer); - } - } else if (forwardPayload instanceof Uint8Array && cell.bits.getFreeBits() < forwardPayload.length * 8) { - const bytes = forwardPayload; - forwardPayload = new Cell(); - forwardPayload.bits.writeBytes(bytes); + if (forwardPayload instanceof Uint8Array) { + const freeBytes = Math.round(cell.bits.getFreeBits() / 8); + forwardPayload = packBytesAsSnake(forwardPayload, freeBytes); } if (!forwardPayload) { @@ -245,3 +241,37 @@ export function parseBase64(base64: string) { return Uint8Array.from(Buffer.from(base64, 'base64')); } } + +export function commentToBytes(comment: string): Uint8Array { + const buffer = Buffer.from(comment); + const bytes = new Uint8Array(buffer.length + 4); + + const startBuffer = Buffer.alloc(4); + startBuffer.writeUInt32BE(OpCode.Comment); + bytes.set(startBuffer, 0); + bytes.set(buffer, 4); + + return bytes; +} + +export function packBytesAsSnake(bytes: Uint8Array, maxBytes = TON_MAX_COMMENT_BYTES): Uint8Array | CellType { + const buffer = Buffer.from(bytes); + if (buffer.length <= maxBytes) { + return bytes; + } + + const cell = new Cell(); + + let subcell = cell; + + for (const byte of buffer) { + if (subcell.bits.getFreeBits() < 8) { + const newCell = new Cell(); + subcell.refs = [newCell]; + subcell = newCell; + } + subcell.bits.writeUint8(byte); + } + + return cell; +} diff --git a/src/api/common/helpers.ts b/src/api/common/helpers.ts index 752b3ef7..29d0d986 100644 --- a/src/api/common/helpers.ts +++ b/src/api/common/helpers.ts @@ -4,9 +4,8 @@ import type { AccountIdParsed, ApiLocalTransactionParams, ApiTransaction, OnApiUpdate, } from '../types'; -import { MAIN_ACCOUNT_ID } from '../../config'; +import { IS_EXTENSION, MAIN_ACCOUNT_ID } from '../../config'; import { buildAccountId, parseAccountId } from '../../util/account'; -import { IS_EXTENSION } from '../environment'; import { storage } from '../storages'; import idbStorage from '../storages/idb'; import { getKnownAddresses, getScamMarkers } from './addresses'; @@ -31,21 +30,15 @@ export function buildInternalAccountId(account: Omit return `${id}-${blockchain}`; } -export function createLocalTransaction(onUpdate: OnApiUpdate, accountId: string, params: ApiLocalTransactionParams) { - const { - amount, fromAddress, toAddress, comment, fee, slug, type, encryptedComment, - } = params; - - const localTransaction = buildLocalTransaction({ - amount, - fromAddress, - toAddress, - comment, - fee, - slug, - type, - encryptedComment, - }); +export function createLocalTransaction( + onUpdate: OnApiUpdate, + accountId: string, + params: ApiLocalTransactionParams, + onTxComplete?: (transaction: ApiTransaction) => void, +) { + const { amount, toAddress } = params; + + const localTransaction = buildLocalTransaction(params); onUpdate({ type: 'newLocalTransaction', @@ -54,13 +47,16 @@ export function createLocalTransaction(onUpdate: OnApiUpdate, accountId: string, }); whenTxComplete(toAddress, amount) - .then(({ txId }) => { + .then(({ transaction }) => { + if (onTxComplete) { + onTxComplete(transaction); + } onUpdate({ type: 'updateTxComplete', accountId, toAddress, amount, - txId, + txId: transaction.txId, localTxId: localTransaction.txId, }); }); diff --git a/src/api/common/txCallbacks.ts b/src/api/common/txCallbacks.ts index 46426be8..41b5529a 100644 --- a/src/api/common/txCallbacks.ts +++ b/src/api/common/txCallbacks.ts @@ -5,12 +5,14 @@ import { createCallbackManager } from '../../util/callbacks'; export const txCallbacks = createCallbackManager(); export function whenTxComplete(toAddress: string, amount: string) { - return new Promise<{ result: boolean; txId: string }>((resolve) => { - txCallbacks.addCallback(function callback(transaction: ApiTransaction) { - if (transaction.toAddress === toAddress && transaction.amount === `-${amount}`) { - txCallbacks.removeCallback(callback); - resolve({ result: true, txId: transaction.txId }); - } - }); + return new Promise<{ result: boolean; transaction: ApiTransaction }>((resolve) => { + txCallbacks.addCallback( + function callback(transaction: ApiTransaction) { + if (transaction.toAddress === toAddress && transaction.amount === `-${amount}`) { + txCallbacks.removeCallback(callback); + resolve({ result: true, transaction }); + } + }, + ); }); } diff --git a/src/api/environment.ts b/src/api/environment.ts index 9a620be8..2f39b71a 100644 --- a/src/api/environment.ts +++ b/src/api/environment.ts @@ -2,13 +2,13 @@ * This module is to be used instead of /src/util/environment.ts * when `window` is not available (e.g. in a web worker). */ +import { APP_ENV, IS_ELECTRON, IS_EXTENSION } from '../config'; -// eslint-disable-next-line no-restricted-globals -export const IS_EXTENSION = Boolean(self?.chrome?.runtime?.id); // eslint-disable-next-line no-restricted-globals export const IS_CHROME_EXTENSION = Boolean(self?.chrome?.system); -export const IS_FIREFOX_EXTENSION = IS_EXTENSION && !IS_CHROME_EXTENSION; - -export const IS_ELECTRON = process.env.IS_ELECTRON; -export const IS_DAPP_SUPPORTED = IS_EXTENSION || IS_ELECTRON; +// eslint-disable-next-line no-restricted-globals +export const X_APP_ORIGIN = self.origin; +export const API_HEADERS = IS_EXTENSION || (IS_ELECTRON && APP_ENV !== 'development') + ? { 'X-App-Origin': X_APP_ORIGIN } + : undefined; diff --git a/src/api/extensionMethods/extension.ts b/src/api/extensionMethods/extension.ts index 8b38cca0..80da790f 100644 --- a/src/api/extensionMethods/extension.ts +++ b/src/api/extensionMethods/extension.ts @@ -1,10 +1,7 @@ import extension from 'webextension-polyfill'; -import type { OnApiUpdate } from '../types'; - -import { PROXY_HOSTS } from '../../config'; +import { IS_FIREFOX_EXTENSION, PROXY_HOSTS } from '../../config'; import { sample } from '../../util/random'; -import { IS_FIREFOX_EXTENSION } from '../environment'; import { storage } from '../storages'; import { updateSites } from './sites'; @@ -38,12 +35,9 @@ const PROXY_PAC_SCRIPT = `function FindProxyForURL(url, host) { : 'DIRECT'; }`; -let onUpdate: OnApiUpdate; let isProxyEnabled = false; -export async function initExtension(_onUpdate: OnApiUpdate) { - onUpdate = _onUpdate; - +export async function initExtension() { const isTonProxyEnabled = await storage.getItem('isTonProxyEnabled'); void doProxy(isTonProxyEnabled); @@ -51,25 +45,8 @@ export async function initExtension(_onUpdate: OnApiUpdate) { doDeeplinkHook(isDeeplinkHookEnabled); } -export function setupDefaultExtensionFeatures() { - doDeeplinkHook(true); - onUpdate({ - type: 'updateDeeplinkHookState', - isEnabled: Boolean(true), - }); -} - -export async function clearExtensionFeatures() { - void doProxy(false); - doMagic(false); - doDeeplinkHook(false); - - await Promise.all([ - storage.removeItem('isTonMagicEnabled'), - storage.removeItem('isTonProxyEnabled'), - storage.removeItem('isDeeplinkHookEnabled'), - storage.removeItem('dapps'), - ]); +export function onFullLogout() { + return storage.removeItem('dapps'); } export function doProxy(isEnabled: boolean) { diff --git a/src/api/extensionMethods/init.ts b/src/api/extensionMethods/init.ts index 8f8aa42c..9b1c9c00 100644 --- a/src/api/extensionMethods/init.ts +++ b/src/api/extensionMethods/init.ts @@ -9,8 +9,7 @@ import { addHooks } from '../hooks'; addHooks({ onWindowNeeded: openPopupWindow, - onFirstLogin: extensionMethods.setupDefaultExtensionFeatures, - onFullLogout: extensionMethods.clearExtensionFeatures, + onFullLogout: extensionMethods.onFullLogout, onDappDisconnected: () => { siteMethods.updateSites({ type: 'disconnectSite', @@ -20,7 +19,7 @@ addHooks({ }); export default function init(onUpdate: OnApiUpdate) { - void extensionMethods.initExtension(onUpdate); + void extensionMethods.initExtension(); legacyDappMethods.initLegacyDappMethods(onUpdate); siteMethods.initSiteMethods(onUpdate); } diff --git a/src/api/extensionMethods/window.ts b/src/api/extensionMethods/window.ts index f665747d..2bd9d337 100644 --- a/src/api/extensionMethods/window.ts +++ b/src/api/extensionMethods/window.ts @@ -13,7 +13,7 @@ const WINDOW_DEFAULTS = { top: 120, left: 20, width: 368, - height: 750, + height: 770, }; const MARGIN_RIGHT = 20; const WINDOW_STATE_MONITOR_INTERVAL = 3000; diff --git a/src/api/methods/accounts.ts b/src/api/methods/accounts.ts index 56047f43..94533419 100644 --- a/src/api/methods/accounts.ts +++ b/src/api/methods/accounts.ts @@ -1,9 +1,9 @@ import type { ApiAccountInfo, ApiTxIdBySlug } from '../types'; +import { IS_EXTENSION } from '../../config'; import { parseAccountId } from '../../util/account'; import { fetchStoredAccount, loginResolve } from '../common/accounts'; import { waitStorageMigration } from '../common/helpers'; -import { IS_EXTENSION } from '../environment'; import { storage } from '../storages'; import { deactivateAccountDapp, deactivateAllDapps, onActiveDappAccountUpdated } from './dapps'; import { diff --git a/src/api/methods/auth.ts b/src/api/methods/auth.ts index 8c1656f8..1f151a16 100644 --- a/src/api/methods/auth.ts +++ b/src/api/methods/auth.ts @@ -1,12 +1,12 @@ import type { LedgerWalletInfo } from '../../util/ledger/types'; import type { ApiAccountInfo, ApiNetwork, ApiTxIdBySlug } from '../types'; +import { IS_DAPP_SUPPORTED } from '../../config'; import blockchains from '../blockchains'; import { getNewAccountId, removeAccountValue, removeNetworkAccountsValue, setAccountValue, } from '../common/accounts'; import { bytesToHex } from '../common/utils'; -import { IS_DAPP_SUPPORTED } from '../environment'; import { storage } from '../storages'; import { activateAccount, deactivateAllAccounts, deactivateCurrentAccount } from './accounts'; import { removeAccountDapps, removeAllDapps, removeNetworkDapps } from './dapps'; diff --git a/src/api/methods/init.ts b/src/api/methods/init.ts index 00fd16c1..d7d79334 100644 --- a/src/api/methods/init.ts +++ b/src/api/methods/init.ts @@ -1,8 +1,7 @@ import type { ApiInitArgs, OnApiUpdate } from '../types'; -import { IS_SSE_SUPPORTED } from '../../config'; +import { IS_DAPP_SUPPORTED, IS_SSE_SUPPORTED } from '../../config'; import { connectUpdater, startStorageMigration } from '../common/helpers'; -import { IS_DAPP_SUPPORTED } from '../environment'; import * as tonConnect from '../tonConnect'; import { resetupSseConnection, sendSseDisconnect } from '../tonConnect/sse'; import * as methods from '.'; @@ -14,12 +13,12 @@ addHooks({ onDappsChanged: resetupSseConnection, }); +// eslint-disable-next-line @typescript-eslint/no-unused-vars export default async function init(onUpdate: OnApiUpdate, args: ApiInitArgs) { connectUpdater(onUpdate); - methods.initPolling(onUpdate, methods.isAccountActive, args); + methods.initPolling(onUpdate, methods.isAccountActive); methods.initTransactions(onUpdate); - void methods.initWallet(onUpdate); methods.initStaking(onUpdate); if (IS_DAPP_SUPPORTED) { diff --git a/src/api/methods/polling.ts b/src/api/methods/polling.ts index 354a38fa..a8bfe69e 100644 --- a/src/api/methods/polling.ts +++ b/src/api/methods/polling.ts @@ -2,7 +2,6 @@ import { randomBytes } from 'tweetnacl'; import type { ApiBaseToken, - ApiInitArgs, ApiNftUpdate, ApiToken, ApiTokenPrice, @@ -22,6 +21,7 @@ import { tryUpdateKnownAddresses } from '../common/addresses'; import { callBackendGet } from '../common/backend'; import { isUpdaterAlive, resolveBlockchainKey } from '../common/helpers'; import { txCallbacks } from '../common/txCallbacks'; +import { X_APP_ORIGIN } from '../environment'; import { storage } from '../storages'; import { getBackendStakingState } from './staking'; @@ -30,16 +30,14 @@ type IsAccountActiveFn = (accountId: string) => boolean; const POLLING_INTERVAL = 1100; // 1.1 sec const BACKEND_POLLING_INTERVAL = 30000; // 30 sec const LONG_BACKEND_POLLING_INTERVAL = 60000; // 1 min - const PAUSE_AFTER_BALANCE_CHANGE = 1000; // 1 sec const FIRST_TRANSACTIONS_LIMIT = 20; - const NFT_FULL_POLLING_INTERVAL = 30000; // 30 sec const NFT_FULL_UPDATE_FREQUNCY = Math.round(NFT_FULL_POLLING_INTERVAL / POLLING_INTERVAL); +const DOUBLE_CHECK_TOKENS_PAUSE = 30000; // 30 sec let onUpdate: OnApiUpdate; let isAccountActive: IsAccountActiveFn; -let origin: string; let clientId: string | undefined; let preloadEnsurePromise: Promise; @@ -50,10 +48,9 @@ const lastBalanceCache: Record; }> = {}; -export function initPolling(_onUpdate: OnApiUpdate, _isAccountActive: IsAccountActiveFn, args: ApiInitArgs) { +export function initPolling(_onUpdate: OnApiUpdate, _isAccountActive: IsAccountActiveFn) { onUpdate = _onUpdate; isAccountActive = _isAccountActive; - origin = args.origin; preloadEnsurePromise = Promise.all([ tryUpdateKnownAddresses(), @@ -97,6 +94,7 @@ export async function setupBalanceBasedPolling(accountId: string, newestTxIds: A let nftFromSec = Math.round(Date.now() / 1000); let nftUpdates: ApiNftUpdate[]; let i = 0; + let doubleCheckTokensTime: number | undefined; const localOnUpdate = onUpdate; @@ -129,57 +127,59 @@ export async function setupBalanceBasedPolling(accountId: string, newestTxIds: A }); } - // Process balance + // Process TON balance const cache = lastBalanceCache[accountId]; const changedTokenSlugs: string[] = []; + const isTonBalanceChanged = balance && balance !== cache?.balance; - if (!balance || balance === cache?.balance) { - await pause(POLLING_INTERVAL); - continue; - } - - changedTokenSlugs.push(TON_TOKEN_SLUG); - onUpdate({ - type: 'updateBalance', - accountId, - slug: TON_TOKEN_SLUG, - balance, - }); + if (isTonBalanceChanged) { + changedTokenSlugs.push(TON_TOKEN_SLUG); + onUpdate({ + type: 'updateBalance', + accountId, + slug: TON_TOKEN_SLUG, + balance, + }); - lastBalanceCache[accountId] = { - ...lastBalanceCache[accountId], - balance, - }; + lastBalanceCache[accountId] = { + ...lastBalanceCache[accountId], + balance, + }; - await pause(PAUSE_AFTER_BALANCE_CHANGE); + await pause(PAUSE_AFTER_BALANCE_CHANGE); + } // Fetch and process token balances - const tokenBalances = await blockchain.getAccountTokenBalances(accountId).catch(logAndRescue); - if (!isUpdaterAlive(localOnUpdate) || !isAccountActive(accountId)) return; - - if (tokenBalances) { - registerNewTokens(tokenBalances); - - for (const { slug, balance: tokenBalance } of tokenBalances) { - const cachedBalance = cache?.tokenBalances && cache.tokenBalances[slug]; - if (cachedBalance === tokenBalance) continue; + if (isTonBalanceChanged || (doubleCheckTokensTime && doubleCheckTokensTime < Date.now())) { + doubleCheckTokensTime = isTonBalanceChanged ? Date.now() + DOUBLE_CHECK_TOKENS_PAUSE : undefined; - changedTokenSlugs.push(slug); + const tokenBalances = await blockchain.getAccountTokenBalances(accountId).catch(logAndRescue); + if (!isUpdaterAlive(localOnUpdate) || !isAccountActive(accountId)) return; - onUpdate({ - type: 'updateBalance', - accountId, - slug, - balance: tokenBalance, - }); + if (tokenBalances) { + registerNewTokens(tokenBalances); + + for (const { slug, balance: tokenBalance } of tokenBalances) { + const cachedBalance = cache?.tokenBalances && cache.tokenBalances[slug]; + if (cachedBalance === tokenBalance) continue; + + changedTokenSlugs.push(slug); + + onUpdate({ + type: 'updateBalance', + accountId, + slug, + balance: tokenBalance, + }); + } + + lastBalanceCache[accountId] = { + ...lastBalanceCache[accountId], + tokenBalances: Object.fromEntries(tokenBalances.map( + ({ slug, balance: tokenBalance }) => [slug, tokenBalance], + )), + }; } - - lastBalanceCache[accountId] = { - ...lastBalanceCache[accountId], - tokenBalances: Object.fromEntries(tokenBalances.map( - ({ slug, balance: tokenBalance }) => [slug, tokenBalance], - )), - }; } // Fetch transactions for tokens with a changed balance @@ -189,9 +189,11 @@ export async function setupBalanceBasedPolling(accountId: string, newestTxIds: A } // Fetch NFT updates - [nftFromSec, nftUpdates] = await blockchain.getNftUpdates(accountId, nftFromSec); - if (!isUpdaterAlive(localOnUpdate) || !isAccountActive(accountId)) return; - nftUpdates.forEach(onUpdate); + if (isTonBalanceChanged) { + [nftFromSec, nftUpdates] = await blockchain.getNftUpdates(accountId, nftFromSec); + if (!isUpdaterAlive(localOnUpdate) || !isAccountActive(accountId)) return; + nftUpdates.forEach(onUpdate); + } i++; } catch (err) { @@ -275,7 +277,7 @@ export async function tryUpdateTokens(localOnUpdate: OnApiUpdate) { try { const [pricesData, tokens] = await Promise.all([ callBackendGet('/prices', undefined, { - 'X-App-Origin': origin, + 'X-App-Origin': X_APP_ORIGIN, 'X-App-Version': APP_VERSION, 'X-App-ClientID': clientId ?? await getClientId(), 'X-App-Env': APP_ENV, diff --git a/src/api/methods/transactions.ts b/src/api/methods/transactions.ts index 282a723a..87efe38c 100644 --- a/src/api/methods/transactions.ts +++ b/src/api/methods/transactions.ts @@ -13,22 +13,20 @@ export function initTransactions(_onUpdate: OnApiUpdate) { onUpdate = _onUpdate; } -export function fetchTransactions(accountId: string) { +export async function fetchTokenTransactionSlice(accountId: string, slug: string, fromTxId?: string, limit?: number) { const blockchain = blockchains[resolveBlockchainKey(accountId)!]; - return blockchain.getAccountTransactionSlice(accountId); -} - -export function fetchTokenTransactionSlice(accountId: string, slug: string, fromTxId?: string, limit?: number) { - const blockchain = blockchains[resolveBlockchainKey(accountId)!]; + const transactions = await blockchain.getTokenTransactionSlice(accountId, slug, fromTxId, undefined, limit); - return blockchain.getTokenTransactionSlice(accountId, slug, fromTxId, undefined, limit); + return transactions; } -export function fetchAllTransactionSlice(accountId: string, lastTxIds: ApiTxIdBySlug, limit: number) { +export async function fetchAllTransactionSlice(accountId: string, lastTxIds: ApiTxIdBySlug, limit: number) { const blockchain = blockchains[resolveBlockchainKey(accountId)!]; - return blockchain.getMergedTransactionSlice(accountId, lastTxIds, limit); + const transactions = await blockchain.getMergedTransactionSlice(accountId, lastTxIds, limit); + + return transactions; } export function checkTransactionDraft( diff --git a/src/api/methods/wallet.ts b/src/api/methods/wallet.ts index a9989c67..fb31bfec 100644 --- a/src/api/methods/wallet.ts +++ b/src/api/methods/wallet.ts @@ -1,4 +1,6 @@ -import type { ApiNetwork, OnApiUpdate } from '../types'; +import * as tonWebMnemonic from 'tonweb-mnemonic'; + +import type { ApiNetwork } from '../types'; import { parseAccountId } from '../../util/account'; import blockchains from '../blockchains'; @@ -9,40 +11,19 @@ import { } from '../common/accounts'; import * as dappPromises from '../common/dappPromises'; import { resolveBlockchainKey } from '../common/helpers'; -import { storage } from '../storages'; - -let onUpdate: OnApiUpdate; const ton = blockchains.ton; -export async function initWallet(_onUpdate: OnApiUpdate) { - onUpdate = _onUpdate; - - const isTonProxyEnabled = await storage.getItem('isTonProxyEnabled'); - onUpdate({ - type: 'updateTonProxyState', - isEnabled: Boolean(isTonProxyEnabled), - }); - - const isTonMagicEnabled = await storage.getItem('isTonMagicEnabled'); - onUpdate({ - type: 'updateTonMagicState', - isEnabled: Boolean(isTonMagicEnabled), - }); - - const isDeeplinkHookEnabled = await storage.getItem('isDeeplinkHookEnabled'); - onUpdate({ - type: 'updateDeeplinkHookState', - isEnabled: Boolean(isDeeplinkHookEnabled), - }); -} - export function getMnemonic(accountId: string, password: string) { const blockchain = blockchains[resolveBlockchainKey(accountId)!]; return blockchain.fetchMnemonic(accountId, password); } +export function getMnemonicWordList() { + return tonWebMnemonic.wordlists.default; +} + export async function verifyPassword(password: string) { const accountId = await getMainAccountId(); if (!accountId) { diff --git a/src/api/providers/extension/connectorForPopup.ts b/src/api/providers/extension/connectorForPopup.ts index 4666e79e..e3f233cb 100644 --- a/src/api/providers/extension/connectorForPopup.ts +++ b/src/api/providers/extension/connectorForPopup.ts @@ -13,7 +13,7 @@ let connector: Connector; export function initApi(onUpdate: OnApiUpdate, initArgs: ApiInitArgs | (() => ApiInitArgs)) { if (!connector) { const getInitArgs = typeof initArgs === 'function' ? initArgs : () => initArgs; - connector = createExtensionConnector(POPUP_PORT, onUpdate, getInitArgs); + connector = createExtensionConnector(POPUP_PORT, onUpdate, getInitArgs as () => ApiInitArgs); } } diff --git a/src/api/providers/worker/connector.ts b/src/api/providers/worker/connector.ts index 8cd976c4..afd94615 100644 --- a/src/api/providers/worker/connector.ts +++ b/src/api/providers/worker/connector.ts @@ -9,7 +9,9 @@ let connector: Connector; export function initApi(onUpdate: OnApiUpdate, initArgs: ApiInitArgs | (() => ApiInitArgs)) { if (!connector) { - connector = createConnector(new Worker(new URL('./provider.ts', import.meta.url)), onUpdate); + connector = createConnector(new Worker( + /* webpackChunkName: "worker" */ new URL('./provider.ts', import.meta.url), + ), onUpdate); } const args = typeof initArgs === 'function' ? initArgs() : initArgs; diff --git a/src/api/storages/extension.ts b/src/api/storages/extension.ts index b79285d8..474f8b53 100644 --- a/src/api/storages/extension.ts +++ b/src/api/storages/extension.ts @@ -1,6 +1,6 @@ import type { Storage } from './types'; -import { IS_EXTENSION } from '../environment'; +import { IS_EXTENSION } from '../../config'; // eslint-disable-next-line no-restricted-globals const storage = IS_EXTENSION ? self.chrome.storage.local : undefined; diff --git a/src/api/storages/index.ts b/src/api/storages/index.ts index 9558434a..3d856742 100644 --- a/src/api/storages/index.ts +++ b/src/api/storages/index.ts @@ -1,6 +1,6 @@ import { StorageType } from './types'; -import { IS_EXTENSION } from '../environment'; +import { IS_EXTENSION } from '../../config'; import extensionStorage from './extension'; import idb from './idb'; import localStorage from './localStorage'; diff --git a/src/api/tonConnect/index.ts b/src/api/tonConnect/index.ts index 982c45ea..6849722b 100644 --- a/src/api/tonConnect/index.ts +++ b/src/api/tonConnect/index.ts @@ -30,7 +30,7 @@ import type { ApiTonConnectProof, LocalConnectEvent, TransactionPayload, TransactionPayloadMessage, } from './types'; -import { TON_TOKEN_SLUG } from '../../config'; +import { IS_EXTENSION, TON_TOKEN_SLUG } from '../../config'; import { parseAccountId } from '../../util/account'; import { isValidLedgerComment } from '../../util/ledger/utils'; import { logDebugError } from '../../util/logs'; @@ -47,7 +47,6 @@ import { createLocalTransaction, isUpdaterAlive } from '../common/helpers'; import { base64ToBytes, bytesToBase64, handleFetchErrors, sha256, } from '../common/utils'; -import { IS_EXTENSION } from '../environment'; import * as apiErrors from '../errors'; import { activateDapp, diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 6151d173..ec135739 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -16,8 +16,6 @@ export interface AccountIdParsed { } export interface ApiInitArgs { - origin: string; - newestTxId?: string; } export interface ApiBaseToken { @@ -159,13 +157,4 @@ export interface ApiSignedTransfer { params: ApiLocalTransactionParams; } -export interface ApiLocalTransactionParams { - amount: string; - fromAddress: string; - toAddress: string; - comment?: string; - fee: string; - slug: string; - type?: ApiTransactionType; - encryptedComment?: string; -} +export type ApiLocalTransactionParams = Omit; diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 003b0332..96eb488f 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -68,21 +68,6 @@ export type ApiUpdateShowError = { error?: ApiAnyDisplayError; }; -export type ApiUpdateTonProxyState = { - type: 'updateTonProxyState'; - isEnabled: boolean; -}; - -export type ApiUpdateTonMagicState = { - type: 'updateTonMagicState'; - isEnabled: boolean; -}; - -export type ApiUpdateDeeplinkHookState = { - type: 'updateDeeplinkHookState'; - isEnabled: boolean; -}; - export type ApiUpdateStakingState = { type: 'updateStakingState'; accountId: string; @@ -169,10 +154,6 @@ export type ApiUpdate = | ApiUpdateCreateTransaction | ApiUpdateCreateSignature | ApiUpdateTxComplete - | ApiUpdateShowError - | ApiUpdateTonProxyState - | ApiUpdateTonMagicState - | ApiUpdateDeeplinkHookState | ApiUpdateStakingState | ApiUpdateActiveDapp | ApiUpdateDappSendTransactions diff --git a/src/assets/cards/sticky_card_bg.png b/src/assets/cards/sticky_card_bg.png new file mode 100644 index 00000000..eeee2b09 Binary files /dev/null and b/src/assets/cards/sticky_card_bg.png differ diff --git a/src/assets/font-icons/arrow-right.svg b/src/assets/font-icons/arrow-right.svg new file mode 100644 index 00000000..fde63c23 --- /dev/null +++ b/src/assets/font-icons/arrow-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/settings/settings_disclaimer.svg b/src/assets/settings/settings_disclaimer.svg new file mode 100644 index 00000000..946ece66 --- /dev/null +++ b/src/assets/settings/settings_disclaimer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/App.module.scss b/src/components/App.module.scss index b7e927d0..f66b05c2 100644 --- a/src/components/App.module.scss +++ b/src/components/App.module.scss @@ -10,6 +10,8 @@ } .appSlide { + --background-color: var(--color-background-second); + background: var(--color-background-second); /* These styles need to be applied via regular CSS and not as conditional class, since Transition does not work well when `slideClassName` updates */ @@ -37,18 +39,8 @@ } } -.transitionContainer { - :global(html.is-electron) & { - height: calc(100% - 3rem); - } - - /* stylelint-disable-next-line order/order, at-rule-empty-line-before */ - @include respond-below(xs) { - overflow: hidden; - - max-width: 25rem; - margin: 0 auto; - } +:global(html.is-electron) .transitionContainer { + height: calc(100% - 3rem); } .loading { diff --git a/src/components/App.tsx b/src/components/App.tsx index 2e0aaa97..d83e1beb 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,4 +1,4 @@ -import React, { memo, useEffect } from '../lib/teact/teact'; +import React, { memo, useEffect, useState } from '../lib/teact/teact'; import { AppState } from '../global/types'; @@ -64,6 +64,7 @@ function App({ const [isInactive, markInactive] = useFlag(false); const [canPrerenderMain, prerenderMain] = useFlag(); + const [contentActiveTabIndex, setContentActiveTabIndex] = useState(0); const renderingKey = isInactive ? AppState.Inactive @@ -96,18 +97,29 @@ function App({ switch (currentKey) { case AppState.Auth: return ; - case AppState.Main: + case AppState.Main: { + const slideFullClassName = buildClassName( + styles.appSlide, + styles.appSlideContent, + 'custom-scroll', + 'app-slide-content', + ); return ( -
+
); + } case AppState.Settings: return ; case AppState.Ledger: @@ -122,7 +134,7 @@ function App({ {IS_ELECTRON && !IS_LINUX && } { + const handleReload = useLastCallback(() => { window.location.reload(); - }, []); + }); return (
diff --git a/src/components/auth/Auth.module.scss b/src/components/auth/Auth.module.scss index 0f97f6ab..85ddf809 100644 --- a/src/components/auth/Auth.module.scss +++ b/src/components/auth/Auth.module.scss @@ -6,15 +6,12 @@ height: 100%; } -.transitionContainer { - overflow: hidden; - - max-width: 25rem; - margin: 0 auto; +@include respond-above(xs) { + .transitionContainer { + overflow: hidden; - /* stylelint-disable-next-line order/order, at-rule-empty-line-before */ - @include respond-above(xs) { max-width: 31.4375rem; + margin: 0 auto; } } @@ -97,6 +94,10 @@ &_afterSmallSticker { margin: 2rem 0 1.25rem; } + + @media (max-width: 460px) { + word-break: break-word; // For Spanish localization on small screens + } } .title, .appName { @@ -381,7 +382,7 @@ display: flex; align-items: center; - margin: 1.6875rem auto 0; + margin: 1.6875rem auto 1rem; font-size: 1.0625rem !important; font-weight: 600; @@ -497,3 +498,18 @@ margin: 0.75rem -1rem 0; } + +@supports (padding-bottom: env(safe-area-inset-bottom)) { + + @include respond-below(xs) { + .disclaimerBackupDialog { + padding-bottom: max(env(safe-area-inset-bottom), 0); + } + } +} + +@media (min-width: 416.01px) { // 26rem = 416px + .disclaimerBackupDialog { + max-width: 24rem; + } +} diff --git a/src/components/auth/Auth.tsx b/src/components/auth/Auth.tsx index 45d7c3a2..171fb081 100644 --- a/src/components/auth/Auth.tsx +++ b/src/components/auth/Auth.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useState } from '../../lib/teact/teact'; +import React, { memo, useState } from '../../lib/teact/teact'; import { getActions } from '../../lib/teact/teactn'; import { AuthState } from '../../global/types'; @@ -9,9 +9,11 @@ import { pick } from '../../util/iteratees'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import { useDeviceScreen } from '../../hooks/useDeviceScreen'; +import useLastCallback from '../../hooks/useLastCallback'; import SettingsAbout from '../settings/SettingsAbout'; import Transition from '../ui/Transition'; +import AuthCreateBackup from './AuthCreateBackup'; import AuthCreatePassword from './AuthCreatePassword'; import AuthCreatingWallet from './AuthCreatingWallet'; import AuthDisclaimer from './AuthDisclaimer'; @@ -46,9 +48,9 @@ const Auth = ({ ) ?? -1; const [nextKey, setNextKey] = useState(renderingAuthState + 1); - const updateNextKey = useCallback(() => { + const updateNextKey = useLastCallback(() => { setNextKey(renderingAuthState + 1); - }, [renderingAuthState]); + }); // eslint-disable-next-line consistent-return function renderAuthScreen(isActive: boolean, isFrom: boolean, currentKey: number) { @@ -59,6 +61,8 @@ const Auth = ({ return ; case AuthState.createPassword: return ; + case AuthState.createBackup: + return ; case AuthState.disclaimerAndBackup: return ( {renderAuthScreen} diff --git a/src/components/auth/AuthCreateBackup.tsx b/src/components/auth/AuthCreateBackup.tsx new file mode 100644 index 00000000..25144476 --- /dev/null +++ b/src/components/auth/AuthCreateBackup.tsx @@ -0,0 +1,157 @@ +import React, { memo, useState } from '../../lib/teact/teact'; + +import { getActions } from '../../global'; +import renderText from '../../global/helpers/renderText'; +import buildClassName from '../../util/buildClassName'; +import { ANIMATED_STICKERS_PATHS } from '../ui/helpers/animatedAssets'; + +import useFlag from '../../hooks/useFlag'; +import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; + +import AnimatedIconWithPreview from '../ui/AnimatedIconWithPreview'; +import Button from '../ui/Button'; +import Modal from '../ui/Modal'; +import Transition from '../ui/Transition'; +import MnemonicCheck from './MnemonicCheck'; +import MnemonicList from './MnemonicList'; +import SafetyRules from './SafetyRules'; + +import modalStyles from '../ui/Modal.module.scss'; +import styles from './Auth.module.scss'; + +interface OwnProps { + isActive?: boolean; + mnemonic?: string[]; + checkIndexes?: number[]; +} + +enum BackupState { + Accept, + View, + Confirm, +} + +const SLIDE_ANIMATION_DURATION_MS = 250; + +const AuthCreateBackup = ({ isActive, mnemonic, checkIndexes }: OwnProps) => { + const { afterCheckMnemonic, skipCheckMnemonic, restartCheckMnemonicIndexes } = getActions(); + + const lang = useLang(); + const [isModalOpen, openModal, closeModal] = useFlag(); + + const [renderingKey, setRenderingKey] = useState(BackupState.Accept); + const [nextKey, setNextKey] = useState(BackupState.View); + + const handleModalClose = useLastCallback(() => { + setRenderingKey(BackupState.Accept); + setNextKey(BackupState.View); + }); + + const handleMnemonicView = useLastCallback(() => { + setRenderingKey(BackupState.View); + setNextKey(BackupState.Confirm); + }); + + const handleRestartCheckMnemonic = useLastCallback(() => { + handleMnemonicView(); + + setTimeout(() => { + restartCheckMnemonicIndexes(); + }, SLIDE_ANIMATION_DURATION_MS); + }); + + const handleShowMnemonicCheck = useLastCallback(() => { + setRenderingKey(BackupState.Confirm); + setNextKey(undefined); + }); + + const handleMnemonicCheckSubmit = useLastCallback(() => { + closeModal(); + afterCheckMnemonic(); + }); + + // eslint-disable-next-line consistent-return + function renderModalContent(isScreenActive: boolean, isFrom: boolean, currentScreenKey: number) { + switch (currentScreenKey) { + case BackupState.Accept: + return ; + + case BackupState.View: + return ( + + ); + + case BackupState.Confirm: + return ( + + ); + } + } + + return ( +
+
+ +
{lang('Create Backup')}
+
+

{renderText(lang('$auth_backup_description1'))}

+

{renderText(lang('$auth_backup_description2'))}

+

{renderText(lang('$auth_backup_description3'))}

+
+
+ + +
+
+ + + + {renderModalContent} + + +
+ ); +}; + +export default memo(AuthCreateBackup); diff --git a/src/components/auth/AuthCreatePassword.tsx b/src/components/auth/AuthCreatePassword.tsx index cc9a1518..b116be72 100644 --- a/src/components/auth/AuthCreatePassword.tsx +++ b/src/components/auth/AuthCreatePassword.tsx @@ -1,5 +1,5 @@ import React, { - memo, useCallback, useEffect, useRef, useState, + memo, useEffect, useRef, useState, } from '../../lib/teact/teact'; import type { AuthMethod } from '../../global/types'; @@ -11,6 +11,7 @@ import { ANIMATED_STICKERS_PATHS } from '../ui/helpers/animatedAssets'; import useFlag from '../../hooks/useFlag'; import useFocusAfterAnimation from '../../hooks/useFocusAfterAnimation'; import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; import { usePasswordValidation } from '../../hooks/usePasswordValidation'; import AnimatedIconWithPreview from '../ui/AnimatedIconWithPreview'; @@ -76,25 +77,25 @@ const AuthCreatePassword = ({ isActive, firstPassword, secondPassword, validation, isSecondPasswordFocused, isPasswordFocused, isJustSubmitted, ]); - const handleFirstPasswordChange = useCallback((value: string) => { + const handleFirstPasswordChange = useLastCallback((value: string) => { setFirstPassword(value); if (isJustSubmitted) { setIsJustSubmitted(false); } - }, [isJustSubmitted]); + }); - const handleSecondPasswordChange = useCallback((value: string) => { + const handleSecondPasswordChange = useLastCallback((value: string) => { setSecondPassword(value); if (isJustSubmitted) { setIsJustSubmitted(false); } - }, [isJustSubmitted]); + }); - const handleCancel = useCallback(() => { + const handleCancel = useLastCallback(() => { restartAuth(); - }, [restartAuth]); + }); - const handleSubmit = useCallback((e: React.FormEvent) => { + const handleSubmit = useLastCallback((e: React.FormEvent) => { e.preventDefault(); e.stopPropagation(); @@ -120,10 +121,7 @@ const AuthCreatePassword = ({ closeWeakPasswordModal(); } afterCreatePassword({ password: firstPassword }); - }, [ - afterCreatePassword, canSubmit, firstPassword, isWeakPasswordModalOpen, openWeakPasswordModal, - secondPassword, validation, closeWeakPasswordModal, - ]); + }); const shouldRenderError = hasError && !isPasswordFocused; diff --git a/src/components/auth/AuthDisclaimer.tsx b/src/components/auth/AuthDisclaimer.tsx index afe0c181..12ab7bd3 100644 --- a/src/components/auth/AuthDisclaimer.tsx +++ b/src/components/auth/AuthDisclaimer.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useState } from '../../lib/teact/teact'; +import React, { memo, useState } from '../../lib/teact/teact'; import { ANIMATED_STICKER_MIDDLE_SIZE_PX } from '../../config'; import { getActions } from '../../global'; @@ -63,6 +63,11 @@ const AuthDisclaimer = ({ setIsInformationConfirmed(false); }); + const handleSkipMnemonic = useLastCallback(() => { + skipCheckMnemonic(); + handleCloseBackupWarningModal(); + }); + const handleModalClose = useLastCallback(() => { setRenderingKey(BackupState.Accept); setNextKey(BackupState.View); @@ -73,23 +78,23 @@ const AuthDisclaimer = ({ setNextKey(BackupState.Confirm); }); - const handleRestartCheckMnemonic = useCallback(() => { + const handleRestartCheckMnemonic = useLastCallback(() => { handleMnemonicView(); setTimeout(() => { restartCheckMnemonicIndexes(); }, SLIDE_ANIMATION_DURATION_MS); - }, [handleMnemonicView, restartCheckMnemonicIndexes]); + }); const handleShowMnemonicCheck = useLastCallback(() => { setRenderingKey(BackupState.Confirm); setNextKey(undefined); }); - const handleMnemonicCheckSubmit = useCallback(() => { + const handleMnemonicCheckSubmit = useLastCallback(() => { closeModal(); afterCheckMnemonic(); - }, [afterCheckMnemonic, closeModal]); + }); // eslint-disable-next-line consistent-return function renderModalContent(isScreenActive: boolean, isFrom: boolean, currentScreenKey: number) { @@ -162,10 +167,9 @@ const AuthDisclaimer = ({

{renderText(lang('$auth_backup_warning_notice'))}

@@ -175,7 +179,7 @@ const AuthDisclaimer = ({ @@ -184,14 +188,13 @@ const AuthDisclaimer = ({ >({}); const { isPortrait } = useDeviceScreen(); - const handlePasteMnemonic = useCallback((pastedText: string) => { + const handlePasteMnemonic = useLastCallback((pastedText: string) => { const pastedMnemonic = parsePastedText(pastedText); if (pastedMnemonic.length === 1 && document.activeElement?.id.startsWith('import-mnemonic-')) { @@ -69,7 +70,7 @@ const AuthImportMnemonic = ({ isActive, isLoading, error }: OwnProps & StateProp if (document.activeElement?.id.startsWith('import-mnemonic-')) { (document.activeElement as HTMLInputElement).blur(); } - }, []); + }); useClipboardPaste(Boolean(isActive), handlePasteMnemonic); @@ -79,24 +80,24 @@ const AuthImportMnemonic = ({ isActive, isLoading, error }: OwnProps & StateProp return mnemonicValues.length !== MNEMONIC_COUNT || mnemonicValues.some((word) => !word); }, [mnemonic]); - const handleSetWord = useCallback((value: string, index: number) => { + const handleSetWord = useLastCallback((value: string, index: number) => { setMnemonic({ ...mnemonic, [index]: value?.toLowerCase(), }); - }, [mnemonic]); + }); - const handleCancel = useCallback(() => { + const handleCancel = useLastCallback(() => { setTimeout(() => { restartAuth(); }, SLIDE_ANIMATION_DURATION_MS); - }, [restartAuth]); + }); - const handleSubmit = useCallback(() => { + const handleSubmit = useLastCallback(() => { if (!isSubmitDisabled) { afterImportMnemonic({ mnemonic: Object.values(mnemonic) }); } - }, [afterImportMnemonic, isSubmitDisabled, mnemonic]); + }); useEffect(() => { return isSubmitDisabled diff --git a/src/components/auth/AuthStart.tsx b/src/components/auth/AuthStart.tsx index 282c16f6..857a9450 100644 --- a/src/components/auth/AuthStart.tsx +++ b/src/components/auth/AuthStart.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback } from '../../lib/teact/teact'; +import React, { memo } from '../../lib/teact/teact'; import { APP_NAME, MNEMONIC_COUNT } from '../../config'; import { getActions } from '../../global'; @@ -28,10 +28,6 @@ const AuthStart = () => { const [isLogoReady, markLogoReady] = useFlag(); const { transitionClassNames } = useShowTransition(isLogoReady, undefined, undefined, 'slow'); - const handleCreateWallet = useCallback(() => { - startCreatingWallet(); - }, [startCreatingWallet]); - return (
{ diff --git a/src/components/auth/MnemonicCheck.tsx b/src/components/auth/MnemonicCheck.tsx index 08518159..393adebc 100644 --- a/src/components/auth/MnemonicCheck.tsx +++ b/src/components/auth/MnemonicCheck.tsx @@ -1,6 +1,6 @@ import type { FormEvent } from 'react'; import React, { - memo, useCallback, useEffect, useState, + memo, useEffect, useState, } from '../../lib/teact/teact'; import { MNEMONIC_CHECK_COUNT } from '../../config'; @@ -9,6 +9,7 @@ import buildClassName from '../../util/buildClassName'; import { areSortedArraysEqual } from '../../util/iteratees'; import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; import InputMnemonic from '../common/InputMnemonic'; import Button from '../ui/Button'; @@ -42,14 +43,14 @@ function MnemonicCheck({ } }, [isActive]); - const handleSetWord = useCallback((value: string, index: number) => { + const handleSetWord = useLastCallback((value: string, index: number) => { setWords({ ...words, [index]: value?.toLowerCase(), }); - }, [words]); + }); - const handleMnemonicCheckSubmit = useCallback((e: FormEvent) => { + const handleMnemonicCheckSubmit = useLastCallback((e: FormEvent) => { e.preventDefault(); const answer = mnemonic && checkIndexes?.map((index) => mnemonic[index]); if (answer && areSortedArraysEqual(answer, Object.values(words))) { @@ -57,7 +58,7 @@ function MnemonicCheck({ } else { setHasMnemonicError(true); } - }, [onSubmit, checkIndexes, mnemonic, words]); + }); return (
diff --git a/src/components/auth/SafetyRules.tsx b/src/components/auth/SafetyRules.tsx index f25c7566..2622a4af 100644 --- a/src/components/auth/SafetyRules.tsx +++ b/src/components/auth/SafetyRules.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useState } from '../../lib/teact/teact'; +import React, { memo, useState } from '../../lib/teact/teact'; import { ANIMATED_STICKER_SMALL_SIZE_PX } from '../../config'; import renderText from '../../global/helpers/renderText'; @@ -6,6 +6,7 @@ import buildClassName from '../../util/buildClassName'; import { ANIMATED_STICKERS_PATHS } from '../ui/helpers/animatedAssets'; import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; import AnimatedIconWithPreview from '../ui/AnimatedIconWithPreview'; import Button from '../ui/Button'; @@ -28,13 +29,13 @@ function SafetyRules({ isActive, onSubmit, onClose }: OwnProps) { const [canBeStolenAccepted, setCanBeStolenAccepted] = useState(false); const canSubmit = writedownAccepted && openWalletAccepted && canBeStolenAccepted; - const handleSubmit = useCallback(() => { + const handleSubmit = useLastCallback(() => { if (!canSubmit) { return; } onSubmit(); - }, [canSubmit, onSubmit]); + }); return (
diff --git a/src/components/common/InputMnemonic.tsx b/src/components/common/InputMnemonic.tsx index 2daedf62..12449f5b 100644 --- a/src/components/common/InputMnemonic.tsx +++ b/src/components/common/InputMnemonic.tsx @@ -1,11 +1,12 @@ -import { wordlists } from 'tonweb-mnemonic'; import React, { - memo, useCallback, useEffect, useState, + memo, useEffect, useState, } from '../../lib/teact/teact'; import buildClassName from '../../util/buildClassName'; +import { callApi } from '../../api'; import useFlag from '../../hooks/useFlag'; +import useLastCallback from '../../hooks/useLastCallback'; import SuggestionList from '../ui/SuggestionList'; @@ -23,7 +24,6 @@ type OwnProps = { onInput: (value: string, inputArg?: any) => void; }; -const { default: mnemonicSuggestions } = wordlists; const SUGGESTION_WORDS_COUNT = 7; function InputMnemonic({ @@ -34,21 +34,29 @@ function InputMnemonic({ const [filteredSuggestions, setFilteredSuggestions] = useState([]); const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0); const [showSuggestions, setShowSuggestions] = useState(false); + const [wordlist, setWordlist] = useState([]); const shouldRenderSuggestions = showSuggestions && value && filteredSuggestions.length > 0; + useEffect(() => { + (async () => { + const words = await callApi('getMnemonicWordList'); + setWordlist(words ?? []); + })(); + }, []); + useEffect(() => { if (showSuggestions && value && filteredSuggestions.length === 0) { setHasError(true); - } else if (!hasFocus && value && !isCorrectMnemonic(value)) { + } else if (!hasFocus && value && !isCorrectMnemonic(value, wordlist)) { setHasError(true); } else { setHasError(false); } - }, [filteredSuggestions.length, hasFocus, showSuggestions, value]); + }, [filteredSuggestions.length, hasFocus, showSuggestions, value, wordlist]); const processSuggestions = (userInput: string) => { // Filter our suggestions that don't contain the user's input - const unLinked = mnemonicSuggestions.filter( + const unLinked = wordlist.filter( (suggestion) => suggestion.toLowerCase().startsWith(userInput.toLowerCase()), ).slice(0, SUGGESTION_WORDS_COUNT); @@ -109,12 +117,12 @@ function InputMnemonic({ } }; - const handleClick = useCallback((suggestion: string) => { + const handleClick = useLastCallback((suggestion: string) => { onInput(suggestion, inputArg); setShowSuggestions(false); setActiveSuggestionIndex(0); setFilteredSuggestions([]); - }, [inputArg, onInput]); + }); const handleFocus = (e: React.FocusEvent) => { processSuggestions(e.target.value); @@ -167,8 +175,8 @@ function InputMnemonic({ ); } -function isCorrectMnemonic(mnemonic: string) { - return mnemonicSuggestions.includes(mnemonic); +function isCorrectMnemonic(mnemonic: string, wordlist: string[]) { + return wordlist.includes(mnemonic); } export default memo(InputMnemonic); diff --git a/src/components/common/TransferResult.tsx b/src/components/common/TransferResult.tsx index 42a83ebf..73d9cd51 100644 --- a/src/components/common/TransferResult.tsx +++ b/src/components/common/TransferResult.tsx @@ -55,7 +55,7 @@ function TransferResult({ {firstButtonText && ( )} - {firstButtonText && ( + {secondButtonText && ( )}
diff --git a/src/components/dapps/DappConnectModal.tsx b/src/components/dapps/DappConnectModal.tsx index 63663ee0..1ee0ab9c 100644 --- a/src/components/dapps/DappConnectModal.tsx +++ b/src/components/dapps/DappConnectModal.tsx @@ -1,5 +1,5 @@ import React, { - memo, useCallback, useEffect, useMemo, useState, + memo, useEffect, useMemo, useState, } from '../../lib/teact/teact'; import { DappConnectState } from '../../global/types'; @@ -19,6 +19,7 @@ import { shortenAddress } from '../../util/shortenAddress'; import useFlag from '../../hooks/useFlag'; import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; import useModalTransitionKeys from '../../hooks/useModalTransitionKeys'; import LedgerConfirmOperation from '../ledger/LedgerConfirmOperation'; @@ -104,11 +105,7 @@ function DappConnectModal({ const { iconUrl, name, url } = dapp || {}; - const handleClose = useCallback(() => { - cancelDappConnectRequestConfirm(); - }, [cancelDappConnectRequestConfirm]); - - const handleSubmit = useCallback(() => { + const handleSubmit = useLastCallback(() => { closeConfirm(); if (!requiredProof) { @@ -122,24 +119,24 @@ function DappConnectModal({ } else if (requiredPermissions?.isPasswordRequired) { setDappConnectRequestState({ state: DappConnectState.Password }); } - }, [requiredProof, accounts, currentAccountId, requiredPermissions?.isPasswordRequired, selectedAccount]); + }); - const handlePasswordCancel = useCallback(() => { + const handlePasswordCancel = useLastCallback(() => { setDappConnectRequestState({ state: DappConnectState.Info }); - }, []); + }); - const submitDappConnectRequestHardware = useCallback(() => { + const submitDappConnectRequestHardware = useLastCallback(() => { submitDappConnectRequestConfirmHardware({ accountId: selectedAccount, }); - }, [selectedAccount]); + }); - const handlePasswordSubmit = useCallback((password: string) => { + const handlePasswordSubmit = useLastCallback((password: string) => { submitDappConnectRequestConfirm({ accountId: selectedAccount, password, }); - }, [selectedAccount]); + }); const iterableAccounts = useMemo(() => { return Object.entries(accounts || {}); @@ -264,13 +261,12 @@ function DappConnectModal({ <> { + const handleDisconnect = useLastCallback(() => { onDisconnect!(origin!); - }, [origin, onDisconnect]); + }); function renderIcon() { if (iconUrl) { diff --git a/src/components/dapps/DappTransactionModal.tsx b/src/components/dapps/DappTransactionModal.tsx index 72f20834..a34f8763 100644 --- a/src/components/dapps/DappTransactionModal.tsx +++ b/src/components/dapps/DappTransactionModal.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useMemo } from '../../lib/teact/teact'; +import React, { memo, useMemo } from '../../lib/teact/teact'; import { TransferState } from '../../global/types'; import type { GlobalState, HardwareConnectState, UserToken } from '../../global/types'; @@ -10,6 +10,7 @@ import buildClassName from '../../util/buildClassName'; import { ANIMATED_STICKERS_PATHS } from '../ui/helpers/animatedAssets'; import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; import useModalTransitionKeys from '../../hooks/useModalTransitionKeys'; import LedgerConfirmOperation from '../ledger/LedgerConfirmOperation'; @@ -63,15 +64,15 @@ function DappTransactionModal({ const { renderingKey, nextKey, updateNextKey } = useModalTransitionKeys(state, isOpen); - const handleBackClick = useCallback(() => { + const handleBackClick = useLastCallback(() => { if (state === TransferState.Confirm || state === TransferState.Password) { setDappTransferScreen({ state: TransferState.Initial }); } - }, [setDappTransferScreen, state]); + }); - const handleTransferPasswordSubmit = useCallback((password: string) => { + const handleTransferPasswordSubmit = useLastCallback((password: string) => { submitDappTransferPassword({ password }); - }, [submitDappTransferPassword]); + }); function renderSingleTransaction(isActive: boolean) { const transaction = viewTransactionOnIdx !== undefined ? transactions?.[viewTransactionOnIdx] : undefined; @@ -170,7 +171,6 @@ function DappTransactionModal({ return ( (null); useElectronDrag(containerRef); - const handleMinimize = useCallback(() => { + const handleMinimize = useLastCallback(() => { window.electron?.minimize(); - }, []); + }); - const handleMaximize = useCallback(async () => { + const handleMaximize = useLastCallback(async () => { if (await window.electron?.getIsMaximized()) { window.electron?.unmaximize(); } else { window.electron?.maximize(); } - }, []); + }); - const handleClose = useCallback(() => { + const handleClose = useLastCallback(() => { window.electron?.close(); - }, []); + }); - const handleDoubleClick = useCallback(() => { + const handleDoubleClick = useLastCallback(() => { window.electron?.handleDoubleClick(); - }, []); + }); if (IS_WINDOWS) { return ( diff --git a/src/components/electron/UpdateApp.tsx b/src/components/electron/UpdateApp.tsx index 81f99d21..8473b341 100644 --- a/src/components/electron/UpdateApp.tsx +++ b/src/components/electron/UpdateApp.tsx @@ -1,5 +1,6 @@ +import { requestMutation } from '../../lib/fasterdom/fasterdom'; import React, { - memo, useCallback, useEffect, useMemo, useRef, useState, + memo, useEffect, useMemo, useRef, useState, } from '../../lib/teact/teact'; import { ElectronEvent } from '../../electron/types'; @@ -9,6 +10,7 @@ import getBoundingClientRectsAsync from '../../util/getBoundingClientReactAsync' import useFlag from '../../hooks/useFlag'; import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; import useShowTransition from '../../hooks/useShowTransition'; import styles from './UpdateApp.module.scss'; @@ -17,7 +19,7 @@ const PROGRESS_OFFSET = 28.5; // Minimum progress in % when progressbar starts t // Fake progress is shown between Update click and actual download progress received const FAKE_PROGRESS_STEP = 1; -const FAKE_PROGRESS_TIMEOUT = 1 * 1000; +const FAKE_PROGRESS_TIMEOUT_MS = 1000; const FAKE_PROGRESS_MAX = 20; function UpdateApp() { @@ -37,13 +39,13 @@ function UpdateApp() { const timer = useRef(); const isCanceled = useRef(false); - const reset = useCallback(() => { + const reset = useLastCallback(() => { clearInterval(timer.current); setIsUpdateAvailable(true); setIsUpdateDownloaded(false); setFakeProgress(0); setProgress(0); - }, []); + }); useEffect(() => { const removeUpdateErrorListener = window.electron?.on(ElectronEvent.UPDATE_ERROR, () => { @@ -81,7 +83,7 @@ function UpdateApp() { }; }, [reset, enable]); - const handleClick = useCallback(async () => { + const handleClick = useLastCallback(async () => { isCanceled.current = false; if (isDisabled || progress) { @@ -106,11 +108,11 @@ function UpdateApp() { return fp + FAKE_PROGRESS_STEP; }); - }, FAKE_PROGRESS_TIMEOUT); + }, FAKE_PROGRESS_TIMEOUT_MS); } - }, [progress, isUpdateDownloaded, isUpdateAvailable, isDisabled, disable]); + }); - const handleCancel = useCallback(async (event: React.MouseEvent) => { + const handleCancel = useLastCallback(async (event: React.MouseEvent) => { event.stopPropagation(); disable(); @@ -122,7 +124,7 @@ function UpdateApp() { } reset(); - }, [reset, progress, disable, enable]); + }); const { transitionClassNames, shouldRender } = useShowTransition(isUpdateDownloaded); @@ -192,19 +194,21 @@ function useContainerAnimation(text?: string) { const textRef = useRef(null); // eslint-disable-line no-null/no-null const lastWidthRef = useRef(); - const calculateWidth = useCallback(() => { + const calculateWidth = useLastCallback(() => { if (!textRef.current) { return; } getBoundingClientRectsAsync(textRef.current).then((rect) => { if (lastWidthRef.current !== rect.width) { - // Text width + icon width (19) + paddings (8 * 2) - containerRef.current!.style.maxWidth = `${rect.width + 19 + 16}px`; - lastWidthRef.current = rect.width; + requestMutation(() => { + // Text width + icon width (19) + paddings (8 * 2) + containerRef.current!.style.maxWidth = `${rect.width + 19 + 16}px`; + lastWidthRef.current = rect.width; + }); } }); - }, []); + }); useEffect(() => { calculateWidth(); diff --git a/src/components/ledger/LedgerConfirmOperation.tsx b/src/components/ledger/LedgerConfirmOperation.tsx index 65371867..70c4f4ed 100644 --- a/src/components/ledger/LedgerConfirmOperation.tsx +++ b/src/components/ledger/LedgerConfirmOperation.tsx @@ -1,7 +1,4 @@ -import React, { - memo, useEffect, - useState, -} from '../../lib/teact/teact'; +import React, { memo, useEffect, useState } from '../../lib/teact/teact'; import { ANIMATED_STICKER_BIG_SIZE_PX } from '../../config'; import buildClassName from '../../util/buildClassName'; @@ -111,7 +108,7 @@ function LedgerConfirmOperation({ return ( { - connectHardwareWallet(); - }, [connectHardwareWallet]); - - const handleConnected = useCallback(() => { + const handleConnected = useLastCallback(() => { if (isRemoteTab) { return; } @@ -63,7 +60,7 @@ function LedgerConnect({ resetHardwareWalletConnect(); }, NEXT_SLIDE_DELAY); }, NEXT_SLIDE_DELAY); - }, [onConnected, resetHardwareWalletConnect, isRemoteTab]); + }); useEffect(() => { if (state === HardwareConnectState.Connected) { @@ -103,7 +100,7 @@ function LedgerConnect({ isPrimary isDisabled={isConnecting} className={styles.button} - onClick={handleConnect} + onClick={connectHardwareWallet} > {isFailed ? lang('Try Again') : lang('Continue')} diff --git a/src/components/ledger/LedgerModal.tsx b/src/components/ledger/LedgerModal.tsx index 882db7e8..626c1fc0 100644 --- a/src/components/ledger/LedgerModal.tsx +++ b/src/components/ledger/LedgerModal.tsx @@ -1,5 +1,5 @@ import React, { - memo, useCallback, useState, + memo, useState, } from '../../lib/teact/teact'; import type { Account, HardwareConnectState } from '../../global/types'; @@ -9,6 +9,8 @@ import { getActions, withGlobal } from '../../global'; import { selectNetworkAccounts } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; +import useLastCallback from '../../hooks/useLastCallback'; + import Modal from '../ui/Modal'; import Transition from '../ui/Transition'; import LedgerConnect from './LedgerConnect'; @@ -50,6 +52,7 @@ function LedgerModal({ const { resetHardwareWalletConnect, } = getActions(); + const [currentSlide, setCurrentSlide] = useState( LedgerModalState.Connect, ); @@ -57,14 +60,14 @@ function LedgerModal({ LedgerModalState.SelectWallets, ); - const handleConnected = useCallback(() => { + const handleConnected = useLastCallback(() => { setCurrentSlide(LedgerModalState.SelectWallets); - }, []); + }); - const handleLedgerModalClose = useCallback(() => { + const handleLedgerModalClose = useLastCallback(() => { setCurrentSlide(LedgerModalState.Connect); resetHardwareWalletConnect(); - }, [resetHardwareWalletConnect]); + }); // eslint-disable-next-line consistent-return function renderContent(isActive: boolean, isFrom: boolean, currentKey: number) { @@ -94,14 +97,13 @@ function LedgerModal({ return ( ([]); const shouldCloseOnCancel = !onCancel; - const handleAccountToggle = useCallback((index: number) => { + const handleAccountToggle = useLastCallback((index: number) => { if (selectedAccountIndices.includes(index)) { setSelectedAccountIndices(selectedAccountIndices.filter((id) => id !== index)); } else { setSelectedAccountIndices(selectedAccountIndices.concat([index])); } - }, [selectedAccountIndices]); + }); - const handleAddLedgerWallets = useCallback(() => { + const handleAddLedgerWallets = useLastCallback(() => { afterSelectHardwareWallets({ hardwareSelectedIndices: selectedAccountIndices }); onClose(); - }, [afterSelectHardwareWallets, selectedAccountIndices, onClose]); + }); const alreadyConnectedList = useMemo( () => Object.values(accounts ?? []).map(({ address }) => address), diff --git a/src/components/main/Main.module.scss b/src/components/main/Main.module.scss index fade16e3..3d22d89a 100644 --- a/src/components/main/Main.module.scss +++ b/src/components/main/Main.module.scss @@ -10,13 +10,16 @@ $scrollOffset: 0.1875rem; display: flex; flex-direction: column; - max-width: 25rem; height: auto; min-height: calc(var(--vh, 1vh) * 100); max-height: none; - margin: auto; padding: 0 0.75rem !important; + // Fix for opera, dead zone of 37 pixels in extension window on windows + :global(html.is-windows.is-opera.is-extension) & { + padding-top: 1.875rem !important; + } + @include adapt-padding-to-scrollbar(0.75rem, !important); /* stylelint-disable-line order/order */ @supports (padding-bottom: env(safe-area-inset-bottom)) { @@ -27,6 +30,12 @@ $scrollOffset: 0.1875rem; padding-bottom: max(env(safe-area-inset-bottom), 1rem) !important; } } + + .head { + width: 100%; + max-width: 25rem; + margin: 0 auto; + } } .head { @@ -51,6 +60,11 @@ $scrollOffset: 0.1875rem; padding-top: 0; } + // Fix for opera, dead zone of 37 pixels in extension window on windows + :global(html.is-windows.is-opera.is-extension) & { + padding-top: 2.3125rem; + } + @supports (column-gap: max(0px, 1px)) { column-gap: max(0px, calc(0.75rem - var(--scrollbar-width) - #{$scrollOffset})); } diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 932f69e8..9145ba90 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -1,24 +1,34 @@ import React, { - memo, useCallback, useEffect, useState, + memo, useEffect, useRef, useState, } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; import { selectCurrentAccount, selectCurrentAccountState } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; +import { REM } from '../../util/windowEnvironment'; import { useDeviceScreen } from '../../hooks/useDeviceScreen'; import useFlag from '../../hooks/useFlag'; +import useLastCallback from '../../hooks/useLastCallback'; +import useShowTransition from '../../hooks/useShowTransition'; +import ReceiveModal from '../receive/ReceiveModal'; import StakeModal from '../staking/StakeModal'; import StakingInfoModal from '../staking/StakingInfoModal'; import UnstakingModal from '../staking/UnstakeModal'; import { LandscapeActions, PortraitActions } from './sections/Actions'; import Card from './sections/Card'; +import StickyCard from './sections/Card/StickyCard'; import Content from './sections/Content'; import Warnings from './sections/Warnings'; import styles from './Main.module.scss'; +interface OwnProps { + initialContentTabIndex?: number; + onChangeContentTabIndex?: (index: number) => void; +} + type StateProps = { currentTokenSlug?: string; currentAccountId?: string; @@ -28,9 +38,12 @@ type StateProps = { isLedger?: boolean; }; +const STICKY_CARD_INTERSECTION_THRESHOLD = -3.75 * REM; + function Main({ currentTokenSlug, currentAccountId, isStakingActive, isUnstakeRequested, isTestnet, isLedger, -}: StateProps) { + initialContentTabIndex = 0, onChangeContentTabIndex, +}: OwnProps & StateProps) { const { selectToken, startStaking, @@ -38,9 +51,23 @@ function Main({ openBackupWalletModal, } = getActions(); - const [activeTabIndex, setActiveTabIndex] = useState(currentTokenSlug ? 1 : 0); + // eslint-disable-next-line no-null/no-null + const cardRef = useRef(null); + const [canRenderStickyCard, setCanRenderStickyCard] = useState(false); + const [activeTabIndex, setActiveTabIndex] = useState(currentTokenSlug ? 1 : initialContentTabIndex); const [isStakingInfoOpened, openStakingInfo, closeStakingInfo] = useFlag(false); + const [isReceiveModalOpened, openReceiveModal, closeReceiveModal] = useFlag(false); const { isPortrait } = useDeviceScreen(); + const { + shouldRender: shouldRenderStickyCard, + transitionClassNames: stickyCardTransitionClassNames, + } = useShowTransition(canRenderStickyCard); + + useEffect(() => { + return () => { + onChangeContentTabIndex?.(activeTabIndex); + }; + }, [activeTabIndex, onChangeContentTabIndex]); useEffect(() => { if (currentAccountId && (isStakingActive || isUnstakeRequested)) { @@ -48,30 +75,55 @@ function Main({ } }, [fetchBackendStakingState, currentAccountId, isStakingActive, isUnstakeRequested]); - const handleTokenCardClose = useCallback(() => { + useEffect(() => { + if (!isPortrait) { + setCanRenderStickyCard(false); + return undefined; + } + + const observer = new IntersectionObserver((entries) => { + const { isIntersecting, boundingClientRect: { left, width } } = entries[0]; + setCanRenderStickyCard(entries.length > 0 && !isIntersecting && left >= 0 && left < width); + }, { rootMargin: `${STICKY_CARD_INTERSECTION_THRESHOLD}px 0px 0px` }); + const cardElement = cardRef.current; + + if (cardElement) { + observer.observe(cardElement); + } + + return () => { + if (cardElement) { + observer.unobserve(cardElement); + } + }; + }, [isPortrait]); + + const handleTokenCardClose = useLastCallback(() => { selectToken({ slug: undefined }); setActiveTabIndex(0); - }, [selectToken]); + }); - const handleEarnClick = useCallback(() => { + const handleEarnClick = useLastCallback(() => { if (isStakingActive || isUnstakeRequested) { openStakingInfo(); } else { startStaking(); } - }, [isStakingActive, isUnstakeRequested, openStakingInfo, startStaking]); + }); function renderPortraitLayout() { return (
- + + {shouldRenderStickyCard && }
@@ -114,13 +166,14 @@ function Main({ + ); } export default memo( - withGlobal((global, ownProps, detachWhenChanged): StateProps => { + withGlobal((global, ownProps, detachWhenChanged): StateProps => { detachWhenChanged(global.currentAccountId); const accountState = selectCurrentAccountState(global); const account = selectCurrentAccount(global); diff --git a/src/components/main/modals/AddAccountModal.tsx b/src/components/main/modals/AddAccountModal.tsx index 6dea35c3..0b322445 100644 --- a/src/components/main/modals/AddAccountModal.tsx +++ b/src/components/main/modals/AddAccountModal.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useState } from '../../../lib/teact/teact'; +import React, { memo, useState } from '../../../lib/teact/teact'; import type { Account, HardwareConnectState } from '../../../global/types'; import type { LedgerWalletInfo } from '../../../util/ledger/types'; @@ -12,6 +12,7 @@ import { IS_LEDGER_SUPPORTED } from '../../../util/windowEnvironment'; import { ANIMATED_STICKERS_PATHS } from '../../ui/helpers/animatedAssets'; import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; import LedgerConnect from '../../ledger/LedgerConnect'; import LedgerSelectWallets from '../../ledger/LedgerSelectWallets'; @@ -56,7 +57,7 @@ function AddAccountModal({ hardwareState, isLedgerConnected, isTonAppConnected, -}: StateProps & StateProps) { +}: StateProps) { const { addAccount, clearAccountError, closeAddAccountModal } = getActions(); const lang = useLang(); @@ -64,17 +65,17 @@ function AddAccountModal({ const [isNewAccountImporting, setIsNewAccountImporting] = useState(false); - const handleBackClick = useCallback(() => { + const handleBackClick = useLastCallback(() => { setRenderingKey(RenderingState.Initial); clearAccountError(); - }, [clearAccountError]); + }); - const handleModalClose = useCallback(() => { + const handleModalClose = useLastCallback(() => { setRenderingKey(RenderingState.Initial); setIsNewAccountImporting(false); - }, []); + }); - const handleNewAccountClick = useCallback(() => { + const handleNewAccountClick = useLastCallback(() => { if (!firstNonHardwareAccount) { addAccount({ method: 'createAccount', @@ -85,9 +86,9 @@ function AddAccountModal({ setRenderingKey(RenderingState.Password); setIsNewAccountImporting(false); - }, [firstNonHardwareAccount, addAccount]); + }); - const handleImportAccountClick = useCallback(() => { + const handleImportAccountClick = useLastCallback(() => { if (!firstNonHardwareAccount) { addAccount({ method: 'importMnemonic', @@ -98,19 +99,19 @@ function AddAccountModal({ setRenderingKey(RenderingState.Password); setIsNewAccountImporting(true); - }, [firstNonHardwareAccount, addAccount]); + }); - const handleImportHardwareWalletClick = useCallback(() => { + const handleImportHardwareWalletClick = useLastCallback(() => { setRenderingKey(RenderingState.ConnectHardware); - }, []); + }); - const handleHardwareWalletConnected = useCallback(() => { + const handleHardwareWalletConnected = useLastCallback(() => { setRenderingKey(RenderingState.SelectAccountsHardware); - }, []); + }); - const handleSubmit = useCallback((password: string) => { + const handleSubmit = useLastCallback((password: string) => { addAccount({ method: isNewAccountImporting ? 'importMnemonic' : 'createAccount', password }); - }, [addAccount, isNewAccountImporting]); + }); function renderSelector(isActive?: boolean) { return ( @@ -214,7 +215,6 @@ function AddAccountModal({ return ( { + const handleSafetyConfirm = useLastCallback(() => { setCurrentSlide(SLIDES.password); setNextKey(SLIDES.mnemonic); - }, []); + }); - const handlePasswordSubmit = useCallback(async (password: string) => { + const handlePasswordSubmit = useLastCallback(async (password: string) => { setIsLoading(true); mnemonicRef.current = await callApi('getMnemonic', currentAccountId!, password); setIsLoading(false); @@ -72,34 +73,34 @@ function BackupModal({ setNextKey(SLIDES.check); setCurrentSlide(SLIDES.mnemonic); - }, [currentAccountId]); + }); - const handleBackupErrorUpdate = useCallback(() => { + const handleBackupErrorUpdate = useLastCallback(() => { setError(undefined); - }, []); + }); - const handleCheckMnemonic = useCallback(() => { + const handleCheckMnemonic = useLastCallback(() => { setCheckIndexes(selectMnemonicForCheck()); setCurrentSlide(SLIDES.check); setNextKey(undefined); - }, []); + }); - const handleRestartCheckMnemonic = useCallback(() => { + const handleRestartCheckMnemonic = useLastCallback(() => { setCurrentSlide(SLIDES.mnemonic); setNextKey(SLIDES.check); - }, []); + }); - const handleModalClose = useCallback(() => { + const handleModalClose = useLastCallback(() => { setIsLoading(false); setError(undefined); setCurrentSlide(SLIDES.confirm); setNextKey(SLIDES.password); - }, []); + }); - const handleCheckMnemonicSubmit = useCallback(() => { + const handleCheckMnemonicSubmit = useLastCallback(() => { setIsBackupRequired({ isMnemonicChecked: true }); onClose(); - }, [setIsBackupRequired, onClose]); + }); // eslint-disable-next-line consistent-return function renderContent(isActive: boolean, isFrom: boolean, currentKey: number) { @@ -159,14 +160,13 @@ function BackupModal({ return ( { + const handleDeleteSavedAddress = useLastCallback(() => { if (!address) { return; } @@ -29,7 +30,7 @@ function DeleteSavedAddressModal({ isOpen, address, onClose }: OwnProps) { removeFromSavedAddress({ address }); showNotification({ message: lang('Address removed from saved') as string, icon: 'icon-trash' }); onClose(); - }, [address, lang, onClose, removeFromSavedAddress, showNotification]); + }); return ( { + const handleDeleteAllDapps = useLastCallback(() => { void deleteAllDapps(); onClose(); - }, [deleteAllDapps, onClose]); + }); - const handleDeleteDapp = useCallback(() => { + const handleDeleteDapp = useLastCallback(() => { void deleteDapp({ origin: dapp!.origin }); onClose(); - }, [deleteDapp, dapp, onClose]); + }); const title = dapp ? lang('Disconnect Dapp') : lang('Disconnect Dapps'); const description = dapp diff --git a/src/components/main/modals/LogOutModal.tsx b/src/components/main/modals/LogOutModal.tsx index 858e0fa4..707701b3 100644 --- a/src/components/main/modals/LogOutModal.tsx +++ b/src/components/main/modals/LogOutModal.tsx @@ -1,5 +1,5 @@ import React, { - memo, useCallback, useEffect, useMemo, useState, + memo, useEffect, useMemo, useState, } from '../../../lib/teact/teact'; import type { Account, AccountState } from '../../../global/types'; @@ -81,10 +81,10 @@ function LogOutModal({ switchAccount({ accountId }); }; - const handleLogOut = useCallback(() => { + const handleLogOut = useLastCallback(() => { onClose(!isLogOutFromAllAccounts && hasManyAccounts); signOut({ isFromAllAccounts: isLogOutFromAllAccounts }); - }, [isLogOutFromAllAccounts, hasManyAccounts, onClose]); + }); const handleClose = useLastCallback(() => { onClose(false); diff --git a/src/components/main/modals/SignatureModal.tsx b/src/components/main/modals/SignatureModal.tsx index 0015b99a..178b8c05 100644 --- a/src/components/main/modals/SignatureModal.tsx +++ b/src/components/main/modals/SignatureModal.tsx @@ -1,5 +1,5 @@ import React, { - memo, useCallback, useEffect, useLayoutEffect, useState, + memo, useEffect, useLayoutEffect, useState, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; @@ -10,6 +10,7 @@ import { ANIMATED_STICKERS_PATHS } from '../../ui/helpers/animatedAssets'; import useFlag from '../../../hooks/useFlag'; import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; import AnimatedIconWithPreview from '../../ui/AnimatedIconWithPreview'; import Button from '../../ui/Button'; @@ -69,14 +70,14 @@ function SignatureModal({ : undefined ), [closeModal, currentSlide]); - const handleConfirm = useCallback(() => { + const handleConfirm = useLastCallback(() => { setCurrentSlide(SLIDES.password); setNextKey(SLIDES.complete); - }, []); + }); - const handlePasswordSubmit = useCallback((password: string) => { + const handlePasswordSubmit = useLastCallback((password: string) => { submitSignature({ password }); - }, [submitSignature]); + }); function renderConfirm() { return ( @@ -159,7 +160,6 @@ function SignatureModal({ return ( ; savedAddresses?: Record; isTestnet?: boolean; startOfStakingCycle?: number; @@ -49,7 +49,7 @@ const EMPTY_HASH_VALUE = 'NOHASH'; function TransactionModal({ transaction, - token, + tokensBySlug, savedAddresses, isTestnet, startOfStakingCycle, @@ -63,7 +63,7 @@ function TransactionModal({ const lang = useLang(); const renderedTransaction = useCurrentOrPrev(transaction, true); - const [isModalOpen, setIsModalOpen] = useState(false); + const [isModalOpen, openModal, closeModal] = useFlag(false); const [unstakeDate, setUnstakeDate] = useState(Date.now() + STAKING_CYCLE_DURATION_MS); const { @@ -81,6 +81,7 @@ function TransactionModal({ const [, transactionHash] = (txId || '').split(':'); const isStaking = Boolean(transaction?.type); + const token = slug ? tokensBySlug?.[slug] : undefined; const amountHuman = amount ? bigStrToHuman(amount, token?.decimals) : 0; const address = isIncoming ? fromAddress : toAddress; const addressName = (address && savedAddresses?.[address]) || transaction?.metadata?.name; @@ -98,6 +99,7 @@ function TransactionModal({ const withUnstakeTimer = Boolean( transaction?.type === 'unstakeRequest' && startOfStakingCycle && transaction.timestamp >= startOfStakingCycle, ); + const { shouldRender: shouldRenderUnstakeTimer, transitionClassNames: unstakeTimerClassNames, @@ -105,7 +107,7 @@ function TransactionModal({ useSyncEffect(() => { if (transaction) { - setIsModalOpen(true); + openModal(); setDecryptedComment(undefined); } }, [transaction]); @@ -116,59 +118,19 @@ function TransactionModal({ } }, [endOfStakingCycle]); - const handleCloseModal = useCallback(() => { - setIsModalOpen(false); - }, []); - - const handleSendClick = useCallback(() => { - handleCloseModal(); + const handleSendClick = useLastCallback(() => { + closeModal(); startTransfer({ tokenSlug: slug || TON_TOKEN_SLUG, toAddress: address, amount: Math.abs(amountHuman), comment: !isIncoming ? comment : undefined, }); - }, [handleCloseModal, startTransfer, slug, address, amountHuman, isIncoming, comment]); + }); - const handleStartStakingClick = useCallback(() => { - handleCloseModal(); + const handleStartStakingClick = useLastCallback(() => { + closeModal(); startStaking(); - }, [handleCloseModal, startStaking]); - - function renderHeader() { - const isLocal = txId && getIsTxIdLocal(txId); - - return ( - <> - {timestamp ? `${formatFullDay(lang.code!, timestamp)}, ${formatTime(timestamp)}` : lang('Transaction Info')} - {isLocal && ( - - )} - {isScam && {lang('Scam')}} - - ); - } - - function renderFee() { - if (isIncoming || !fee) { - return undefined; - } - - return ( - - ); - } - - const spoilerCallback = useLastCallback(() => { - openPasswordModal(); }); const handlePasswordSubmit = useLastCallback(async (password: string) => { @@ -189,10 +151,55 @@ function TransactionModal({ setDecryptedComment(result); }); - const handlePasswordUpdate = useLastCallback(() => { + const clearPasswordError = useLastCallback(() => { setPasswordError(undefined); }); + function renderHeader() { + const isLocal = txId && getIsTxIdLocal(txId); + const plainTitle = isIncoming + ? lang('Received') + : isLocal + ? lang('Sending') + : lang('Sent'); + const title = plainTitle; + + return ( +
+
+ {title} + {isLocal && ( + + )} + {isScam && {lang('Scam')}} +
+ {!!timestamp && ( +
+ {formatFullDay(lang.code!, timestamp)}, {formatTime(timestamp)} +
+ )} +
+ ); + } + + function renderFee() { + if (isIncoming || !fee) { + return undefined; + } + + return ( + + ); + } + function renderComment() { if ((!comment && !encryptedComment) || transaction?.type) { return undefined; @@ -211,7 +218,7 @@ function TransactionModal({ text={encryptedComment ? decryptedComment : comment} spoiler={spoiler} spoilerRevealText={encryptedComment ? lang('Decrypt') : lang('Display')} - spoilerCallback={spoilerCallback} + spoilerCallback={openPasswordModal} copyNotification={lang('Comment was copied!')} className={styles.copyButtonWrapper} textClassName={styles.comment} @@ -229,9 +236,10 @@ function TransactionModal({ submitLabel={lang('Send')} placeholder={lang('Enter your password')} error={passwordError} + containerClassName={styles.passwordFormContent} onSubmit={handlePasswordSubmit} onCancel={closePasswordModal} - onUpdate={handlePasswordUpdate} + onUpdate={clearPasswordError} />
)} @@ -250,27 +258,9 @@ function TransactionModal({ ); } - return ( - -
- {tonscanTransactionUrl && ( - - - - )} + function renderPlainTransaction() { + return ( + <> )}
+ + ); + } + + function renderTransactionContent() { + return renderPlainTransaction(); + } + + return ( + +
+ {tonscanTransactionUrl && ( + + + + )} + {renderTransactionContent()}
); @@ -317,13 +336,12 @@ export default memo( const txId = accountState?.currentTransactionId; const transaction = txId ? accountState?.transactions?.byTxId[txId] : undefined; - const token = transaction?.slug ? global.tokenInfo?.bySlug[transaction.slug] : undefined; const { startOfCycle: startOfStakingCycle, endOfCycle: endOfStakingCycle } = accountState?.poolState || {}; const savedAddresses = accountState?.savedAddresses; return { transaction, - token, + tokensBySlug: global.tokenInfo?.bySlug, savedAddresses, isTestnet: global.settings.isTestnet, startOfStakingCycle, diff --git a/src/components/main/sections/Actions/LandscapeActions.module.scss b/src/components/main/sections/Actions/LandscapeActions.module.scss index 2dd4fcf8..a1752cf8 100644 --- a/src/components/main/sections/Actions/LandscapeActions.module.scss +++ b/src/components/main/sections/Actions/LandscapeActions.module.scss @@ -150,7 +150,7 @@ $tab-1-height: 348; .tabIcon { display: block; - margin-bottom: 1px; + margin-bottom: 0.0625rem; font-size: 1.875rem; color: var(--color-blue); @@ -172,7 +172,7 @@ $tab-1-height: 348; left: 0; transform: translateY(-50%); - width: 1px; + width: 0.0625rem; height: 2rem; /* stylelint-disable-next-line plugin/whole-pixel */ @@ -221,7 +221,7 @@ $tab-1-height: 348; .contentBg { transform-origin: top; // Using lesser base height (such as `1px`) causes incorrect scaling after zooming in Blink-based browsers - height: 100px; + height: 6.25rem; &.tab-0 { transform: scaleY(calc(#{$tab-0-height} / 100)); diff --git a/src/components/main/sections/Actions/LandscapeActions.tsx b/src/components/main/sections/Actions/LandscapeActions.tsx index 8571f2aa..f660c1b7 100644 --- a/src/components/main/sections/Actions/LandscapeActions.tsx +++ b/src/components/main/sections/Actions/LandscapeActions.tsx @@ -1,3 +1,4 @@ +import { requestMutation } from '../../../../lib/fasterdom/fasterdom'; import React, { memo, useEffect, useMemo, useRef, } from '../../../../lib/teact/teact'; @@ -9,7 +10,6 @@ import { DEFAULT_LANDSCAPE_ACTION_TAB_ID } from '../../../../config'; import { getActions, withGlobal } from '../../../../global'; import { selectLandscapeActionsActiveTabIndex } from '../../../../global/selectors'; import buildClassName from '../../../../util/buildClassName'; -import { fastRaf } from '../../../../util/schedulers'; import { ReceiveStatic } from '../../../receive'; import useLang from '../../../../hooks/useLang'; @@ -193,27 +193,31 @@ function useTabHeightAnimation(slideClassName: string, contentBackgroundClassNam if (lastHeightRef.current === rect.height || !contentBgRef.current) return; - const contentBgStyle = contentBgRef.current.style; - const contentFooterStyle = contentFooterRef.current!.style; + requestMutation(() => { + if (!contentBgRef.current || !contentFooterRef.current) return; - if (shouldRenderWithoutTransition) { - contentBgStyle.transition = 'none'; - contentFooterStyle.transition = 'none'; - } + const contentBgStyle = contentBgRef.current.style; + const contentFooterStyle = contentFooterRef.current!.style; - contentBgStyle.transform = `scaleY(calc(${rect.height} / 100))`; - contentFooterStyle.transform = `translateY(${Math.floor(rect.height)}px)`; + if (shouldRenderWithoutTransition) { + contentBgStyle.transition = 'none'; + contentFooterStyle.transition = 'none'; + } - if (shouldRenderWithoutTransition) { - fastRaf(() => { - if (!contentBgRef.current || !contentFooterRef.current) return; + contentBgStyle.transform = `scaleY(calc(${rect.height} / 100))`; + contentFooterStyle.transform = `translateY(${Math.floor(rect.height)}px)`; - contentBgRef.current.style.transition = ''; - contentFooterRef.current.style.transition = ''; - }); - } + if (shouldRenderWithoutTransition) { + requestMutation(() => { + if (!contentBgRef.current || !contentFooterRef.current) return; - lastHeightRef.current = rect.height; + contentBgRef.current.style.transition = ''; + contentFooterRef.current.style.transition = ''; + }); + } + + lastHeightRef.current = rect.height; + }); }); useEffect(() => { diff --git a/src/components/main/sections/Actions/PortraitActions.tsx b/src/components/main/sections/Actions/PortraitActions.tsx index c3518926..4e9e7f83 100644 --- a/src/components/main/sections/Actions/PortraitActions.tsx +++ b/src/components/main/sections/Actions/PortraitActions.tsx @@ -7,10 +7,8 @@ import { getActions } from '../../../../global'; import { bigStrToHuman } from '../../../../global/helpers'; import buildClassName from '../../../../util/buildClassName'; -import useFlag from '../../../../hooks/useFlag'; import useLang from '../../../../hooks/useLang'; -import ReceiveModal from '../../../receive/ReceiveModal'; import Button from '../../../ui/Button'; import styles from './PortraitActions.module.scss'; @@ -20,16 +18,16 @@ interface OwnProps { isTestnet?: boolean; isUnstakeRequested?: boolean; onEarnClick: NoneToVoidFunction; + onReceiveClick: NoneToVoidFunction; isLedger?: boolean; } function PortraitActions({ - hasStaking, isTestnet, isUnstakeRequested, onEarnClick, isLedger, + hasStaking, isTestnet, isUnstakeRequested, onEarnClick, onReceiveClick, isLedger, }: OwnProps) { const { startTransfer } = getActions(); const lang = useLang(); - const [isReceiveTonOpened, openReceiveTon, closeReceiveTon] = useFlag(false); useEffect(() => { return window.electron?.on(ElectronEvent.DEEPLINK, (params: any) => { @@ -51,7 +49,7 @@ function PortraitActions({ ) } > - @@ -70,7 +68,6 @@ function PortraitActions({ )}
-
); } diff --git a/src/components/main/sections/Card/AccountSelector.module.scss b/src/components/main/sections/Card/AccountSelector.module.scss index a317b0ed..df36924d 100644 --- a/src/components/main/sections/Card/AccountSelector.module.scss +++ b/src/components/main/sections/Card/AccountSelector.module.scss @@ -197,8 +197,9 @@ z-index: 1; top: 0; right: 0; - bottom: 0; left: 0; + + height: 100vh; } .dialog { diff --git a/src/components/main/sections/Card/AccountSelector.tsx b/src/components/main/sections/Card/AccountSelector.tsx index 37fe6eb0..bcc56724 100644 --- a/src/components/main/sections/Card/AccountSelector.tsx +++ b/src/components/main/sections/Card/AccountSelector.tsx @@ -1,5 +1,5 @@ import React, { - memo, useCallback, useEffect, useMemo, useRef, useState, + memo, useEffect, useMemo, useRef, useState, } from '../../../../lib/teact/teact'; import type { Account } from '../../../../global/types'; @@ -14,6 +14,7 @@ import trapFocus from '../../../../util/trapFocus'; import useFlag from '../../../../hooks/useFlag'; import useFocusAfterAnimation from '../../../../hooks/useFocusAfterAnimation'; import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; import useShowTransition from '../../../../hooks/useShowTransition'; import Button from '../../../ui/Button'; @@ -21,6 +22,12 @@ import AddAccountModal from '../../modals/AddAccountModal'; import styles from './AccountSelector.module.scss'; +interface OwnProps { + canEdit?: boolean; + accountClassName?: string; + menuButtonClassName?: string; +} + interface StateProps { currentAccountId: string; currentAccount?: Account; @@ -33,8 +40,11 @@ const ACCOUNTS_AMOUNT_FOR_COMPACT_DIALOG = 3; function AccountSelector({ currentAccountId, currentAccount, + canEdit, + accountClassName, + menuButtonClassName, accounts, -}: StateProps) { +}: OwnProps & StateProps) { const { switchAccount, renameAccount, openAddAccountModal, openSettings, } = getActions(); @@ -68,9 +78,9 @@ function AccountSelector({ } }, [currentAccount?.title, isEdit]); - const handleOpenAccountSelector = useCallback(() => { + const handleOpenAccountSelector = () => { openAccountSelector(); - }, [openAccountSelector]); + }; const handleSwitchAccount = (value: string) => { closeAccountSelector(); @@ -83,24 +93,24 @@ function AccountSelector({ openEdit(); }; - const handleSaveClick = useCallback(() => { + const handleSaveClick = useLastCallback(() => { renameAccount({ accountId: currentAccountId, title: inputValue.trim() }); closeAccountSelector(); closeEdit(); - }, [closeAccountSelector, closeEdit, currentAccountId, inputValue, renameAccount]); + }); - const handleAddWalletClick = useCallback(() => { + const handleAddWalletClick = useLastCallback(() => { closeAccountSelector(); openAddAccountModal(); - }, [closeAccountSelector, openAddAccountModal]); + }); - const handleInputKeyDown = useCallback((e: React.KeyboardEvent) => { + const handleInputKeyDown = useLastCallback((e: React.KeyboardEvent) => { if (e.code === 'Enter') { handleSaveClick(); } else { setInputValue(e.currentTarget.value); } - }, [handleSaveClick]); + }); function renderButton(accountId: string, address: string, isHardware?: boolean, title?: string) { const isActive = accountId === currentAccountId; @@ -119,7 +129,7 @@ function AccountSelector({
- {isActive && ( + {isActive && canEdit && (
@@ -136,11 +146,11 @@ function AccountSelector({ function renderCurrentAccount() { return ( <> -
+
{currentAccount?.title || shortenAddress(currentAccount?.address || '')}
+ + +
- )} -
- - - -
-
- {shouldRenderTokenCard && ( - - )} + {shouldRenderTokenCard && ( + + )} + + ); + } + + return ( +
+ {!values ? renderLoader() : renderContent()}
); } @@ -194,27 +203,3 @@ export default memo(withGlobal((global, ownProps, detachWhenChanged): isTestnet: global.settings.isTestnet, }; })(Card)); - -function buildValues(tokens: UserToken[]) { - const primaryValue = tokens.reduce((acc, token) => acc + token.amount * token.price, 0); - const [primaryWholePart, primaryFractionPart] = formatInteger(primaryValue).split('.'); - const changeValue = round(tokens.reduce((acc, token) => { - return acc + calcChangeValue(token.amount * token.price, token.change24h); - }, 0), 4); - - const changePercent = round(primaryValue ? (changeValue / (primaryValue - changeValue)) * 100 : 0, 2); - const changeClassName = changePercent > 0 - ? styles.changeCourseUp - : (changePercent < 0 ? styles.changeCourseDown : undefined); - const changePrefix = changeValue > 0 ? '↑' : changeValue < 0 ? '↓' : undefined; - - return { - primaryValue, - primaryWholePart, - primaryFractionPart, - changeClassName, - changePrefix, - changePercent, - changeValue, - }; -} diff --git a/src/components/main/sections/Card/StickyCard.module.scss b/src/components/main/sections/Card/StickyCard.module.scss new file mode 100644 index 00000000..c4a2a580 --- /dev/null +++ b/src/components/main/sections/Card/StickyCard.module.scss @@ -0,0 +1,91 @@ +.root { + position: fixed; + top: 0; + left: 0; + + width: 100%; + height: 0; +} + +.background { + pointer-events: none; + + position: relative; + + padding: 0 1rem; + + opacity: 0; + background-image: linear-gradient(125deg, #71AAEF 10.21%, #3F79CF 29.02%, #2E74B5 49.57%, #2160A1 65.77%); + background-size: cover; + + &::before { + pointer-events: none; + content: ''; + + position: absolute; + top: 0; + left: 0; + + width: 100%; + height: 100%; + + background: url("../../../../assets/cards/sticky_card_bg.png") repeat-x; + mix-blend-mode: multiply; + } + + &:global(.shown) { + transition: opacity 0.3s ease; + } + + &:global(.open) { + pointer-events: auto; + + opacity: 1; + } +} + +.content { + position: relative; + + display: grid; + grid-template: "account balance menu"; + grid-template-columns: minmax(25%, auto) 1fr 25%; + align-items: center; + + max-width: 25rem; + height: 3.75rem; + margin: 0 auto; +} + +.account { + position: relative; + top: initial; + left: initial; + + grid-area: account; + justify-self: start; + + max-width: 100%; +} + +.menuButton { + position: static; + + grid-area: menu; + justify-self: end; +} + +.balance { + grid-area: balance; + justify-self: center; + + font-size: 1.1875rem; + font-weight: 800; + font-style: normal; + color: var(--color-card-text); +} + +.balanceFractionPart { + font-size: 0.9375rem; + color: var(--color-card-second-text); +} diff --git a/src/components/main/sections/Card/StickyCard.tsx b/src/components/main/sections/Card/StickyCard.tsx new file mode 100644 index 00000000..013a97f8 --- /dev/null +++ b/src/components/main/sections/Card/StickyCard.tsx @@ -0,0 +1,55 @@ +import React, { memo, useMemo } from '../../../../lib/teact/teact'; + +import type { UserToken } from '../../../../global/types'; + +import { DEFAULT_PRICE_CURRENCY } from '../../../../config'; +import { withGlobal } from '../../../../global'; +import { selectCurrentAccountTokens } from '../../../../global/selectors'; +import buildClassName from '../../../../util/buildClassName'; +import { buildTokenValues } from './helpers/buildTokenValues'; + +import AccountSelector from './AccountSelector'; + +import styles from './StickyCard.module.scss'; + +interface OwnProps { + classNames?: string; +} + +interface StateProps { + tokens?: UserToken[]; +} + +function StickyCard({ classNames, tokens }: OwnProps & StateProps) { + const values = useMemo(() => { + return tokens ? buildTokenValues(tokens) : undefined; + }, [tokens]); + + const { primaryWholePart, primaryFractionPart } = values || {}; + + return ( +
+
+
+ +
+ {DEFAULT_PRICE_CURRENCY} + {primaryWholePart} + {primaryFractionPart && .{primaryFractionPart}} +
+
+
+
+ ); +} + +export default memo(withGlobal((global, ownProps, detachWhenChanged): StateProps => { + detachWhenChanged(global.currentAccountId); + + return { + tokens: selectCurrentAccountTokens(global), + }; +})(StickyCard)); diff --git a/src/components/main/sections/Card/helpers/buildTokenValues.ts b/src/components/main/sections/Card/helpers/buildTokenValues.ts new file mode 100644 index 00000000..a7196b72 --- /dev/null +++ b/src/components/main/sections/Card/helpers/buildTokenValues.ts @@ -0,0 +1,31 @@ +import type { UserToken } from '../../../../../global/types'; + +import { calcChangeValue } from '../../../../../util/calcChangeValue'; +import { formatInteger } from '../../../../../util/formatNumber'; +import { round } from '../../../../../util/round'; + +import styles from '../Card.module.scss'; + +export function buildTokenValues(tokens: UserToken[]) { + const primaryValue = tokens.reduce((acc, token) => acc + token.amount * token.price, 0); + const [primaryWholePart, primaryFractionPart] = formatInteger(primaryValue).split('.'); + const changeValue = round(tokens.reduce((acc, token) => { + return acc + calcChangeValue(token.amount * token.price, token.change24h); + }, 0), 4); + + const changePercent = round(primaryValue ? (changeValue / (primaryValue - changeValue)) * 100 : 0, 2); + const changeClassName = changePercent > 0 + ? styles.changeCourseUp + : (changePercent < 0 ? styles.changeCourseDown : undefined); + const changePrefix = changeValue > 0 ? '↑' : changeValue < 0 ? '↓' : undefined; + + return { + primaryValue, + primaryWholePart, + primaryFractionPart, + changeClassName, + changePrefix, + changePercent, + changeValue, + }; +} diff --git a/src/components/main/sections/Content/Activity.tsx b/src/components/main/sections/Content/Activity.tsx index 0f885d91..b081a9f3 100644 --- a/src/components/main/sections/Content/Activity.tsx +++ b/src/components/main/sections/Content/Activity.tsx @@ -4,7 +4,7 @@ import React, { import type { ApiToken, ApiTransaction } from '../../../../api/types'; -import { ANIMATED_STICKER_BIG_SIZE_PX, TON_TOKEN_SLUG } from '../../../../config'; +import { TON_TOKEN_SLUG } from '../../../../config'; import { getActions, withGlobal } from '../../../../global'; import { getIsTinyTransaction, getIsTxIdLocal } from '../../../../global/helpers'; import { selectCurrentAccountState, selectIsNewWallet } from '../../../../global/selectors'; @@ -12,14 +12,12 @@ import buildClassName from '../../../../util/buildClassName'; import { compareTransactions } from '../../../../util/compareTransactions'; import { formatHumanDay, getDayStartAt } from '../../../../util/dateFormat'; import { findLast } from '../../../../util/iteratees'; -import { ANIMATED_STICKERS_PATHS } from '../../../ui/helpers/animatedAssets'; import { useDeviceScreen } from '../../../../hooks/useDeviceScreen'; import useInfiniteLoader from '../../../../hooks/useInfiniteLoader'; import useLang from '../../../../hooks/useLang'; import useLastCallback from '../../../../hooks/useLastCallback'; -import AnimatedIconWithPreview from '../../../ui/AnimatedIconWithPreview'; import Loading from '../../../ui/Loading'; import NewWalletGreeting from './NewWalletGreeting'; import Transaction from './Transaction'; @@ -196,7 +194,7 @@ function Activity({ - - - ); - } - - if (!transactions?.length) { - return ( -
- -

{lang('No Activity')}

+
+
); } diff --git a/src/components/main/sections/Content/Assets.tsx b/src/components/main/sections/Content/Assets.tsx index 2b36af16..8147eac5 100644 --- a/src/components/main/sections/Content/Assets.tsx +++ b/src/components/main/sections/Content/Assets.tsx @@ -17,6 +17,7 @@ import styles from './Assets.module.scss'; type OwnProps = { isActive?: boolean; + noGreeting?: boolean; onTokenClick: (slug: string) => void; onStakedTokenClick: NoneToVoidFunction; }; @@ -37,13 +38,14 @@ function Assets({ stakingStatus, stakingBalance, isInvestorViewEnabled, + noGreeting, apyValue, onTokenClick, onStakedTokenClick, }: OwnProps & StateProps) { const { isPortrait } = useDeviceScreen(); - const shouldShowGreeting = isNewWallet && isPortrait; + const shouldShowGreeting = isNewWallet && isPortrait && !noGreeting; const tonToken = useMemo(() => tokens?.find(({ slug }) => slug === TON_TOKEN_SLUG), [tokens])!; const { shouldRender: shouldRenderStakedToken, transitionClassNames: stakedTokenClassNames } = useShowTransition( Boolean(stakingStatus), diff --git a/src/components/main/sections/Content/Content.module.scss b/src/components/main/sections/Content/Content.module.scss index 23becbcf..af467ed4 100644 --- a/src/components/main/sections/Content/Content.module.scss +++ b/src/components/main/sections/Content/Content.module.scss @@ -1,12 +1,11 @@ +@import "../../../../styles/mixins"; + +.contentPanel, .container { display: flex; flex: 1 1 auto; flex-direction: column; - &.portraitContainer { - flex: 1 1 auto; - } - &.landscapeContainer { overflow: hidden; } @@ -20,6 +19,12 @@ background: var(--color-background-first); border-radius: var(--border-radius-default); + + .portraitContainer & { + width: 100%; + max-width: 25rem; + margin: 0 auto 1rem; + } } .tabs { @@ -28,14 +33,39 @@ padding: 0 1.75rem; - border-radius: var(--border-radius-default) var(--border-radius-default) 0 0; - /* stylelint-disable-next-line plugin/whole-pixel */ - box-shadow: 0 0.025rem 0 0 var(--color-separator); - .landscapeContainer & { justify-content: flex-start; padding: 0 0.75rem; + + border-radius: var(--border-radius-default) var(--border-radius-default) 0 0; + } + + .portraitContainer & { + position: sticky; + top: 3.75rem; + + overflow: visible; + + width: 100%; + max-width: 25rem; + margin: 0 auto; + + &::after { + content: ''; + + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + + width: 100vw; + height: 0.0625rem; + + /* stylelint-disable-next-line plugin/whole-pixel */ + box-shadow: 0 0.025rem 0 0 var(--color-separator); + } + } } @@ -74,6 +104,9 @@ .slides { flex: 1 1 auto; + max-width: 25rem; + margin: 0 auto; + :global(html:not(.with-safe-area-bottom)) & { border-radius: 0; } @@ -86,3 +119,13 @@ min-height: 0; } } + +.contentPanel { + position: relative; + + flex-grow: 1; + + margin: 0 -0.75rem; + + background: var(--color-background-first); +} diff --git a/src/components/main/sections/Content/Content.tsx b/src/components/main/sections/Content/Content.tsx index 02c42f16..de445891 100644 --- a/src/components/main/sections/Content/Content.tsx +++ b/src/components/main/sections/Content/Content.tsx @@ -1,4 +1,8 @@ -import React, { memo, useCallback, useMemo } from '../../../../lib/teact/teact'; +import React, { + memo, useEffect, useMemo, useRef, +} from '../../../../lib/teact/teact'; + +import type { UserToken } from '../../../../global/types'; import { getActions, withGlobal } from '../../../../global'; import { selectCurrentAccountTokens, selectIsHardwareAccount } from '../../../../global/selectors'; @@ -6,6 +10,7 @@ import buildClassName from '../../../../util/buildClassName'; import { useDeviceScreen } from '../../../../hooks/useDeviceScreen'; import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; import TabList from '../../../ui/TabList'; import Transition from '../../../ui/Transition'; @@ -22,92 +27,132 @@ interface OwnProps { } interface StateProps { - tokenCount: number; + tokens?: UserToken[]; isNftSupported: boolean; } -const MIN_ASSETS_FOR_DESKTOP_TAB_VIEW = 5; +enum TabId { + // eslint-disable-next-line @typescript-eslint/no-shadow + Assets, + // eslint-disable-next-line @typescript-eslint/no-shadow + Activity, + Nft, +} + +const MIN_ASSETS_TAB_VIEW = 5; +const DEFAULT_TABS_COUNT = 3; +const STICKY_CARD_HEIGHT = 60; function Content({ - activeTabIndex, tokenCount, setActiveTabIndex, onStakedTokenClick, isNftSupported, + activeTabIndex, tokens, setActiveTabIndex, onStakedTokenClick, isNftSupported, }: OwnProps & StateProps) { const { selectToken } = getActions(); const { isLandscape } = useDeviceScreen(); const lang = useLang(); + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); - const shouldShowSeparateAssetsPanel = isLandscape && tokenCount < MIN_ASSETS_FOR_DESKTOP_TAB_VIEW; + const tokenCount = useMemo(() => (tokens ?? []).filter(({ isDisabled }) => !isDisabled).length, [tokens]); + const shouldShowSeparateAssetsPanel = tokenCount > 0 && tokenCount < MIN_ASSETS_TAB_VIEW; const TABS = useMemo( () => [ ...(!shouldShowSeparateAssetsPanel - ? [{ id: 'assets', title: lang('Assets') as string, className: styles.tab }] + ? [{ id: TabId.Assets, title: lang('Assets') as string, className: styles.tab }] : []), - { id: 'activity', title: lang('Activity') as string, className: styles.tab }, - ...(isNftSupported ? [{ id: 'nft', title: lang('NFT') as string, className: styles.tab }] : []), + { id: TabId.Activity, title: lang('Activity') as string, className: styles.tab }, + ...(isNftSupported ? [{ id: TabId.Nft, title: lang('NFT') as string, className: styles.tab }] : []), ], [lang, shouldShowSeparateAssetsPanel, isNftSupported], ); - activeTabIndex = Math.min(activeTabIndex, TABS.length - 1); - - const handleSwitchTab = useCallback( - (index: number) => { - selectToken({ slug: undefined }); - setActiveTabIndex(index); - }, - [selectToken, setActiveTabIndex], - ); - const handleClickAssets = useCallback( - (slug: string) => { - selectToken({ slug }); - setActiveTabIndex(TABS.findIndex((tab) => tab.id === 'activity')); - }, - [TABS, selectToken, setActiveTabIndex], - ); + // Calculate the difference between the default number of tabs and the current number of tabs. + // This shift is used to adjust the tab index in landscape mode. + const indexShift = DEFAULT_TABS_COUNT - TABS.length; + const realActiveIndex = activeTabIndex === 0 ? activeTabIndex : activeTabIndex - indexShift; + + useEffect(() => { + if (isLandscape || realActiveIndex !== TabId.Activity) { + return; + } + + const contentTop = containerRef.current?.getBoundingClientRect().top ?? 0; + const containerEl = containerRef.current?.closest('.app-slide-content'); + + if (contentTop > STICKY_CARD_HEIGHT || !containerEl) { + return; + } + + containerEl.scrollTop = containerEl.scrollTop + contentTop - STICKY_CARD_HEIGHT; + }, [isLandscape, realActiveIndex]); + + const handleSwitchTab = useLastCallback((index: number) => { + selectToken({ slug: undefined }); + setActiveTabIndex(index + indexShift); + }); + + const handleClickAssets = useLastCallback((slug: string) => { + selectToken({ slug }); + setActiveTabIndex(TABS.findIndex((tab) => tab.id === TabId.Activity)); + }); function renderCurrentTab(isActive: boolean) { // When assets are shown separately, there is effectively no tab with index 0, // so we fall back to next tab to not break parent's component logic. - if (activeTabIndex === 0 && shouldShowSeparateAssetsPanel) { + if (realActiveIndex === 0 && shouldShowSeparateAssetsPanel) { return ; } - const currentTabId = activeTabIndex > TABS.length - 1 ? TABS[TABS.length - 1].id : TABS[activeTabIndex].id; - - switch (currentTabId) { - case 'assets': + switch (TABS[realActiveIndex].id) { + case TabId.Assets: return ; - - case 'activity': + case TabId.Activity: return ; - - case 'nft': + case TabId.Nft: return ; - default: return undefined; } } + function renderContent() { + return ( + <> + + + {renderCurrentTab} + + + ); + } + return (
{shouldShowSeparateAssetsPanel && (
- +
)} - - - {renderCurrentTab} - + {isLandscape ? renderContent() : (
{renderContent()}
)}
); } @@ -120,7 +165,7 @@ export default memo( const isLedger = selectIsHardwareAccount(global); return { - tokenCount: tokens?.length ?? 0, + tokens, isNftSupported: !isLedger, }; })(Content), diff --git a/src/components/main/sections/Content/NewWalletGreeting.module.scss b/src/components/main/sections/Content/NewWalletGreeting.module.scss index 137c200b..9caa64f4 100644 --- a/src/components/main/sections/Content/NewWalletGreeting.module.scss +++ b/src/components/main/sections/Content/NewWalletGreeting.module.scss @@ -9,19 +9,6 @@ &.panel { flex-direction: row; gap: 1rem; - - &::after { - content: ''; - - position: absolute; - right: 2rem; - bottom: 0; - left: 3.875rem; - - height: 0.0625rem; - /* stylelint-disable-next-line plugin/whole-pixel */ - box-shadow: inset 0 -0.025rem 0 0 var(--color-separator); - } } &.emptyList { diff --git a/src/components/main/sections/Content/NewWalletGreeting.tsx b/src/components/main/sections/Content/NewWalletGreeting.tsx index 30d19276..82754250 100644 --- a/src/components/main/sections/Content/NewWalletGreeting.tsx +++ b/src/components/main/sections/Content/NewWalletGreeting.tsx @@ -26,7 +26,7 @@ function NewWalletGreeting({ isActive, mode }: Props) { previewUrl={ANIMATED_STICKERS_PATHS.helloPreview} nonInteractive noLoop={false} - size={mode === 'panel' ? ANIMATED_STICKER_SMALL_SIZE_PX : ANIMATED_STICKER_BIG_SIZE_PX} + size={mode === 'emptyList' ? ANIMATED_STICKER_BIG_SIZE_PX : ANIMATED_STICKER_SMALL_SIZE_PX} />
diff --git a/src/components/main/sections/Content/Token.tsx b/src/components/main/sections/Content/Token.tsx index b1784c30..6645bf2d 100644 --- a/src/components/main/sections/Content/Token.tsx +++ b/src/components/main/sections/Content/Token.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback } from '../../../../lib/teact/teact'; +import React, { memo } from '../../../../lib/teact/teact'; import type { UserToken } from '../../../../global/types'; @@ -9,6 +9,7 @@ import { formatCurrency } from '../../../../util/formatNumber'; import { round } from '../../../../util/round'; import { ASSET_LOGO_PATHS } from '../../../ui/helpers/assetLogos'; +import useLastCallback from '../../../../hooks/useLastCallback'; import useShowTransition from '../../../../hooks/useShowTransition'; import AnimatedCounter from '../../../ui/AnimatedCounter'; @@ -58,9 +59,9 @@ function Token({ transitionClassNames: renderApyClassNames, } = useShowTransition(withApy); - const handleClick = useCallback(() => { + const handleClick = useLastCallback(() => { onClick(slug); - }, [onClick, slug]); + }); function renderApy() { return ( diff --git a/src/components/main/sections/Content/Transaction.module.scss b/src/components/main/sections/Content/Transaction.module.scss index 4ed57d21..5a9914c1 100644 --- a/src/components/main/sections/Content/Transaction.module.scss +++ b/src/components/main/sections/Content/Transaction.module.scss @@ -29,7 +29,7 @@ bottom: 0; left: 3.875rem; - height: 1px; + height: 0.0625rem; /* stylelint-disable-next-line plugin/whole-pixel */ box-shadow: inset 0 -0.025rem 0 0 var(--color-separator); } @@ -89,6 +89,12 @@ background-color: var(--color-activity-purple-background); } + + &_orange { + color: var(--color-activity-orange-text); + + background-color: var(--color-activity-orange-background); + } } .iconWaiting { @@ -99,6 +105,13 @@ font-size: 1rem; line-height: 1; color: var(--color-gray-1); + + background-color: var(--color-background-first); + border-radius: 50%; +} + +.iconWaitingSwap { + color: var(--color-activity-orange-text); } .leftBlock { @@ -122,12 +135,14 @@ } .address, -.date { +.date, +.swapPrice { font-size: 0.75rem; color: var(--color-gray-2); } -.addressValue { +.addressValue, +.swapPriceValue { font-weight: 600; } diff --git a/src/components/main/sections/Content/Transaction.tsx b/src/components/main/sections/Content/Transaction.tsx index ffb2e6fd..3c1057c9 100644 --- a/src/components/main/sections/Content/Transaction.tsx +++ b/src/components/main/sections/Content/Transaction.tsx @@ -1,5 +1,5 @@ import type { Ref, RefObject } from 'react'; -import React, { memo, useCallback } from '../../../../lib/teact/teact'; +import React, { memo } from '../../../../lib/teact/teact'; import type { ApiToken, ApiTransaction } from '../../../../api/types'; @@ -11,6 +11,7 @@ import { formatCurrencyExtended } from '../../../../util/formatNumber'; import { shortenAddress } from '../../../../util/shortenAddress'; import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; import Button from '../../../ui/Button'; @@ -20,7 +21,7 @@ import scamImg from '../../../../assets/scam.svg'; type OwnProps = { ref?: Ref; - token?: ApiToken; + tokensBySlug?: Record; transaction: ApiTransaction; apyValue: number; savedAddresses?: Record; @@ -29,7 +30,7 @@ type OwnProps = { function Transaction({ ref, - token, + tokensBySlug, transaction, apyValue, savedAddresses, @@ -48,29 +49,35 @@ function Transaction({ isIncoming, type, metadata, + slug, } = transaction; - const isStaking = type === 'stake' || type === 'unstake' || type === 'unstakeRequest'; + const isStake = type === 'stake'; + const isUnstake = type === 'unstake'; + const isUnstakeRequest = type === 'unstakeRequest'; + const isStaking = isStake || isUnstake || isUnstakeRequest; + + const token = tokensBySlug?.[slug]; const amountHuman = bigStrToHuman(amount, token!.decimals); const address = isIncoming ? fromAddress : toAddress; const addressName = savedAddresses?.[address] || metadata?.name; const isLocal = getIsTxIdLocal(txId); const isScam = Boolean(metadata?.isScam); - const handleClick = useCallback(() => { + const handleClick = useLastCallback(() => { onClick(txId); - }, [onClick, txId]); + }); function getOperationName() { - if (type === 'stake') { + if (isStake) { return 'Staked'; } - if (type === 'unstakeRequest') { + if (isUnstakeRequest) { return 'Unstake Requested'; } - if (type === 'unstake') { + if (isUnstake) { return 'Unstaked'; } @@ -78,6 +85,10 @@ function Transaction({ } function renderComment() { + if (isStaking || isScam || (!comment && !encryptedComment)) { + return undefined; + } + return (
+
+ {formatCurrencyExtended( + isStaking ? Math.abs(amountHuman) : amountHuman, + token?.symbol || CARD_SECONDARY_VALUE_SYMBOL, + isStaking, + )} +
+
+ {!isStaking && lang(isIncoming ? '$transaction_from' : '$transaction_to', { + address: {addressName || shortenAddress(address)}, + })} + {isStake && lang('at APY %1$s%', apyValue)} + {(isUnstake || isUnstakeRequest) && '\u00A0'} +
+
+ ); + } + return (
{formatTime(timestamp)}
-
-
- {formatCurrencyExtended( - isStaking ? Math.abs(amountHuman) : amountHuman, - token?.symbol || CARD_SECONDARY_VALUE_SYMBOL, - isStaking, - )} -
-
- {!isStaking && lang(isIncoming ? '$transaction_from' : '$transaction_to', { - address: {addressName || shortenAddress(address)}, - })} - {type === 'stake' && lang('at APY %1$s%', apyValue)} - {(type === 'unstake' || type === 'unstakeRequest') && '\u00A0'} -
-
- {!isStaking && !isScam && (comment || encryptedComment) && renderComment()} + {renderAmount()} + {renderComment()} ); diff --git a/src/components/main/sections/Header/MenuPortal.tsx b/src/components/main/sections/Header/MenuPortal.tsx deleted file mode 100644 index 7ddd70dd..00000000 --- a/src/components/main/sections/Header/MenuPortal.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { VirtualElement } from '../../../../lib/teact/teact'; -import React, { useEffect, useState } from '../../../../lib/teact/teact'; - -import { useDeviceScreen } from '../../../../hooks/useDeviceScreen'; - -import Portal from '../../../ui/Portal'; - -interface Props { - className?: string; - containerRef: { current: HTMLElement | null }; - children: VirtualElement; -} - -function MenuPortal({ className, containerRef, children }: Props) { - const [style, setStyle] = useState(); - const { isPortrait } = useDeviceScreen(); - - useEffect(() => { - function updateMenuPosition() { - if (!containerRef.current) { - return; - } - - if (isPortrait) { - setStyle(undefined); - return; - } - - const rect = containerRef.current.getBoundingClientRect(); - - setStyle(`top: ${rect.top + rect.height}px; left: ${rect.left + rect.width}px;`); - } - - updateMenuPosition(); - window.addEventListener('resize', updateMenuPosition); - window.addEventListener('scroll', updateMenuPosition); - - return () => { - window.removeEventListener('resize', updateMenuPosition); - window.removeEventListener('scroll', updateMenuPosition); - }; - }, [isPortrait, containerRef]); - - if (isPortrait) { - return children; - } - - return ( - - {children} - - ); -} - -export default MenuPortal; diff --git a/src/components/main/sections/Warnings/SecurityWarning.tsx b/src/components/main/sections/Warnings/SecurityWarning.tsx index 6c3cc5a8..e2cde9a8 100644 --- a/src/components/main/sections/Warnings/SecurityWarning.tsx +++ b/src/components/main/sections/Warnings/SecurityWarning.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback } from '../../../../lib/teact/teact'; +import React, { memo } from '../../../../lib/teact/teact'; import { MY_TON_WALLET_PROMO_URL } from '../../../../config'; import { getActions, withGlobal } from '../../../../global'; @@ -6,6 +6,7 @@ import buildClassName from '../../../../util/buildClassName'; import { useDeviceScreen } from '../../../../hooks/useDeviceScreen'; import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; import useShowTransition from '../../../../hooks/useShowTransition'; import styles from './Warnings.module.scss'; @@ -26,14 +27,11 @@ function SecurityWarning({ isSecurityWarningHidden }: StateProps) { window.open(MY_TON_WALLET_PROMO_URL, '_blank', 'noopener'); } - const handleClose = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); + const handleClose = useLastCallback((e: React.MouseEvent) => { + e.stopPropagation(); - closeSecurityWarning(); - }, - [closeSecurityWarning], - ); + closeSecurityWarning(); + }); if (!shouldRender) { return undefined; diff --git a/src/components/main/sections/Warnings/Warnings.tsx b/src/components/main/sections/Warnings/Warnings.tsx index 982d8208..3983c02f 100644 --- a/src/components/main/sections/Warnings/Warnings.tsx +++ b/src/components/main/sections/Warnings/Warnings.tsx @@ -1,9 +1,8 @@ import React, { memo } from '../../../../lib/teact/teact'; -import { IS_ELECTRON } from '../../../../config'; +import { IS_ELECTRON, IS_EXTENSION } from '../../../../config'; import { withGlobal } from '../../../../global'; import { selectCurrentAccountState } from '../../../../global/selectors'; -import { IS_EXTENSION } from '../../../../util/windowEnvironment'; import { useDeviceScreen } from '../../../../hooks/useDeviceScreen'; import useLang from '../../../../hooks/useLang'; diff --git a/src/components/receive/InvoiceModal.tsx b/src/components/receive/InvoiceModal.tsx index 449fd8f4..9642ca2b 100644 --- a/src/components/receive/InvoiceModal.tsx +++ b/src/components/receive/InvoiceModal.tsx @@ -1,6 +1,5 @@ -import TonWeb from 'tonweb'; import React, { - memo, useCallback, useMemo, useState, + memo, useMemo, useState, } from '../../lib/teact/teact'; import type { UserToken } from '../../global/types'; @@ -11,9 +10,11 @@ import { humanToBigStr } from '../../global/helpers'; import renderText from '../../global/helpers/renderText'; import { selectAccount, selectCurrentAccountTokens } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; +import formatTransferUrl from '../../util/ton/formatTransferUrl'; import { ASSET_LOGO_PATHS } from '../ui/helpers/assetLogos'; import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; import Button from '../ui/Button'; import type { DropdownItem } from '../ui/Dropdown'; @@ -21,7 +22,6 @@ import Dropdown from '../ui/Dropdown'; import Input from '../ui/Input'; import InteractiveTextField from '../ui/InteractiveTextField'; import Modal from '../ui/Modal'; -import ModalTransitionContent from '../ui/ModalTransitionContent'; import RichNumberInput from '../ui/RichNumberInput'; import styles from './ReceiveModal.module.scss'; @@ -53,7 +53,7 @@ function InvoiceModal({ const [hasAmountError, setHasAmountError] = useState(false); const invoiceAmount = amount ? humanToBigStr(amount) : undefined; - const invoiceUrl = address ? TonWeb.utils.formatTransferUrl(address, invoiceAmount, comment) : ''; + const invoiceUrl = address ? formatTransferUrl(address, invoiceAmount, comment) : ''; const dropdownItems = useMemo(() => { if (!tokens) { @@ -73,7 +73,7 @@ function InvoiceModal({ }, []); }, [tokens]); - const handleAmountInput = useCallback((value?: number) => { + const handleAmountInput = useLastCallback((value?: number) => { setHasAmountError(false); if (value === undefined) { @@ -87,45 +87,49 @@ function InvoiceModal({ } setAmount(value); - }, []); + }); function renderTokens() { return ; } return ( - - -
- {renderText(lang('$receive_invoice_description'))} -
- - {renderTokens()} - - - -

- {lang('Share this URL to receive TON')} -

- - -
- -
-
+ +
+ {renderText(lang('$receive_invoice_description'))} +
+ + {renderTokens()} + + + +

+ {lang('Share this URL to receive TON')} +

+ + +
+ +
); } diff --git a/src/components/receive/QrModal.tsx b/src/components/receive/QrModal.tsx index 6a2ccd7e..bb1ea485 100644 --- a/src/components/receive/QrModal.tsx +++ b/src/components/receive/QrModal.tsx @@ -1,4 +1,3 @@ -import TonWeb from 'tonweb'; import QrCodeStyling from 'qr-code-styling'; import React, { memo, useEffect, useRef } from '../../lib/teact/teact'; @@ -6,12 +5,12 @@ import { withGlobal } from '../../global'; import { selectAccount } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import { shortenAddress } from '../../util/shortenAddress'; +import formatTransferUrl from '../../util/ton/formatTransferUrl'; import useLang from '../../hooks/useLang'; import Button from '../ui/Button'; import Modal from '../ui/Modal'; -import ModalTransitionContent from '../ui/ModalTransitionContent'; import styles from './ReceiveModal.module.scss'; @@ -66,19 +65,19 @@ function QrModal({ if (!address) { return; } - QR_CODE.update({ data: TonWeb.utils.formatTransferUrl(address) }); + QR_CODE.update({ data: formatTransferUrl(address) }); }, [address]); return ( - - + +

{address && shortenAddress(address)}

- +
); } diff --git a/src/components/receive/ReceiveModal.module.scss b/src/components/receive/ReceiveModal.module.scss index 0a921ef5..89fdf824 100644 --- a/src/components/receive/ReceiveModal.module.scss +++ b/src/components/receive/ReceiveModal.module.scss @@ -6,16 +6,8 @@ } } -.contentInvoice { - padding: 0.5rem 1rem 1rem; - - @supports (padding-bottom: env(safe-area-inset-bottom)) { - padding-bottom: max(env(safe-area-inset-bottom), 1rem); - } -} - .contentQr { - margin-top: -0.1875rem; + margin-top: -0.25rem; } .info { @@ -48,7 +40,7 @@ width: 100%; max-width: 20.5rem; max-height: 20.5rem; - margin: 0.5rem auto 0; + margin: 0 auto; background-color: var(--color-white); border-radius: var(--border-radius-default); diff --git a/src/components/receive/ReceiveModal.tsx b/src/components/receive/ReceiveModal.tsx index 4050ff41..aa2cfabb 100644 --- a/src/components/receive/ReceiveModal.tsx +++ b/src/components/receive/ReceiveModal.tsx @@ -1,14 +1,16 @@ -import React, { memo, useCallback } from '../../lib/teact/teact'; +import React, { memo } from '../../lib/teact/teact'; import useFlag from '../../hooks/useFlag'; import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; import Modal from '../ui/Modal'; -import ModalTransitionContent from '../ui/ModalTransitionContent'; import Content from './Content'; import InvoiceModal from './InvoiceModal'; import QrModal from './QrModal'; +import styles from './ReceiveModal.module.scss'; + type Props = { isOpen: boolean; onClose: () => void; @@ -19,18 +21,18 @@ function ReceiveModal({ isOpen, onClose }: Props) { const [isQrModalOpen, openQrModal, closeQrModal] = useFlag(false); const [isInvoiceModalOpen, openInvoiceModal, closeInvoiceModal] = useFlag(false); - const handleClose = useCallback(() => { + const handleClose = useLastCallback(() => { onClose(); closeInvoiceModal(); closeQrModal(); - }, [closeInvoiceModal, closeQrModal, onClose]); + }); return ( <> - - + +
- +
diff --git a/src/components/receive/ReceiveStatic.tsx b/src/components/receive/ReceiveStatic.tsx index 1d34b76c..370cfa9d 100644 --- a/src/components/receive/ReceiveStatic.tsx +++ b/src/components/receive/ReceiveStatic.tsx @@ -1,7 +1,8 @@ -import React, { memo, useCallback } from '../../lib/teact/teact'; +import React, { memo } from '../../lib/teact/teact'; import useFlag from '../../hooks/useFlag'; import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; import Content from './Content'; import InvoiceModal from './InvoiceModal'; @@ -16,10 +17,10 @@ function ReceiveStatic({ className }: Props) { const [isQrModalOpen, openQrModal, closeQrModal] = useFlag(false); const [isInvoiceModalOpen, openInvoiceModal, closeInvoiceModal] = useFlag(false); - const handleClose = useCallback(() => { + const handleClose = useLastCallback(() => { closeInvoiceModal(); closeQrModal(); - }, [closeInvoiceModal, closeQrModal]); + }); return (
diff --git a/src/components/settings/SelectTokens.tsx b/src/components/settings/SelectTokens.tsx index 60be3264..39a2dfcd 100644 --- a/src/components/settings/SelectTokens.tsx +++ b/src/components/settings/SelectTokens.tsx @@ -105,7 +105,7 @@ function SelectTokens({ } = position; const { width: componentWidth, height: componentHeight } = elementRef.current.getBoundingClientRect(); - const isTop = window.innerHeight > top + componentWidth; + const isTop = window.innerHeight > top + componentHeight; const verticalStyle = isTop ? `top: ${top + offset.top}px;` : `top: calc(100% - ${componentHeight + offset.bottom}px);`; diff --git a/src/components/settings/Settings.module.scss b/src/components/settings/Settings.module.scss index ba772dcb..2414c230 100644 --- a/src/components/settings/Settings.module.scss +++ b/src/components/settings/Settings.module.scss @@ -8,15 +8,15 @@ display: flex; flex-direction: column; + width: 100%; + max-width: 25rem; height: 100%; min-height: 0; max-height: 100%; + margin: 0 auto; } .transitionContainer { - max-width: 25rem; - margin: 0 auto; - background-color: var(--color-background-second); } @@ -44,6 +44,14 @@ font-size: 1.0625rem; font-weight: 700; line-height: 1.0625rem; + + /* stylelint-disable-next-line order/order, at-rule-empty-line-before */ + @include respond-below(xs) { + // Fix for opera, dead zone of 37 pixels in extension window on windows + :global(html.is-windows.is-opera.is-extension) & { + padding-top: 2.3125rem; + } + } } .languageHeader { @@ -56,6 +64,8 @@ display: flex; align-items: center; + padding: 0.0625rem 0.375rem; + font-size: 1.0625rem; color: var(--color-blue); } @@ -68,9 +78,16 @@ display: flex; justify-content: center; + padding: 0 0.5rem; + color: var(--color-black) } +.modalHeader { + min-height: 4rem; + margin-bottom: 0.75rem; +} + .content { overflow-x: hidden; overflow-y: scroll; @@ -533,6 +550,10 @@ height: 2.25rem; border-radius: 100%; + + @media (pointer: coarse) { + opacity: 0; + } } .tokenSortIcon { @@ -557,7 +578,8 @@ color: var(--color-gray-2); } -.dot { +.dot, +.dotLarge { width: 0.125rem; height: 0.125rem; margin: 0 0.25rem; @@ -569,16 +591,24 @@ border-radius: 50%; } +.dotLarge { + width: 0.1875rem; + height: 0.1875rem; +} + .contentRelative, .sortableContainer { position: relative; } .modalDialog { - height: 33rem; + // Hidden is needed for animating screen transition on the desktop + overflow: hidden; + + height: 46rem; @supports (height: env(safe-area-inset-bottom)) { - height: calc(33rem + env(safe-area-inset-bottom)); + height: calc(46rem + env(safe-area-inset-bottom)); } } @@ -610,6 +640,7 @@ transition: var(--dropdown-transition) !important; + :global(html.animation-level-0) &, &:global(.open) { transform: translateY(0) !important; } @@ -618,12 +649,12 @@ transition: var(--dropdown-transition-backwards) !important; :global(html.animation-level-0) & { - transition: none !important; + transition: var(--no-animation-transition) !important; } } :global(html.animation-level-0) & { - transition: none !important; + transition: var(--no-animation-transition) !important; } } @@ -861,3 +892,39 @@ bottom: -100vh; left: -100vw; } + +.stickerAndTitle { + display: flex; + column-gap: 1rem; + align-items: center; + + margin-bottom: 1.5rem; +} + +.sideTitle { + font-size: 1.6875rem; + font-weight: 800; + line-height: 1; + color: var(--color-black); +} + +.aboutFooterWrapper { + display: flex; + flex-grow: 1; + align-items: flex-end; + justify-content: center; + + margin-top: 1rem; +} + +.aboutFooterContent { + display: flex; + gap: 0.25rem; + align-items: center; + justify-content: center; + + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-blue); + text-align: center; +} diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index b929a378..ae32265a 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -10,6 +10,9 @@ import type { import { APP_NAME, APP_VERSION, + IS_DAPP_SUPPORTED, + IS_ELECTRON, + IS_EXTENSION, LANG_LIST, PROXY_HOSTS, TELEGRAM_WEB_URL, @@ -20,7 +23,7 @@ import { } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import captureEscKeyListener from '../../util/captureEscKeyListener'; -import { IS_DAPP_SUPPORTED, IS_EXTENSION, IS_LEDGER_SUPPORTED } from '../../util/windowEnvironment'; +import { IS_LEDGER_SUPPORTED } from '../../util/windowEnvironment'; import useFlag from '../../hooks/useFlag'; import useLang from '../../hooks/useLang'; @@ -38,6 +41,7 @@ import SettingsAppearance from './SettingsAppearance'; import SettingsAssets from './SettingsAssets'; import SettingsDapps from './SettingsDapps'; import SettingsDeveloperOptions from './SettingsDeveloperOptions'; +import SettingsDisclaimer from './SettingsDisclaimer'; import SettingsLanguage from './SettingsLanguage'; import modalStyles from '../ui/Modal.module.scss'; @@ -48,6 +52,7 @@ import appearanceImg from '../../assets/settings/settings_appearance.svg'; import assetsActivityImg from '../../assets/settings/settings_assets-activity.svg'; import backupSecretImg from '../../assets/settings/settings_backup-secret.svg'; import connectedDappsImg from '../../assets/settings/settings_connected-dapps.svg'; +import disclaimerImg from '../../assets/settings/settings_disclaimer.svg'; import exitImg from '../../assets/settings/settings_exit.svg'; import languageImg from '../../assets/settings/settings_language.svg'; import ledgerImg from '../../assets/settings/settings_ledger.svg'; @@ -63,6 +68,7 @@ const enum RenderingState { Dapps, Language, About, + Disclaimer, } type OwnProps = { @@ -166,6 +172,10 @@ function Settings({ setRenderingKey(RenderingState.About); } + function handleDisclaimerOpen() { + setRenderingKey(RenderingState.Disclaimer); + } + const handleBackClick = useLastCallback(() => { setRenderingKey(RenderingState.Initial); }); @@ -216,6 +226,21 @@ function Settings({ [handleBackClick, renderingKey], ); + function renderHandleDeeplinkButton() { + return ( +
+ {lang('Handle + {lang('Handle ton:// links')} + + +
+ ); + } + function renderSettings() { return (
@@ -224,6 +249,7 @@ function Settings({ title={lang('Settings')} withBorder={!isContentNotScrolled} onClose={closeSettings} + className={styles.modalHeader} /> ) : (
@@ -268,16 +294,12 @@ function Settings({
)} -
- {lang('Handle - {lang('Handle ton:// links')} - - -
+ {renderHandleDeeplinkButton()} +
+ )} + {IS_ELECTRON && ( +
+ {renderHandleDeeplinkButton()}
)} @@ -339,6 +361,12 @@ function Settings({
+
+ {lang('Use + {lang('Use Responsibly')} + + +
{lang('Exit')} {lang('Exit')} @@ -399,15 +427,24 @@ function Settings({ return ; case RenderingState.About: return ; + case RenderingState.Disclaimer: + return ( + + ); } } return (
{renderContent} diff --git a/src/components/settings/SettingsAbout.tsx b/src/components/settings/SettingsAbout.tsx index 166b4e8f..56d4c331 100644 --- a/src/components/settings/SettingsAbout.tsx +++ b/src/components/settings/SettingsAbout.tsx @@ -1,9 +1,8 @@ import React, { memo } from '../../lib/teact/teact'; -import { APP_NAME, APP_VERSION } from '../../config'; +import { APP_NAME, APP_VERSION, IS_EXTENSION } from '../../config'; import renderText from '../../global/helpers/renderText'; import buildClassName from '../../util/buildClassName'; -import { IS_EXTENSION } from '../../util/windowEnvironment'; import useLang from '../../hooks/useLang'; import useScrolledState from '../../hooks/useScrolledState'; @@ -36,6 +35,7 @@ function SettingsAbout({ handleBackClick, isInsideModal }: OwnProps) { title={lang('About')} withBorder={!isContentNotScrolled} onBackButtonClick={handleBackClick} + className={styles.modalHeader} /> ) : (
@@ -144,6 +144,23 @@ function SettingsAbout({ handleBackClick, isInsideModal }: OwnProps) { })}

+
); diff --git a/src/components/settings/SettingsAppearance.tsx b/src/components/settings/SettingsAppearance.tsx index 45912f83..54ff9b71 100644 --- a/src/components/settings/SettingsAppearance.tsx +++ b/src/components/settings/SettingsAppearance.tsx @@ -98,7 +98,11 @@ function SettingsAppearance({ return (
{isInsideModal ? ( - + ) : (
+
+ )} +
+
+ +
{lang('Use Responsibly')}
+
+
+

{renderText(lang('$auth_responsibly_description1'))}

+

{renderText(lang('$auth_responsibly_description2'))}

+

{renderText(lang('$auth_responsibly_description3'))}

+

{renderText(lang('$auth_responsibly_description4'))}

+
+
+
+ ); +} + +export default memo(SettingsDisclaimer); diff --git a/src/components/settings/SettingsLanguage.tsx b/src/components/settings/SettingsLanguage.tsx index f12f2ce9..d8a058b4 100644 --- a/src/components/settings/SettingsLanguage.tsx +++ b/src/components/settings/SettingsLanguage.tsx @@ -62,7 +62,7 @@ function SettingsLanguage({ ) : (
diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 8e571dd1..d4d71388 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -14,7 +14,6 @@ function SettingsModal({ children, isOpen, onClose }: OwnProps) { return ( { + const handleBackClick = useLastCallback(() => { if (state === StakingState.StakePassword) { setStakingScreen({ state: StakingState.StakeInitial }); } - }, [setStakingScreen, state]); + }); - const handleTransferSubmit = useCallback((password: string) => { + const handleTransferSubmit = useLastCallback((password: string) => { submitStakingPassword({ password }); - }, [submitStakingPassword]); + }); - const handleViewStakingInfoClick = useCallback(() => { + const handleViewStakingInfoClick = useLastCallback(() => { onViewStakingInfo(); cancelStaking(); - }, [cancelStaking, onViewStakingInfo]); + }); function renderPassword(isActive: boolean) { return ( @@ -144,7 +145,6 @@ function StakeModal({ return ( { + const handleStakeClick = useLastCallback(() => { onClose?.(); startStaking(); - }, [onClose, startStaking]); + }); - const handleUnstakeClick = useCallback(() => { + const handleUnstakeClick = useLastCallback(() => { onClose?.(); startStaking({ isUnstaking: true }); - }, [onClose, startStaking]); + }); const stakingResult = round(amount, STAKING_DECIMAL); const balanceResult = round(amount + (amount / 100) * apyValue, STAKING_DECIMAL); diff --git a/src/components/staking/StakingInfoModal.tsx b/src/components/staking/StakingInfoModal.tsx index 62c6bfe4..efde0c1e 100644 --- a/src/components/staking/StakingInfoModal.tsx +++ b/src/components/staking/StakingInfoModal.tsx @@ -43,7 +43,6 @@ function StakingInfoModal({ return ( diff --git a/src/components/staking/StakingInitial.tsx b/src/components/staking/StakingInitial.tsx index 4804fb2f..0e14ba37 100644 --- a/src/components/staking/StakingInitial.tsx +++ b/src/components/staking/StakingInitial.tsx @@ -1,5 +1,5 @@ import React, { - memo, useCallback, useEffect, useMemo, useState, + memo, useEffect, useMemo, useState, } from '../../lib/teact/teact'; import type { UserToken } from '../../global/types'; @@ -23,6 +23,7 @@ import { ASSET_LOGO_PATHS } from '../ui/helpers/assetLogos'; import useFlag from '../../hooks/useFlag'; import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; import AnimatedIconWithPreview from '../ui/AnimatedIconWithPreview'; import Button from '../ui/Button'; @@ -81,7 +82,7 @@ function StakingInitial({ } = useMemo(() => tokens?.find(({ slug }) => slug === TON_TOKEN_SLUG), [tokens]) || {}; const hasAmountError = Boolean(isInsufficientBalance || apiError); - const validateAndSetAmount = useCallback((newAmount: number | undefined, noReset = false) => { + const validateAndSetAmount = useLastCallback((newAmount: number | undefined, noReset = false) => { if (!noReset) { setShouldUseAllBalance(false); setIsNotEnough(false); @@ -105,7 +106,7 @@ function StakingInitial({ } setAmount(newAmount); - }, [balance, stakingBalance, stakingMinAmount]); + }); useEffect(() => { if (shouldUseAllBalance && balance) { @@ -130,15 +131,13 @@ function StakingInitial({ }); }, [amount, fetchStakingFee]); - const handleAmountChange = useCallback(validateAndSetAmount, [validateAndSetAmount]); - - const handleAmountBlur = useCallback(() => { + const handleAmountBlur = useLastCallback(() => { if (amount && amount + stakingBalance < stakingMinAmount) { setIsNotEnough(true); } - }, [amount, stakingBalance, stakingMinAmount]); + }); - const handleBalanceLinkClick = useCallback((e: React.MouseEvent) => { + const handleBalanceLinkClick = useLastCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -147,9 +146,9 @@ function StakingInitial({ } setShouldUseAllBalance(true); - }, [balance]); + }); - const handleMinusOneClick = useCallback((e: React.MouseEvent) => { + const handleMinusOneClick = useLastCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -158,13 +157,13 @@ function StakingInitial({ } validateAndSetAmount(amount - MIN_BALANCE_FOR_UNSTAKE); - }, [amount, balance, validateAndSetAmount]); + }); const canSubmit = amount && balance && !isNotEnough && amount <= balance && (amount + stakingBalance >= stakingMinAmount); - const handleSubmit = useCallback((e) => { + const handleSubmit = useLastCallback((e) => { e.preventDefault(); if (!canSubmit) { @@ -172,7 +171,7 @@ function StakingInitial({ } submitStakingInitial({ amount }); - }, [canSubmit, submitStakingInitial, amount]); + }); function getError() { if (isInsufficientBalance) { @@ -315,7 +314,7 @@ function StakingInitial({ value={amount} labelText={lang('Amount')} onBlur={handleAmountBlur} - onChange={handleAmountChange} + onChange={validateAndSetAmount} onPressEnter={handleSubmit} decimals={decimals} inputClassName={isStatic ? styles.inputRichStatic : undefined} diff --git a/src/components/staking/StakingProfileItem.module.scss b/src/components/staking/StakingProfileItem.module.scss index 05b79df3..60ade5bc 100644 --- a/src/components/staking/StakingProfileItem.module.scss +++ b/src/components/staking/StakingProfileItem.module.scss @@ -23,7 +23,7 @@ bottom: 0; left: 3.875rem; - height: 1px; + height: 0.0625rem; /* stylelint-disable-next-line plugin/whole-pixel */ box-shadow: inset 0 -0.025rem 0 0 var(--color-separator); } diff --git a/src/components/staking/UnstakeModal.tsx b/src/components/staking/UnstakeModal.tsx index a17d0b28..6395612c 100644 --- a/src/components/staking/UnstakeModal.tsx +++ b/src/components/staking/UnstakeModal.tsx @@ -1,5 +1,5 @@ import React, { - memo, useCallback, useEffect, useMemo, useState, + memo, useEffect, useMemo, useState, } from '../../lib/teact/teact'; import { StakingState } from '../../global/types'; @@ -18,6 +18,7 @@ import { ASSET_LOGO_PATHS } from '../ui/helpers/assetLogos'; import useForceUpdate from '../../hooks/useForceUpdate'; import useInterval from '../../hooks/useInterval'; import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; import useModalTransitionKeys from '../../hooks/useModalTransitionKeys'; import usePrevious from '../../hooks/usePrevious'; import useSyncEffect from '../../hooks/useSyncEffect'; @@ -89,28 +90,28 @@ function UnstakeModal({ } }, [endOfStakingCycle]); - const refreshUnstakeDate = useCallback(() => { + const refreshUnstakeDate = useLastCallback(() => { if (unstakeDate < Date.now()) { fetchBackendStakingState(); } forceUpdate(); - }, [fetchBackendStakingState, forceUpdate, unstakeDate]); + }); useInterval(refreshUnstakeDate, UPDATE_UNSTAKE_DATE_INTERVAL_MS); - const handleBackClick = useCallback(() => { + const handleBackClick = useLastCallback(() => { if (state === StakingState.UnstakePassword) { setStakingScreen({ state: StakingState.UnstakeInitial }); } - }, [setStakingScreen, state]); + }); - const handleStartUnstakeClick = useCallback(() => { + const handleStartUnstakeClick = useLastCallback(() => { submitStakingInitial({ isUnstaking: true }); - }, [submitStakingInitial]); + }); - const handleTransferSubmit = useCallback((password: string) => { + const handleTransferSubmit = useLastCallback((password: string) => { submitStakingPassword({ password, isUnstaking: true }); - }, [submitStakingPassword]); + }); function renderInitial(isActive: boolean) { return ( @@ -237,7 +238,6 @@ function UnstakeModal({ return ( ((acc, token) => { - if (token.amount > 0) { + if (token.amount > 0 || token.slug === tokenSlug) { acc.push({ value: token.slug, icon: token.image || ASSET_LOGO_PATHS[token.symbol.toLowerCase() as keyof typeof ASSET_LOGO_PATHS], @@ -145,12 +144,11 @@ function TransferInitial({ return acc; }, []); - }, [tokens]); + }, [tokenSlug, tokens]); const validateAndSetAmount = useLastCallback( (newAmount: number | undefined, noReset = false) => { if (!noReset) { - setShouldUseAllBalance(false); setHasAmountError(false); setIsInsufficientBalance(false); } @@ -175,18 +173,15 @@ function TransferInitial({ ); useEffect(() => { - if (shouldUseAllBalance && balance) { + if (balance && amount === balance) { const calculatedFee = fee ? bigStrToHuman(fee, decimals) : 0; const reducedAmount = balance - calculatedFee * RESERVED_FEE_FACTOR; - const newAmount = tokenSlug === TON_TOKEN_SLUG ? reducedAmount : balance; - validateAndSetAmount(newAmount, true); + const newAmount = tokenSlug === TON_TOKEN_SLUG && reducedAmount > 0 ? reducedAmount : balance; + validateAndSetAmount(newAmount); } else { - validateAndSetAmount(amount, true); + validateAndSetAmount(amount); } - }, [ - tokenSlug, amount, balance, fee, - decimals, shouldUseAllBalance, validateAndSetAmount, - ]); + }, [tokenSlug, amount, balance, fee, decimals, validateAndSetAmount]); useEffect(() => { if (!toAddress || hasToAddressError || !amount || !isAddressValid) { @@ -309,7 +304,7 @@ function TransferInitial({ return; } - setShouldUseAllBalance(true); + setTransferAmount({ amount: balance }); }, ); diff --git a/src/components/transfer/TransferModal.tsx b/src/components/transfer/TransferModal.tsx index 0cff9910..d3581644 100644 --- a/src/components/transfer/TransferModal.tsx +++ b/src/components/transfer/TransferModal.tsx @@ -1,6 +1,4 @@ -import React, { - memo, useEffect, useMemo, -} from '../../lib/teact/teact'; +import React, { memo, useEffect, useMemo } from '../../lib/teact/teact'; import { TransferState } from '../../global/types'; import type { GlobalState, HardwareConnectState, UserToken } from '../../global/types'; @@ -278,7 +276,6 @@ function TransferModal({ return ( { + const handleLoad = useLastCallback(() => { markAnimationLoaded(); onLoad?.(); - }, [markAnimationLoaded, onLoad]); + }); const [playKey, setPlayKey] = useState(String(Math.random())); - const handleClick = useCallback(() => { + const handleClick = useLastCallback(() => { if (play === true) { setPlayKey(String(Math.random())); } onClick?.(); - }, [onClick, play]); + }); return ( { + const handlePreviewLoad = useLastCallback(() => { markPreviewLoaded(); loadedPreviewUrls.add(previewUrl); - }, [markPreviewLoaded, previewUrl]); + }); return (
{ + const handleClick = useLastCallback(() => { if (!isDisabled && onClick) { onClick(); } @@ -56,7 +58,7 @@ function Button({ setTimeout(() => { setIsClicked(false); }, CLICKED_TIMEOUT); - }, [isDisabled, onClick]); + }); return (