diff --git a/.gitignore b/.gitignore index 137ce2f8..9cb6864f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ MyTonWallet-opera.zip .vscode *.iml dev/perf/screenshot* +dev/locales/input.yaml +dev/locales/output.yaml .DS_store .DS_Store test-results diff --git a/capacitor.config.ts b/capacitor.config.ts index aca1515a..495c4ba7 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -1,4 +1,5 @@ import type { CapacitorConfig } from '@capacitor/cli'; +import type { KeyboardResize } from '@capacitor/keyboard'; const { APP_ENV = 'production' } = process.env; @@ -10,6 +11,7 @@ const COMMON_PLUGINS = [ '@capacitor/clipboard', '@capacitor/filesystem', '@capacitor/haptics', + '@capacitor/keyboard', '@capacitor/push-notifications', '@capacitor/share', '@capgo/capacitor-native-biometric', @@ -29,10 +31,6 @@ const IOS_PLUGINS = [ '@sina_kh/mtw-capacitor-splash-screen', ]; -const ANDROID_PLUGINS = [ - '@capacitor/keyboard', -]; - const config: CapacitorConfig = { appId: 'org.mytonwallet.app', appName: 'MyTonWallet', @@ -43,7 +41,7 @@ const config: CapacitorConfig = { }, android: { path: 'mobile/android', - includePlugins: COMMON_PLUGINS.concat(ANDROID_PLUGINS), + includePlugins: COMMON_PLUGINS, webContentsDebuggingEnabled: APP_ENV !== 'production', }, ios: { @@ -62,6 +60,11 @@ const config: CapacitorConfig = { PushNotifications: { presentationOptions: [], }, + Keyboard: { + // Needed to disable the automatic focus scrolling on iOS. The scroll is controlled manually by focusScroll.ts + // for a better focus scroll control. + resize: 'none' as KeyboardResize, + }, }, }; diff --git a/changelogs/3.2.6.txt b/changelogs/3.2.6.txt new file mode 100644 index 00000000..619f4cd5 --- /dev/null +++ b/changelogs/3.2.6.txt @@ -0,0 +1 @@ +Bug fixes and performance improvements diff --git a/dev/locales/README.md b/dev/locales/README.md new file mode 100644 index 00000000..ec9e5ab8 --- /dev/null +++ b/dev/locales/README.md @@ -0,0 +1,25 @@ +# i18n helpers + +Scripts for managing i18n translation keys and YAML files. + +## Features +1. **Extract Missing Translations**: Scans `src/` for `lang('key')` patterns and identifies missing translations in `src/i18n/`. +2. **Update Locales**: Merges new translations from `dev/locales/input.yaml` into YAML files in `src/i18n/`. + +## Usage + +### Extract Missing Translations +1. Run `npm run i18n:findMissing` + +2. Missing keys are written to `output.yaml`. + +### Update Locales +1. Add translations to `dev/locales/input.yaml`: + ```yaml + en: + $key: "English text" + de: + $key: "German text" + ``` + +2. Run `npm run i18n:update` diff --git a/dev/locales/config.js b/dev/locales/config.js new file mode 100644 index 00000000..562c6818 --- /dev/null +++ b/dev/locales/config.js @@ -0,0 +1,3 @@ +const i18nDir = './src/i18n'; + +module.exports = { i18nDir }; diff --git a/dev/locales/findMissingKeys.js b/dev/locales/findMissingKeys.js new file mode 100644 index 00000000..e36194b7 --- /dev/null +++ b/dev/locales/findMissingKeys.js @@ -0,0 +1,79 @@ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const { i18nDir } = require('./config'); + +const outputFilePath = path.resolve(__dirname, 'output.yaml'); +const srcDir = './src'; + +function extractLangKeys(dir) { + const langKeys = new Set(); + + function traverse(directory) { + const files = fs.readdirSync(directory); + + files.forEach((file) => { + const fullPath = path.join(directory, file); + const stat = fs.statSync(fullPath); + + if (stat.isDirectory()) { + traverse(fullPath); + } else if (file.endsWith('.ts') || file.endsWith('.tsx') || file.endsWith('.js') || file.endsWith('.jsx')) { + const content = fs.readFileSync(fullPath, 'utf8'); + + const matches = content.matchAll(/lang\(\s*(['"`])((?:\\.|[^\\])*?)\1(?:,|\))/gs); + + for (const match of matches) { + let key = match[2]; + + key = key.replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\n/g, '\n'); + + langKeys.add(key); + } + } + }); + } + + traverse(dir); + return langKeys; +} + +function findMissingTranslations(langKeys, i18nDir) { + const missingTranslations = {}; + + const localeFiles = fs.readdirSync(i18nDir).filter((file) => file.endsWith('.yaml')); + + localeFiles.forEach((file) => { + const lang = path.basename(file, '.yaml'); + const filePath = path.join(i18nDir, file); + const translations = yaml.load(fs.readFileSync(filePath, 'utf8')) || {}; + + langKeys.forEach((key) => { + if (!translations[key]) { + if (!missingTranslations[lang]) { + missingTranslations[lang] = {}; + } + missingTranslations[lang][key] = key; + } + }); + }); + + return missingTranslations; +} + +function writeMissingTranslations(missingTranslations, outputFilePath) { + const yamlContent = yaml.dump(missingTranslations, { noRefs: true, indent: 2 }); + fs.writeFileSync(outputFilePath, yamlContent, 'utf8'); + console.log(`Missing translations written to ${outputFilePath}`); +} + +(function main() { + console.log('Extracting lang keys from source code...'); + const langKeys = extractLangKeys(srcDir); + + console.log('Checking for missing translations...'); + const missingTranslations = findMissingTranslations(langKeys, i18nDir); + + console.log('Writing missing translations to output file...'); + writeMissingTranslations(missingTranslations, outputFilePath); +})(); diff --git a/dev/locales/updateLocales.js b/dev/locales/updateLocales.js new file mode 100644 index 00000000..1d6a74a2 --- /dev/null +++ b/dev/locales/updateLocales.js @@ -0,0 +1,46 @@ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const { i18nDir } = require('./config'); + +const inputFilePath = path.resolve(__dirname, 'input.yaml'); + +function updateYamlFiles() { + try { + const inputContent = fs.readFileSync(inputFilePath, 'utf8'); + const inputData = yaml.load(inputContent); + + for (const lang in inputData) { + const langData = inputData[lang]; + const yamlFilePath = path.join(i18nDir, `${lang}.yaml`); + + let existingData = {}; + + if (fs.existsSync(yamlFilePath)) { + const existingContent = fs.readFileSync(yamlFilePath, 'utf8'); + existingData = yaml.load(existingContent) || {}; + } + + const updatedData = { + ...existingData, + ...langData, + }; + + const updatedYaml = yaml.dump(updatedData, { + noRefs: true, + indent: 2, + lineWidth: -1, + quotingType: '"', + }); + fs.writeFileSync(yamlFilePath, updatedYaml, 'utf8'); + + console.log(`Updated: ${yamlFilePath}`); + } + + console.log('All YAML files have been updated.'); + } catch (error) { + console.error('Error updating YAML files:', error); + } +} + +updateYamlFiles(); diff --git a/mobile/android/app/capacitor.build.gradle b/mobile/android/app/capacitor.build.gradle index 6b5f33b4..f18dbeb0 100644 --- a/mobile/android/app/capacitor.build.gradle +++ b/mobile/android/app/capacitor.build.gradle @@ -16,6 +16,7 @@ dependencies { implementation project(':capacitor-clipboard') implementation project(':capacitor-filesystem') implementation project(':capacitor-haptics') + implementation project(':capacitor-keyboard') implementation project(':capacitor-push-notifications') implementation project(':capacitor-share') implementation project(':capgo-capacitor-native-biometric') @@ -28,7 +29,6 @@ dependencies { implementation project(':mtw-capacitor-usb-hid') implementation project(':native-bottom-sheet') implementation project(':native-dialog') - implementation project(':capacitor-keyboard') } diff --git a/mobile/android/capacitor.settings.gradle b/mobile/android/capacitor.settings.gradle index 10c42b68..b9fd1a4b 100644 --- a/mobile/android/capacitor.settings.gradle +++ b/mobile/android/capacitor.settings.gradle @@ -23,6 +23,9 @@ project(':capacitor-filesystem').projectDir = new File('../../node_modules/@capa include ':capacitor-haptics' project(':capacitor-haptics').projectDir = new File('../../node_modules/@capacitor/haptics/android') +include ':capacitor-keyboard' +project(':capacitor-keyboard').projectDir = new File('../../node_modules/@capacitor/keyboard/android') + include ':capacitor-push-notifications' project(':capacitor-push-notifications').projectDir = new File('../../node_modules/@capacitor/push-notifications/android') @@ -58,6 +61,3 @@ project(':native-bottom-sheet').projectDir = new File('../plugins/native-bottom- include ':native-dialog' project(':native-dialog').projectDir = new File('../plugins/native-dialog/android') - -include ':capacitor-keyboard' -project(':capacitor-keyboard').projectDir = new File('../../node_modules/@capacitor/keyboard/android') diff --git a/mobile/ios/App/Podfile b/mobile/ios/App/Podfile index e74ae4a5..376ec6a8 100644 --- a/mobile/ios/App/Podfile +++ b/mobile/ios/App/Podfile @@ -18,6 +18,7 @@ def capacitor_pods pod 'CapacitorClipboard', :path => '../../../node_modules/@capacitor/clipboard' pod 'CapacitorFilesystem', :path => '../../../node_modules/@capacitor/filesystem' pod 'CapacitorHaptics', :path => '../../../node_modules/@capacitor/haptics' + pod 'CapacitorKeyboard', :path => '../../../node_modules/@capacitor/keyboard' pod 'CapacitorPushNotifications', :path => '../../../node_modules/@capacitor/push-notifications' pod 'CapacitorShare', :path => '../../../node_modules/@capacitor/share' pod 'CapgoCapacitorNativeBiometric', :path => '../../../node_modules/@capgo/capacitor-native-biometric' diff --git a/mobile/ios/App/Podfile.lock b/mobile/ios/App/Podfile.lock index 1dcf561b..f8232df1 100644 --- a/mobile/ios/App/Podfile.lock +++ b/mobile/ios/App/Podfile.lock @@ -14,6 +14,8 @@ PODS: - Capacitor - CapacitorHaptics (6.0.1): - Capacitor + - CapacitorKeyboard (6.0.3): + - Capacitor - CapacitorMlkitBarcodeScanning (6.2.0): - Capacitor - GoogleMLKit/BarcodeScanning (= 5.0.0) @@ -152,6 +154,7 @@ DEPENDENCIES: - "CapacitorCordova (from `../../../node_modules/@capacitor/ios`)" - "CapacitorFilesystem (from `../../../node_modules/@capacitor/filesystem`)" - "CapacitorHaptics (from `../../../node_modules/@capacitor/haptics`)" + - "CapacitorKeyboard (from `../../../node_modules/@capacitor/keyboard`)" - "CapacitorMlkitBarcodeScanning (from `../../../node_modules/@capacitor-mlkit/barcode-scanning`)" - CapacitorNativeSettings (from `../../../node_modules/capacitor-native-settings`) - CapacitorPluginSafeArea (from `../../../node_modules/capacitor-plugin-safe-area`) @@ -207,6 +210,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/@capacitor/filesystem" CapacitorHaptics: :path: "../../../node_modules/@capacitor/haptics" + CapacitorKeyboard: + :path: "../../../node_modules/@capacitor/keyboard" CapacitorMlkitBarcodeScanning: :path: "../../../node_modules/@capacitor-mlkit/barcode-scanning" CapacitorNativeSettings: @@ -255,6 +260,7 @@ SPEC CHECKSUMS: CapacitorCordova: f48c89f96c319101cd2f0ce8a2b7449b5fb8b3dd CapacitorFilesystem: 37fb3aa5c945b4539ab11c74a5c57925a302bf24 CapacitorHaptics: fe689ade56ef20ec9b041a753c6da70c5d8ec9a9 + CapacitorKeyboard: 460c6f9ec5e52c84f2742d5ce2e67bbc7ab0ebb0 CapacitorMlkitBarcodeScanning: 178fb57424ec688b6a2fceee506ecc1ea00d1c8d CapacitorNativeSettings: 1ce5585ff07b161616cd0a795702637316677af2 CapacitorPluginSafeArea: e1eca7f70974f0e270d96f70cd0a5f51523164b1 @@ -289,6 +295,6 @@ SPEC CHECKSUMS: SinaKhMtwCapacitorStatusBar: e3fa73038e8dbac071751e1942eab65fc6c39a5f SwiftKeychainWrapper: 807ba1d63c33a7d0613288512399cd1eda1e470c -PODFILE CHECKSUM: ad14d80bb92e306162432517de945467c60b7527 +PODFILE CHECKSUM: fa64166a091f354ce503fdaf4d4b36e1cc75b934 COCOAPODS: 1.16.2 diff --git a/package-lock.json b/package-lock.json index d14643b0..9a2d15f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mytonwallet", - "version": "3.2.5", + "version": "3.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mytonwallet", - "version": "3.2.5", + "version": "3.2.6", "license": "GPL-3.0-or-later", "dependencies": { "@awesome-cordova-plugins/core": "6.9.0", diff --git a/package.json b/package.json index ad72760a..956b5665 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mytonwallet", - "version": "3.2.5", + "version": "3.2.6", "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": { @@ -43,7 +43,9 @@ "giveaways:build": "webpack --config ./webpack-giveaways.config.ts && bash ./deploy/copy_to_dist.sh dist-giveaways", "giveaways:build:dev": "APP_ENV=development webpack --mode development --config ./webpack-giveaways.config.ts && bash ./deploy/copy_to_dist.sh dist-giveaways", "giveaways:dev": "APP_ENV=development webpack serve --mode development --port 4322 --config ./webpack-giveaways.config.ts", - "resolve-stacktrace": "node ./dev/resolveStackTrace.js" + "resolve-stacktrace": "node ./dev/resolveStackTrace.js", + "i18n:update": "node ./dev/locales/updateLocales.js", + "i18n:find-missing": "node ./dev/locales/findMissingKeys.js" }, "engines": { "node": "^22", diff --git a/public/static-sites/.gitignore b/public/static-sites/.gitignore index 749a535b..57ceaa32 100644 --- a/public/static-sites/.gitignore +++ b/public/static-sites/.gitignore @@ -1,3 +1,5 @@ index.css bg +images +common.js !_common/* diff --git a/public/static-sites/_common/common.js b/public/static-sites/_common/common.js new file mode 100644 index 00000000..0cf1b177 --- /dev/null +++ b/public/static-sites/_common/common.js @@ -0,0 +1,32 @@ +function getPlatform() { + const { + userAgent, + platform, + } = window.navigator; + + const iosPlatforms = ['iPhone', 'iPad', 'iPod']; + if ( + iosPlatforms.indexOf(platform) !== -1 + // For new IPads with M1 chip and IPadOS platform returns "MacIntel" + || (platform === 'MacIntel' && ('maxTouchPoints' in navigator && navigator.maxTouchPoints > 2)) + ) { + return 'iOS'; + } + + const macosPlatforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K']; + if (macosPlatforms.indexOf(platform) !== -1) return 'macOS'; + + const windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE']; + if (windowsPlatforms.indexOf(platform) !== -1) return 'Windows'; + + if (/Android/.test(userAgent)) return 'Android'; + + if (/Linux/.test(platform)) return 'Linux'; + + return undefined; +} + +export let platform = getPlatform(); + +export const IS_DESKTOP = ['Windows', 'Linux', 'macOS'].includes(platform); +export const IS_MOBILE = !IS_DESKTOP; diff --git a/public/static-sites/_common/images/MacBook-mockup-1.5x.webp b/public/static-sites/_common/images/MacBook-mockup-1.5x.webp new file mode 100644 index 00000000..7efe7e19 Binary files /dev/null and b/public/static-sites/_common/images/MacBook-mockup-1.5x.webp differ diff --git a/public/static-sites/_common/images/MacBook-mockup-1x.webp b/public/static-sites/_common/images/MacBook-mockup-1x.webp new file mode 100644 index 00000000..32916899 Binary files /dev/null and b/public/static-sites/_common/images/MacBook-mockup-1x.webp differ diff --git a/public/static-sites/_common/images/MacBook-mockup-2x.webp b/public/static-sites/_common/images/MacBook-mockup-2x.webp new file mode 100644 index 00000000..c14367d3 Binary files /dev/null and b/public/static-sites/_common/images/MacBook-mockup-2x.webp differ diff --git a/public/static-sites/_common/images/QR-code-1.5x.webp b/public/static-sites/_common/images/QR-code-1.5x.webp new file mode 100644 index 00000000..aeb05474 Binary files /dev/null and b/public/static-sites/_common/images/QR-code-1.5x.webp differ diff --git a/public/static-sites/_common/images/QR-code-1x.webp b/public/static-sites/_common/images/QR-code-1x.webp new file mode 100644 index 00000000..22a6d240 Binary files /dev/null and b/public/static-sites/_common/images/QR-code-1x.webp differ diff --git a/public/static-sites/_common/images/QR-code-2x.webp b/public/static-sites/_common/images/QR-code-2x.webp new file mode 100644 index 00000000..d1bff43f Binary files /dev/null and b/public/static-sites/_common/images/QR-code-2x.webp differ diff --git a/public/static-sites/_common/index.css b/public/static-sites/_common/index.css index c6619a6e..49039834 100644 --- a/public/static-sites/_common/index.css +++ b/public/static-sites/_common/index.css @@ -57,6 +57,10 @@ body { } } +.hidden { + display: none !important; +} + .container { display: flex; flex-direction: column; @@ -74,10 +78,18 @@ body { box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); } +.container.desktop { + max-width: unset; +} + .container.small-padding { padding: 1rem; } +.container.no-flex { + display: block; +} + .logo-container { display: flex; gap: 5px; @@ -133,7 +145,10 @@ body { color: #444; } -.info { +info-block { + display: block; + + box-sizing: border-box; margin-top: 28px; margin-bottom: 36px; @@ -141,6 +156,16 @@ body { color: #444444; } +/* All except last one */ +info-block:not(.with-gap > info-block):not(:last-of-type) { + margin-bottom: 0; +} + +/* All except first one */ +info-block:not(.with-gap > info-block) + info-block { + margin-top: 0; +} + .download-container { display: flex; gap: 20px; @@ -231,6 +256,11 @@ a:hover { border-radius: 1.25rem; } +.qr-code.without-margin { + margin: 0 !important; + margin-top: -16px !important; +} + .special-section { margin-top: 3rem; @@ -248,3 +278,62 @@ a:hover { margin-bottom: -1rem; margin-left: 0.625rem; } + +.with-separator { + position: relative; +} + +.subheader { + display: block; + + font-size: 20px; + font-weight: 700; + line-height: 1; + color: #222222; +} + +.with-separator::after { + content: ""; + + position: absolute; + top: calc(50% - 240px / 2); + left: 50%; + + display: block; + + width: 1px; + height: 240px; + + background-color: #CECECF; +} + +.column { + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + + min-width: 288px; + margin-top: 32px; + margin-bottom: 32px !important; + /* margin-bottom: 16px !important; */ +} + +.instruction { + margin-top: 0; + margin-bottom: 32px; +} + +.column > info-block:last-of-type { + margin-bottom: 0 !important; +} + +.columns { + display: flex; + gap: 32px; +} + +.dapp-open-block { + margin-top: -16px !important; + margin-bottom: 4px !important; +} diff --git a/public/static-sites/connect/desktop.html b/public/static-sites/connect/desktop.html new file mode 100644 index 00000000..e864aca4 --- /dev/null +++ b/public/static-sites/connect/desktop.html @@ -0,0 +1,69 @@ + + + + + + MyTonWallet Connect + + + +
+

Connect MyTonWallet

+
+ + On Mobile +

+ Scan QR to open the app
on your mobile device: +

+ +
+ + On Desktop + +

+ 1. Launch the app and log into
your wallet. +

+ + Install MyTonWallet + +
+ +

+ 2. Once you have done it, proceed
by clicking the button below. +

+ + Open MyTonWallet + +
+
+
+ +

+ Once you connected your wallet, go back to dapp. +

+ + Return to dapp + +
+
+ + + diff --git a/public/static-sites/connect/index.html b/public/static-sites/connect/index.html index 95b0e466..669454d5 100644 --- a/public/static-sites/connect/index.html +++ b/public/static-sites/connect/index.html @@ -7,48 +7,59 @@ -
+

Connect MyTonWallet

-
+

1. Launch the app and log into your wallet.

- Install MyTonWallet -
-
+ Install MyTonWallet + +

2. Once you launched MyTonWallet,
proceed by clicking the button below.

- + Open MyTonWallet -
-
+ +

3. Once you connected your wallet,
go back to dapp.

Return to dapp -
+
- diff --git a/public/static-sites/get/android.html b/public/static-sites/get/android.html index 0ba24d25..4978de92 100644 --- a/public/static-sites/get/android.html +++ b/public/static-sites/get/android.html @@ -4,7 +4,7 @@ MyTonWallet for Android - + diff --git a/public/static-sites/get/desktop.html b/public/static-sites/get/desktop.html index 1213bf9f..91040e84 100644 --- a/public/static-sites/get/desktop.html +++ b/public/static-sites/get/desktop.html @@ -4,7 +4,7 @@ MyTonWallet Desktop - + diff --git a/public/static-sites/get/index.html b/public/static-sites/get/index.html index 35d48fe3..56655661 100644 --- a/public/static-sites/get/index.html +++ b/public/static-sites/get/index.html @@ -4,7 +4,7 @@ Install MyTonWallet - + diff --git a/public/static-sites/get/index.js b/public/static-sites/get/index.js index bd6e2efe..b49c1f41 100644 --- a/public/static-sites/get/index.js +++ b/public/static-sites/get/index.js @@ -1,3 +1,6 @@ +import { IS_DESKTOP, IS_MOBILE, platform } from "./common.js"; + + const REPO = 'mytonwalletorg/mytonwallet'; const LATEST_RELEASE_API_URL = `https://api.github.com/repos/${REPO}/releases/latest`; const LATEST_RELEASE_WEB_URL = `https://github.com/${REPO}/releases/latest`; @@ -9,7 +12,6 @@ const MOBILE_URLS = { androidDirect: `${LATEST_RELEASE_DOWNLOAD_URL}/MyTonWallet.apk`, }; -let platform = getPlatform(); const currentPage = location.href.includes('/android') ? 'android' : location.href.includes('/mac') @@ -59,9 +61,6 @@ const packagesPromise = fetch(LATEST_RELEASE_API_URL) console.error('Error:', error); }); -const IS_DESKTOP = ['Windows', 'Linux', 'macOS'].includes(platform); -const IS_MOBILE = !IS_DESKTOP; - (function init() { if (currentPage === 'rate') { setupRateButtons(); @@ -97,34 +96,6 @@ function $(id) { return document.getElementById(id); } -function getPlatform() { - const { - userAgent, - platform, - } = window.navigator; - - const iosPlatforms = ['iPhone', 'iPad', 'iPod']; - if ( - iosPlatforms.indexOf(platform) !== -1 - // For new IPads with M1 chip and IPadOS platform returns "MacIntel" - || (platform === 'MacIntel' && ('maxTouchPoints' in navigator && navigator.maxTouchPoints > 2)) - ) { - return 'iOS'; - } - - const macosPlatforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K']; - if (macosPlatforms.indexOf(platform) !== -1) return 'macOS'; - - const windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE']; - if (windowsPlatforms.indexOf(platform) !== -1) return 'Windows'; - - if (/Android/.test(userAgent)) return 'Android'; - - if (/Linux/.test(platform)) return 'Linux'; - - return undefined; -} - function setupDownloadButton() { document.addEventListener('DOMContentLoaded', () => { const downloadBtn = document.querySelector('.download-btn'); diff --git a/public/static-sites/get/mac.html b/public/static-sites/get/mac.html index abe2f7f0..32731c6a 100644 --- a/public/static-sites/get/mac.html +++ b/public/static-sites/get/mac.html @@ -4,7 +4,7 @@ MyTonWallet for Mac - + diff --git a/public/static-sites/get/mobile.html b/public/static-sites/get/mobile.html index 34acd4c5..237724ab 100644 --- a/public/static-sites/get/mobile.html +++ b/public/static-sites/get/mobile.html @@ -5,7 +5,7 @@ MyTonWallet App - + diff --git a/public/static-sites/get/rate.html b/public/static-sites/get/rate.html index f1dc1791..a2e46978 100644 --- a/public/static-sites/get/rate.html +++ b/public/static-sites/get/rate.html @@ -7,7 +7,7 @@ -
+
@@ -81,6 +81,6 @@

You received +50 XP 👍

Thank you!

- + diff --git a/public/static-sites/go/desktop.html b/public/static-sites/go/desktop.html new file mode 100644 index 00000000..a255ae46 --- /dev/null +++ b/public/static-sites/go/desktop.html @@ -0,0 +1,64 @@ + + + + + + Open MyTonWallet + + + +
+

Open MyTonWallet

+
+ + On Mobile +

+ Scan QR to open the app
on your mobile device: +

+ +
+ + On Desktop + +

+ 1. Launch the app and log into
your wallet. +

+ + Install MyTonWallet + +
+ +

+ 2. Once you have done it, proceed
by clicking the button below. +

+ + Open MyTonWallet + +
+
+
+ +
+ + + diff --git a/public/static-sites/go/index.html b/public/static-sites/go/index.html index 4b6595a5..65d5590e 100644 --- a/public/static-sites/go/index.html +++ b/public/static-sites/go/index.html @@ -5,42 +5,55 @@ Open MyTonWallet +

Open MyTonWallet

-
+

Launch the app and log into your wallet.

- Install MyTonWallet -
-
+ Install MyTonWallet + +

Once you have done it, proceed by clicking the button below.

Open MyTonWallet -
+
- diff --git a/public/version.txt b/public/version.txt index 5ae69bd5..34cde569 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -3.2.5 +3.2.6 diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 5045dcf0..6766e839 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -9,6 +9,9 @@ declare namespace React { // Optimization for DOM nodes prepends and inserts teactFastList?: boolean; teactExperimentControlled?: boolean; + // `focusScroll.ts` uses this attribute to decide where to scroll the focused element to in Capacitor environments. + // 'nearest' - no scroll unless the element is hidden; 'start' - the element will at the top; 'end' - at the bottom. + 'data-focus-scroll-position'?: ScrollLogicalPosition; } // Teact feature @@ -118,6 +121,13 @@ interface Document { webkitExitFullscreen?: () => Promise; } +interface Element { + dataset: DOMStringMap & { + // See HTMLAttributes['data-focus-scroll-position'] + focusScrollPosition?: ScrollLogicalPosition; + }; +} + interface HTMLElement { mozRequestFullScreen?: () => Promise; webkitEnterFullscreen?: () => Promise; diff --git a/src/api/chains/tron/constants.ts b/src/api/chains/tron/constants.ts new file mode 100644 index 00000000..f4773f49 --- /dev/null +++ b/src/api/chains/tron/constants.ts @@ -0,0 +1,3 @@ +export const TRON_GAS = { + transferTrc20Estimated: 28_214_970n, +}; diff --git a/src/api/chains/tron/transfer.ts b/src/api/chains/tron/transfer.ts index 26623088..e2404334 100644 --- a/src/api/chains/tron/transfer.ts +++ b/src/api/chains/tron/transfer.ts @@ -6,7 +6,7 @@ import type { ContractParamter, Transaction } from 'tronweb/lib/commonjs/types'; import type { ApiSubmitTransferOptions, CheckTransactionDraftOptions } from '../../methods/types'; import type { ApiCheckTransactionDraftResult } from '../ton/types'; import { ApiTransactionDraftError, ApiTransactionError } from '../../types'; -import type { ApiAccountWithMnemonic, ApiBip39Account } from '../../types'; +import type { ApiAccountWithMnemonic, ApiBip39Account, ApiNetwork } from '../../types'; import { parseAccountId } from '../../../util/account'; import { logDebugError } from '../../../util/logs'; @@ -14,10 +14,11 @@ import { getChainParameters, getTronClient } from './util/tronweb'; import { fetchStoredAccount, fetchStoredTronWallet } from '../../common/accounts'; import { getMnemonic } from '../../common/mnemonic'; import { handleServerError } from '../../errors'; -import { getWalletBalance } from './wallet'; +import { getTrc20Balance, getWalletBalance } from './wallet'; import type { ApiSubmitTransferTronResult } from './types'; import { hexToString } from '../../../util/stringFormat'; import { getChainConfig } from '../../../util/chain'; +import { TRON_GAS } from './constants'; const SIGNATURE_SIZE = 65; @@ -53,16 +54,17 @@ export async function checkTransactionDraft( if (tokenAddress) { const buildResult = await buildTrc20Transaction(tronWeb, { + network, toAddress, tokenAddress, - amount: amount ?? 0n, + amount, energyUnitFee, feeLimitTrx: chainConfig.gas.maxTransferToken, fromAddress: address, }); transaction = buildResult.transaction; - fee = BigInt(buildResult.energyFee); + fee = buildResult.energyFee; } else { // This call throws "Error: Invalid amount provided" when the amount is 0. // It doesn't throw when the amount is > than the balance. @@ -120,7 +122,7 @@ export async function submitTransfer(options: ApiSubmitTransferOptions): Promise const { energyUnitFee } = await getChainParameters(network); const { transaction } = await buildTrc20Transaction(tronWeb, { - toAddress, tokenAddress, amount, feeLimitTrx, energyUnitFee, fromAddress: address, + network, toAddress, tokenAddress, amount, feeLimitTrx, energyUnitFee, fromAddress: address, }); const signedTx = await tronWeb.trx.sign(transaction, privateKey); @@ -151,17 +153,31 @@ export async function submitTransfer(options: ApiSubmitTransferOptions): Promise } async function buildTrc20Transaction(tronWeb: TronWeb, options: { + network: ApiNetwork; tokenAddress: string; toAddress: string; - amount: bigint; + amount?: bigint; feeLimitTrx: bigint; energyUnitFee: number; fromAddress: string; }) { const { - tokenAddress, toAddress, amount, feeLimitTrx, energyUnitFee, fromAddress, + network, tokenAddress, toAddress, feeLimitTrx, energyUnitFee, fromAddress, } = options; + const isEstimation = options.amount === undefined; + let { amount = 0n } = options; + let energyFee: bigint | undefined; + + if (isEstimation) { + const tokenBalance = await getTrc20Balance(network, tokenAddress, fromAddress); + if (tokenBalance) { + amount = 1n; + } else { + energyFee = TRON_GAS.transferTrc20Estimated; + } + } + const functionSelector = 'transfer(address,uint256)'; const parameter = [ { type: 'address', value: toAddress }, @@ -176,17 +192,19 @@ async function buildTrc20Transaction(tronWeb: TronWeb, options: { fromAddress, ); - // This call throws "Error: REVERT opcode executed" when the given amount is more than the token balance. - // It doesn't throw when the amount is 0. - const { energy_required: energyRequired } = await tronWeb.transactionBuilder.estimateEnergy( - tokenAddress, - functionSelector, - {}, - parameter, - fromAddress, - ); - - const energyFee = energyUnitFee * energyRequired; + if (!energyFee) { + // This call throws "Error: REVERT opcode executed" when the given amount is more than the token balance. + // It doesn't throw when the amount is 0. + const { energy_required: energyRequired } = await tronWeb.transactionBuilder.estimateEnergy( + tokenAddress, + functionSelector, + {}, + parameter, + fromAddress, + ); + + energyFee = BigInt(energyUnitFee * energyRequired); + } return { transaction, energyFee }; } diff --git a/src/api/methods/notifications.ts b/src/api/methods/notifications.ts index cbcb5b0e..4109fc8f 100644 --- a/src/api/methods/notifications.ts +++ b/src/api/methods/notifications.ts @@ -1,33 +1,14 @@ -import type { NotificationAddress } from '../../global/selectors/notifications'; -import type { CapacitorPlatform } from '../../util/capacitor/platform'; +import type { + ApiSubscribeNotificationsProps, ApiSubscribeNotificationsResult, ApiUnsubscribeNotificationsProps, +} from '../types'; import { logDebugError } from '../../util/logs'; import { callBackendPost } from '../common/backend'; import { handleServerError } from '../errors'; -export interface SubscribeNotificationsProps { - userToken: string; - platform: CapacitorPlatform; - addresses: NotificationAddress[]; -} - -export interface UnsubscribeNotificationsProps { - userToken: string; - addresses: NotificationAddress[]; -} - -export interface NotificationsAccountValue { - key: string; -} - -export interface SubscribeNotificationsResult { - ok: true; - addressKeys: Record; -} - -export async function subscribeNotifications(props: SubscribeNotificationsProps) { +export async function subscribeNotifications(props: ApiSubscribeNotificationsProps) { try { - return await callBackendPost( + return await callBackendPost( '/notifications/subscribe', props, { shouldRetry: true }, ); } catch (err) { @@ -36,7 +17,7 @@ export async function subscribeNotifications(props: SubscribeNotificationsProps) } } -export async function unsubscribeNotifications(props: UnsubscribeNotificationsProps) { +export async function unsubscribeNotifications(props: ApiUnsubscribeNotificationsProps) { try { return await callBackendPost<{ ok: true }>( '/notifications/unsubscribe', props, { shouldRetry: true }, diff --git a/src/api/types/index.ts b/src/api/types/index.ts index e2d5a267..fea33dc7 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -5,3 +5,4 @@ export * from './errors'; export * from './backend'; export * from './storage'; export * from './activity'; +export * from './notifications'; diff --git a/src/api/types/notifications.ts b/src/api/types/notifications.ts new file mode 100644 index 00000000..4b711421 --- /dev/null +++ b/src/api/types/notifications.ts @@ -0,0 +1,28 @@ +import type { CapacitorPlatform } from '../../util/capacitor/platform'; +import type { ApiChain } from './misc'; + +export interface ApiNotificationAddress { + title?: string; + address: string; + chain: ApiChain; +} + +export interface ApiNotificationsAccountValue { + key: string; +} + +export interface ApiSubscribeNotificationsProps { + userToken: string; + platform: CapacitorPlatform; + addresses: ApiNotificationAddress[]; +} + +export interface ApiUnsubscribeNotificationsProps { + userToken: string; + addresses: ApiNotificationAddress[]; +} + +export interface ApiSubscribeNotificationsResult { + ok: true; + addressKeys: Record; +} diff --git a/src/components/App.module.scss b/src/components/App.module.scss index a874be8d..cd3bb107 100644 --- a/src/components/App.module.scss +++ b/src/components/App.module.scss @@ -1,6 +1,4 @@ -@import "../styles/variables"; - -@import '../styles/mixins/'; +@import '../styles/mixins'; .containerInner { overflow: hidden; diff --git a/src/components/auth/Auth.module.scss b/src/components/auth/Auth.module.scss index b6dc5f9c..70dd4f4b 100644 --- a/src/components/auth/Auth.module.scss +++ b/src/components/auth/Auth.module.scss @@ -1,5 +1,3 @@ -@import '../../styles/variables'; - @import '../../styles/mixins'; .wrapper { diff --git a/src/components/common/InputMnemonic.tsx b/src/components/common/InputMnemonic.tsx index 7d77105c..61c18d5b 100644 --- a/src/components/common/InputMnemonic.tsx +++ b/src/components/common/InputMnemonic.tsx @@ -191,6 +191,7 @@ function InputMnemonic({ onPaste={handlePaste} value={value} tabIndex={0} + data-focus-scroll-position={suggestionsPosition === 'top' ? 'end' : 'start'} />
); diff --git a/src/components/main/Main.module.scss b/src/components/main/Main.module.scss index 830b8ebc..9ea80a90 100644 --- a/src/components/main/Main.module.scss +++ b/src/components/main/Main.module.scss @@ -1,7 +1,5 @@ @use "sass:color"; -@import "../../styles/variables"; - @import "../../styles/mixins"; $scrollOffset: 0.1875rem; diff --git a/src/components/main/modals/OnRampWidgetModal.module.scss b/src/components/main/modals/OnRampWidgetModal.module.scss index 321c6e0c..47470ec7 100644 --- a/src/components/main/modals/OnRampWidgetModal.module.scss +++ b/src/components/main/modals/OnRampWidgetModal.module.scss @@ -1,5 +1,3 @@ -@import '../../../styles/variables'; - @import '../../../styles/mixins'; .modalDialog { diff --git a/src/components/main/sections/Card/Card.module.scss b/src/components/main/sections/Card/Card.module.scss index 71f0bd09..f0dfe7b5 100644 --- a/src/components/main/sections/Card/Card.module.scss +++ b/src/components/main/sections/Card/Card.module.scss @@ -1,7 +1,5 @@ @use "sass:math"; -@import "../../../../styles/variables"; - @import "../../../../styles/mixins"; .containerWrapper { @@ -428,11 +426,15 @@ } .tokenInfo { - display: flex; + display: grid; + grid-template-areas: "back icon header" "back icon subheader"; + grid-template-columns: min-content min-content minmax(0, 1fr); // `minmax` forces the column to stay within the grid boundaries and to cut the content with ellipsis align-items: center; } .tokenLogo { + grid-area: icon; + width: 2.5rem; height: 2.5rem; margin-right: 0.5rem; @@ -440,8 +442,16 @@ border-radius: 50%; } -.tokenAmount { - display: block; +.tokenInfoHeader, +.tokenInfoSubheader { + display: flex; + align-items: center; + justify-content: space-between; +} + +.tokenInfoHeader { + grid-area: header; + align-self: flex-end; font-size: 0.9375rem; font-weight: 700; @@ -449,24 +459,49 @@ color: var(--color-card-text); } -.tokenName { - display: flex; +.tokenInfoSubheader { + grid-area: subheader; + align-self: flex-start; margin-top: 0.25rem; font-size: 0.8125rem; font-weight: 500; - line-height: 0.9375rem; + line-height: 1; color: var(--color-card-second-text); } -.tokenPrice { - top: 1.125rem; - right: 1rem; +.tokenAmount, +.tokenName { + overflow: hidden; + + text-overflow: ellipsis; + white-space: nowrap; +} + +.tokenValue, +.tokenChange { + flex: none; + + margin-inline-start: 0.25rem; text-align: right; } +.tokenValue { + --offset-x-value: -0.3125rem; + --offset-y-value: 0.1875rem; + + margin-inline-end: -0.1875rem; +} + +.tokenTitle { + display: flex; + align-items: center; + + min-width: 0; +} + .tokenHistoryPrice { bottom: 0; } @@ -480,7 +515,6 @@ } .tokenHistoryPrice, -.tokenPrice, .tokenCurrentPrice { position: absolute; @@ -489,7 +523,6 @@ color: var(--color-card-text); } -.tokenChange, .tokenPriceDate { margin-top: 0.125rem; @@ -502,9 +535,10 @@ .apy { cursor: var(--custom-cursor, pointer); - display: inline-block; + flex: none; height: 0.9375rem; + margin-block: -0.125rem -0.0625rem; margin-inline-start: 0.25rem; padding: 0.1875rem; @@ -587,6 +621,8 @@ } .backButton { + grid-area: back; + margin: 0.25rem 0 0 -0.875rem; padding-right: 0.125rem; padding-left: 0.375rem; diff --git a/src/components/main/sections/Card/TokenCard.tsx b/src/components/main/sections/Card/TokenCard.tsx index cf40ee56..24e7d24c 100644 --- a/src/components/main/sections/Card/TokenCard.tsx +++ b/src/components/main/sections/Card/TokenCard.tsx @@ -189,34 +189,34 @@ function TokenCard({ {token.name} -
+
{formatCurrency(toDecimal(amount, token.decimals), symbol)} - - {name} + {withChange && ( +
+
+ ≈ {formatCurrency(value, currencySymbol, undefined, true)} + +
+ +
+ )} +
+
+ + {name} {yieldType && ( onYieldClick(stakingId)}> {yieldType} {annualYield}% )} -
-
- - {withChange && ( -
- - ≈ {formatCurrency(value, currencySymbol, undefined, true)} - - - - - {Boolean(changeValue) && ( + {withChange && Boolean(changeValue) && (
{changePrefix}   @@ -224,7 +224,7 @@ function TokenCard({
)}
- )} +
{!history ? ( diff --git a/src/components/main/sections/Card/helpers/calculateFullBalance.ts b/src/components/main/sections/Card/helpers/calculateFullBalance.ts index 9887ca4e..8a1c6be3 100644 --- a/src/components/main/sections/Card/helpers/calculateFullBalance.ts +++ b/src/components/main/sections/Card/helpers/calculateFullBalance.ts @@ -10,18 +10,18 @@ import { round } from '../../../../../util/math'; import styles from '../Card.module.scss'; -export function calculateFullBalance(tokens: UserToken[], states?: ApiStakingState[]) { - const stateBySlug = buildCollectionByKey(states ?? [], 'tokenSlug'); +export function calculateFullBalance(tokens: UserToken[], stakingStates?: ApiStakingState[]) { + const stakingStateBySlug = buildCollectionByKey(stakingStates ?? [], 'tokenSlug'); const primaryValue = tokens.reduce((acc, token) => { - const stakingState = stateBySlug[token.slug]; + const stakingState = stakingStateBySlug[token.slug]; if (stakingState) { - let stakingAmount = toBig(stakingState.balance, token.decimals).mul(token.price); + let stakingAmount = toBig(stakingState.balance, token.decimals); if (stakingState.type === 'jetton') { stakingAmount = stakingAmount.plus(toBig(stakingState.unclaimedRewards, token.decimals)); } - acc = acc.plus(stakingAmount); + acc = acc.plus(stakingAmount.mul(token.price)); } return acc.plus(token.totalValue); diff --git a/src/components/main/sections/Content/Content.module.scss b/src/components/main/sections/Content/Content.module.scss index 578700d8..8bb15b6a 100644 --- a/src/components/main/sections/Content/Content.module.scss +++ b/src/components/main/sections/Content/Content.module.scss @@ -34,29 +34,15 @@ flex-shrink: 0; - height: 2.75rem; + height: var(--tabs-container-height); .portraitContainer & { position: sticky; - top: 3.75rem; + top: var(--sticky-card-height); width: 100%; max-width: 27rem; margin: 0 auto; - - :global(html.with-safe-area-top) & { - top: 2.625rem; - } - - // Fix for opera, dead zone of 37 pixels in extension window on windows - :global(html.is-windows.is-opera.is-extension) & { - top: 4.75rem; - } - - // Sticky header height - electron header height - :global(html.is-electron) & { - top: 0.75rem; - } } } diff --git a/src/components/main/sections/Content/Explore.module.scss b/src/components/main/sections/Content/Explore.module.scss index 16f95f0c..415b499b 100644 --- a/src/components/main/sections/Content/Explore.module.scss +++ b/src/components/main/sections/Content/Explore.module.scss @@ -260,6 +260,10 @@ } .searchInput { + $verticalScrollMargin: 0.625rem; + + scroll-margin: $verticalScrollMargin 0.5rem; + width: 100%; padding: 0 0.25rem; @@ -284,4 +288,9 @@ color: var(--color-interactive-input-text-hover-active); } } + + @include respond-below(xs) { + // To fit the floating elements, otherwise the input will hide under the elements + scroll-margin-top: calc(var(--safe-area-top) + var(--tabs-container-height) + var(--sticky-card-height) + $verticalScrollMargin); + } } diff --git a/src/components/main/sections/Content/Explore.tsx b/src/components/main/sections/Content/Explore.tsx index f230f701..12515efd 100644 --- a/src/components/main/sections/Content/Explore.tsx +++ b/src/components/main/sections/Content/Explore.tsx @@ -156,6 +156,9 @@ function Explore({ onChange={handleChange} onFocus={handleFocus} onBlur={handleBlur} + // Android doesn't support scroll-margin here because of a Chromium bug (https://issues.chromium.org/issues/40074749). + // We can't use 'start' on Android, because without scroll-margin the input gets hidden under the floating header. + data-focus-scroll-position={IS_ANDROID ? 'center' : 'start'} /> ); diff --git a/src/components/main/sections/Content/Token.module.scss b/src/components/main/sections/Content/Token.module.scss index 415f0255..223fd0e2 100644 --- a/src/components/main/sections/Content/Token.module.scss +++ b/src/components/main/sections/Content/Token.module.scss @@ -1,7 +1,5 @@ @use "sass:color"; -@import "../../../../styles/variables"; - @import "../../../../styles/mixins"; .container { diff --git a/src/components/main/sections/Content/Transaction.module.scss b/src/components/main/sections/Content/Transaction.module.scss index dc42a853..14d42418 100644 --- a/src/components/main/sections/Content/Transaction.module.scss +++ b/src/components/main/sections/Content/Transaction.module.scss @@ -1,5 +1,3 @@ -@import "../../../../styles/variables"; - @import "../../../../styles/mixins"; .item { diff --git a/src/components/mediaViewer/MediaViewer.module.scss b/src/components/mediaViewer/MediaViewer.module.scss index 1f0ef699..82364740 100644 --- a/src/components/mediaViewer/MediaViewer.module.scss +++ b/src/components/mediaViewer/MediaViewer.module.scss @@ -1,5 +1,3 @@ -@import '../../styles/variables'; - @import '../../styles/mixins'; .root { diff --git a/src/components/receive/content/TonActions.module.scss b/src/components/receive/content/TonActions.module.scss index 5ad2fe2d..69d5ba1d 100644 --- a/src/components/receive/content/TonActions.module.scss +++ b/src/components/receive/content/TonActions.module.scss @@ -1,6 +1,4 @@ -@import '../../../styles/variables'; - -@import '../../../styles/mixins/index'; +@import '../../../styles/mixins'; .actionSheetDialog { width: 100%; diff --git a/src/components/settings/SettingsPushNotifications.tsx b/src/components/settings/SettingsPushNotifications.tsx index 0a8c8dec..23bbcb1b 100644 --- a/src/components/settings/SettingsPushNotifications.tsx +++ b/src/components/settings/SettingsPushNotifications.tsx @@ -49,10 +49,10 @@ function SettingsPushNotifications({ const { toggleNotifications, toggleNotificationAccount, toggleCanPlaySounds } = getActions(); const iterableAccounts = useMemo(() => Object.entries(accounts || {}), [accounts]); - const isPushNotificationsEnabled = Boolean(Object.keys(enabledAccounts).length); + const arePushNotificationsEnabled = Boolean(Object.keys(enabledAccounts).length); const handlePushNotificationsToggle = useLastCallback(() => { - toggleNotifications({ isEnabled: !isPushNotificationsEnabled }); + toggleNotifications({ isEnabled: !arePushNotificationsEnabled }); }); const handleCanPlaySoundToggle = useLastCallback(() => { @@ -148,7 +148,7 @@ function SettingsPushNotifications({ diff --git a/src/components/transfer/TransferInitial.tsx b/src/components/transfer/TransferInitial.tsx index b8efef18..6d62d257 100644 --- a/src/components/transfer/TransferInitial.tsx +++ b/src/components/transfer/TransferInitial.tsx @@ -145,7 +145,7 @@ function TransferInitial({ const { submitTransferInitial, showNotification, - fetchFee, + fetchTransferFee, fetchNftFee, changeTransferToken, setTransferAmount, @@ -156,7 +156,7 @@ function TransferInitial({ requestOpenQrScanner, showDialog, authorizeDiesel, - fetchDieselState, + fetchTransferDieselState, } = getActions(); // eslint-disable-next-line no-null/no-null @@ -260,7 +260,7 @@ function TransferInitial({ const updateDieselState = useLastCallback(() => { if (tokenSlug) { - fetchDieselState({ tokenSlug }); + fetchTransferDieselState({ tokenSlug }); } }); @@ -313,7 +313,7 @@ function TransferInitial({ nftAddresses: nfts?.map(({ address }) => address) || [], }); } else { - fetchFee({ + fetchTransferFee({ tokenSlug, toAddress, comment, diff --git a/src/components/ui/Button.module.scss b/src/components/ui/Button.module.scss index e36ae0ac..48fa3e9f 100644 --- a/src/components/ui/Button.module.scss +++ b/src/components/ui/Button.module.scss @@ -1,7 +1,5 @@ @use "sass:color"; -@import "../../styles/variables"; - @import "../../styles/mixins"; .button { diff --git a/src/components/ui/PinPad.tsx b/src/components/ui/PinPad.tsx index 072f4285..29babcf5 100644 --- a/src/components/ui/PinPad.tsx +++ b/src/components/ui/PinPad.tsx @@ -6,6 +6,8 @@ import type { GlobalState } from '../../global/types'; import buildClassName from '../../util/buildClassName'; import { getIsFaceIdAvailable, vibrateOnError } from '../../util/capacitor'; +import { disableSwipeToClose, enableSwipeToClose } from '../../util/modalSwipeManager'; +import { SWIPE_DISABLED_CLASS_NAME } from '../../util/swipeController'; import { IS_DELEGATED_BOTTOM_SHEET } from '../../util/windowEnvironment'; import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps'; @@ -106,6 +108,16 @@ function PinPad({ }; }, [length, onChange, onClearError, resetStateDelayMs, type, value.length]); + useEffect(() => { + if (!isActive) return undefined; + + disableSwipeToClose(); + + return () => { + enableSwipeToClose(); + }; + }, [isActive]); + const handleClick = useLastCallback((char: string) => { if (value.length === length || value.length === 0) { onClearError?.(); @@ -157,7 +169,7 @@ function PinPad({ } return ( -
+
{title}
{renderDots()} diff --git a/src/components/ui/SearchBar.module.scss b/src/components/ui/SearchBar.module.scss index c8db3e15..d5c428b9 100644 --- a/src/components/ui/SearchBar.module.scss +++ b/src/components/ui/SearchBar.module.scss @@ -1,5 +1,3 @@ -@import "../../styles/variables"; - $addon-size: 2.25rem; .wrapper { diff --git a/src/config.ts b/src/config.ts index 60e40beb..ca7f4da8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -112,7 +112,7 @@ export const SHORT_FRACTION_DIGITS = 2; export const MAX_PUSH_NOTIFICATIONS_ACCOUNT_COUNT = 3; -export const SUPPORT_USERNAME = 'MyTonWalletSupport'; +export const SUPPORT_USERNAME = 'mysupport'; export const MTW_CARDS_BASE_URL = 'https://static.mytonwallet.org/cards/'; export const MYTONWALLET_PROMO_URL = 'https://mytonwallet.io/'; export const TELEGRAM_WEB_URL = 'https://web.telegram.org/a/'; diff --git a/src/giveaways/components/App.module.scss b/src/giveaways/components/App.module.scss index d3c2c27d..06d270c4 100644 --- a/src/giveaways/components/App.module.scss +++ b/src/giveaways/components/App.module.scss @@ -1,6 +1,4 @@ -@import "../../styles/variables"; - -@import '../../styles/mixins/'; +@import '../../styles/mixins'; .app { display: flex; diff --git a/src/global/actions/api/auth.ts b/src/global/actions/api/auth.ts index 9d33b980..31397a4d 100644 --- a/src/global/actions/api/auth.ts +++ b/src/global/actions/api/auth.ts @@ -62,12 +62,12 @@ const CREATING_DURATION = 3300; const NATIVE_BIOMETRICS_PAUSE_MS = 750; export async function switchAccount(global: GlobalState, accountId: string, newNetwork?: ApiNetwork) { - const actions = getActions(); - - if (IS_DELEGATED_BOTTOM_SHEET) { - callActionInMain('switchAccount', { accountId, newNetwork }); + if (accountId === global.currentAccountId) { + return; } + const actions = getActions(); + const newestTxTimestamps = selectNewestTxTimestamps(global, accountId); await callApi('activateAccount', accountId, newestTxTimestamps); @@ -647,6 +647,11 @@ addActionHandler('startChangingNetwork', (global, actions, { network }) => { }); addActionHandler('switchAccount', async (global, actions, payload) => { + if (IS_DELEGATED_BOTTOM_SHEET) { + callActionInMain('switchAccount', payload); + return; + } + const { accountId, newNetwork } = payload; await switchAccount(global, accountId, newNetwork); }); diff --git a/src/global/actions/api/notifications.ts b/src/global/actions/api/notifications.ts index 0ad0d885..b51b1744 100644 --- a/src/global/actions/api/notifications.ts +++ b/src/global/actions/api/notifications.ts @@ -1,12 +1,35 @@ import type { - NotificationsAccountValue, -} from '../../../api/methods'; + ApiNotificationsAccountValue, + ApiSubscribeNotificationsProps, + ApiSubscribeNotificationsResult, + ApiUnsubscribeNotificationsProps, +} from '../../../api/types'; import { MAX_PUSH_NOTIFICATIONS_ACCOUNT_COUNT } from '../../../config'; +import { createAbortableFunction } from '../../../util/createAbortableFunction'; import { callApi } from '../../../api'; import { addActionHandler, getGlobal, setGlobal } from '../../index'; +import { + createNotificationAccount, + deleteAllNotificationAccounts, deleteNotificationAccount, + updateNotificationAccount, +} from '../../reducers/notifications'; import { selectAccounts } from '../../selectors'; -import { selectNotificationTonAddresses } from '../../selectors/notifications'; +import { selectNotificationTonAddressesSlow } from '../../selectors/notifications'; + +const abortableSubscribeNotifications = createAbortableFunction( + { aborted: true }, + (request: ApiSubscribeNotificationsProps) => { + return callApi('subscribeNotifications', request); + }, +); + +const abortableUnsubscribeNotifications = createAbortableFunction( + { aborted: true }, + (request: ApiUnsubscribeNotificationsProps) => { + return callApi('unsubscribeNotifications', request); + }, +); addActionHandler('registerNotifications', async (global, actions, { userToken, platform }) => { const { pushNotifications } = global; @@ -20,18 +43,18 @@ addActionHandler('registerNotifications', async (global, actions, { userToken, p createResult = await callApi('subscribeNotifications', { userToken, platform, - addresses: selectNotificationTonAddresses(global, enabledAccounts), + addresses: selectNotificationTonAddressesSlow(global, enabledAccounts), }); } else if (pushNotifications.userToken !== userToken && enabledAccounts.length) { [createResult] = await Promise.all([ callApi('subscribeNotifications', { userToken, platform, - addresses: selectNotificationTonAddresses(global, enabledAccounts), + addresses: selectNotificationTonAddressesSlow(global, enabledAccounts), }), callApi('unsubscribeNotifications', { userToken: pushNotifications.userToken!, - addresses: selectNotificationTonAddresses(global, enabledAccounts), + addresses: selectNotificationTonAddressesSlow(global, enabledAccounts), }), ]); } @@ -57,7 +80,7 @@ addActionHandler('registerNotifications', async (global, actions, { userToken, p } return acc; - }, {} as Record); + }, {} as Record); setGlobal({ ...global, @@ -67,3 +90,139 @@ addActionHandler('registerNotifications', async (global, actions, { userToken, p }, }); }); + +addActionHandler('deleteNotificationAccount', async (global, actions, { accountId, withAbort }) => { + const { userToken, enabledAccounts } = global.pushNotifications; + const pushNotificationsAccount = enabledAccounts[accountId]!; + + if (!userToken) { + return; + } + + setGlobal(deleteNotificationAccount(global, accountId)); + + const props = { userToken, addresses: selectNotificationTonAddressesSlow(global, [accountId]) }; + const result = withAbort + ? await abortableUnsubscribeNotifications(props) + : await callApi('unsubscribeNotifications', props); + + if (result && 'aborted' in result) { + return; + } + + global = getGlobal(); + + if (!result || !('ok' in result)) { + setGlobal(createNotificationAccount( + global, + accountId, + pushNotificationsAccount, + )); + return; + } + + setGlobal(deleteNotificationAccount(global, accountId)); +}); + +addActionHandler('createNotificationAccount', async (global, actions, { accountId, withAbort }) => { + const { userToken, platform } = global.pushNotifications; + + if (!userToken || !platform) { + return; + } + + setGlobal(createNotificationAccount( + global, + accountId, + {}, + )); + + const props = { userToken, platform, addresses: selectNotificationTonAddressesSlow(global, [accountId]) }; + const result = withAbort + ? await abortableSubscribeNotifications(props) + : await callApi('subscribeNotifications', props); + + if (result && 'aborted' in result) { + return; + } + + global = getGlobal(); + + if (!result || !('ok' in result)) { + setGlobal(deleteNotificationAccount( + global, + accountId, + )); + return; + } + + setGlobal(updateNotificationAccount( + global, + accountId, + result.addressKeys[global.accounts!.byId[accountId].addressByChain.ton], + )); +}); + +addActionHandler('toggleNotifications', async (global, actions, { isEnabled }) => { + const { + enabledAccounts = {}, userToken = '', platform, isAvailable, + } = global.pushNotifications; + + if (!isAvailable) { + return; + } + + let accountIds: string[]; + + if (isEnabled) { + accountIds = Object.keys(selectAccounts(global) || {}) + .slice(0, MAX_PUSH_NOTIFICATIONS_ACCOUNT_COUNT); + for (const newAccountId of accountIds) { + global = createNotificationAccount(global, newAccountId, {}); + } + } else { + accountIds = Object.keys(enabledAccounts); + global = deleteAllNotificationAccounts(global); + } + + setGlobal(global); + if (!accountIds.length) { + return; + } + + const props = { userToken, addresses: selectNotificationTonAddressesSlow(global, accountIds), platform: platform! }; + const result = isEnabled + ? await abortableSubscribeNotifications(props) + : await abortableUnsubscribeNotifications(props); + + if (result && 'aborted' in result) { + return; + } + + global = getGlobal(); + + if (!result || !('ok' in result)) { + if (isEnabled) { + global = deleteAllNotificationAccounts(global); + } else { + for (const accountId of accountIds) { + global = createNotificationAccount(global, accountId, enabledAccounts[accountId]); + } + } + setGlobal(global); + return; + } + + if (isEnabled && 'addressKeys' in result) { + const addressKeys = (result as ApiSubscribeNotificationsResult).addressKeys; + for (const accountId of accountIds) { + const address = global.accounts!.byId[accountId].addressByChain.ton; + + if (addressKeys[address]) { + global = createNotificationAccount(global, accountId, addressKeys[address]); + } + } + } + + setGlobal(global); +}); diff --git a/src/global/actions/api/staking.ts b/src/global/actions/api/staking.ts index 73feb366..9486fc32 100644 --- a/src/global/actions/api/staking.ts +++ b/src/global/actions/api/staking.ts @@ -21,6 +21,7 @@ import { updateCurrentStaking, } from '../../reducers'; import { selectAccount, selectAccountStakingState } from '../../selectors'; +import { switchAccount } from './auth'; const MODAL_CLOSING_DELAY = 50; @@ -377,6 +378,18 @@ addActionHandler('fetchStakingHistory', async (global, actions, payload) => { setGlobal(global); }); +addActionHandler('openAnyAccountStakingInfo', async (global, actions, { accountId, network, stakingId }) => { + if (IS_DELEGATED_BOTTOM_SHEET) { + callActionInMain('openAnyAccountStakingInfo', { accountId, network, stakingId }); + return; + } + + await switchAccount(global, accountId, network); + + actions.changeCurrentStaking({ stakingId }); + actions.openStakingInfo(); +}); + // Should be called only when you're sure that the staking is active. Otherwise, call `openStakingInfoOrStart`. addActionHandler('openStakingInfo', (global) => { if (IS_DELEGATED_BOTTOM_SHEET) { diff --git a/src/global/actions/api/transfer.ts b/src/global/actions/api/transfer.ts new file mode 100644 index 00000000..903b36b1 --- /dev/null +++ b/src/global/actions/api/transfer.ts @@ -0,0 +1,435 @@ +import type { ApiCheckTransactionDraftResult, ApiSubmitMultiTransferResult } from '../../../api/chains/ton/types'; +import type { ApiSubmitTransferOptions, ApiSubmitTransferResult } from '../../../api/methods/types'; +import { type ApiDappTransfer, ApiTransactionDraftError, type ApiTransactionError } from '../../../api/types'; +import { TransferState } from '../../types'; + +import { IS_CAPACITOR, NFT_BATCH_SIZE } from '../../../config'; +import { vibrateOnError, vibrateOnSuccess } from '../../../util/capacitor'; +import { callActionInNative } from '../../../util/multitab'; +import { IS_DELEGATING_BOTTOM_SHEET } from '../../../util/windowEnvironment'; +import { callApi } from '../../../api'; +import { ApiHardwareBlindSigningNotEnabled, ApiUserRejectsError } from '../../../api/errors'; +import { addActionHandler, getGlobal, setGlobal } from '../../index'; +import { + clearCurrentTransfer, + clearIsPinAccepted, + setIsPinAccepted, + updateAccountState, + updateCurrentTransfer, + updateCurrentTransferByCheckResult, + updateCurrentTransferLoading, +} from '../../reducers'; +import { selectAccountState, selectToken, selectTokenAddress } from '../../selectors'; + +addActionHandler('submitTransferInitial', async (global, actions, payload) => { + if (IS_DELEGATING_BOTTOM_SHEET) { + callActionInNative('submitTransferInitial', payload); + return; + } + + const { + tokenSlug, + toAddress, + amount, + comment, + shouldEncrypt, + nftAddresses, + withDiesel, + stateInit, + isGaslessWithStars, + binPayload, + } = payload; + + setGlobal(updateCurrentTransferLoading(global, true)); + + const { tokenAddress, chain } = selectToken(global, tokenSlug); + let result: ApiCheckTransactionDraftResult | undefined; + + if (nftAddresses?.length) { + result = await callApi('checkNftTransferDraft', { + accountId: global.currentAccountId!, + nftAddresses, + toAddress, + comment, + }); + } else { + result = await callApi('checkTransactionDraft', chain, { + accountId: global.currentAccountId!, + tokenAddress, + toAddress, + amount, + data: binPayload ?? comment, + shouldEncrypt, + stateInit, + isBase64Data: Boolean(binPayload), + isGaslessWithStars, + }); + } + + global = getGlobal(); + global = updateCurrentTransferLoading(global, false); + + if (result) { + global = updateCurrentTransferByCheckResult(global, result); + } + + if (!result || 'error' in result) { + setGlobal(global); + + if (result?.error === ApiTransactionDraftError.InsufficientBalance && !nftAddresses?.length) { + actions.showDialog({ message: 'The network fee has slightly changed, try sending again.' }); + } else { + actions.showError({ error: result?.error }); + } + + return; + } + + setGlobal(updateCurrentTransfer(global, { + state: TransferState.Confirm, + error: undefined, + toAddress, + chain, + resolvedAddress: result.resolvedAddress, + amount, + comment, + shouldEncrypt, + tokenSlug, + isToNewAddress: result.isToAddressNew, + withDiesel, + isGaslessWithStars, + })); +}); + +addActionHandler('fetchTransferFee', async (global, actions, payload) => { + global = updateCurrentTransfer(global, { isLoading: true, error: undefined }); + setGlobal(global); + + const { + tokenSlug, toAddress, comment, shouldEncrypt, binPayload, stateInit, isGaslessWithStars, + } = payload; + + const { tokenAddress, chain } = selectToken(global, tokenSlug); + + const result = await callApi('checkTransactionDraft', chain, { + accountId: global.currentAccountId!, + toAddress, + data: binPayload ?? comment, + tokenAddress, + shouldEncrypt, + isBase64Data: Boolean(binPayload), + stateInit, + isGaslessWithStars, + }); + + global = getGlobal(); + + if (tokenSlug !== global.currentTransfer.tokenSlug) { + // For cases when the user switches the token before the result arrives + return; + } + + global = updateCurrentTransfer(global, { isLoading: false }); + if (result) { + global = updateCurrentTransferByCheckResult(global, result); + } + setGlobal(global); + + if (result?.error && result.error !== ApiTransactionDraftError.InsufficientBalance) { + actions.showError({ error: result.error }); + } +}); + +addActionHandler('fetchNftFee', async (global, actions, payload) => { + const { toAddress, nftAddresses, comment } = payload; + + global = updateCurrentTransfer(global, { isLoading: true, error: undefined }); + setGlobal(global); + + const result = await callApi('checkNftTransferDraft', { + accountId: global.currentAccountId!, + nftAddresses, + toAddress, + comment, + }); + + global = getGlobal(); + global = updateCurrentTransfer(global, { isLoading: false }); + + if (result?.fee) { + global = updateCurrentTransfer(global, { fee: result.fee }); + } + + setGlobal(global); + + if (result?.error) { + actions.showError({ + error: result?.error === ApiTransactionDraftError.InsufficientBalance + ? 'Insufficient TON for fee.' + : result.error, + }); + } +}); + +addActionHandler('submitTransferPassword', async (global, actions, { password }) => { + const { + resolvedAddress, + comment, + amount, + promiseId, + tokenSlug, + fee, + shouldEncrypt, + binPayload, + nfts, + withDiesel, + diesel, + stateInit, + isGaslessWithStars, + } = global.currentTransfer; + + if (!(await callApi('verifyPassword', password))) { + setGlobal(updateCurrentTransfer(getGlobal(), { error: 'Wrong password, please try again.' })); + + return; + } + + global = getGlobal(); + global = updateCurrentTransfer(getGlobal(), { + isLoading: true, + error: undefined, + }); + if (IS_CAPACITOR) { + global = setIsPinAccepted(global); + } + setGlobal(global); + + if (IS_CAPACITOR) { + await vibrateOnSuccess(true); + } + + if (promiseId) { + if (IS_CAPACITOR) { + global = getGlobal(); + global = setIsPinAccepted(global); + setGlobal(global); + } + + void callApi('confirmDappRequest', promiseId, password); + return; + } + + let result: ApiSubmitTransferResult | ApiSubmitMultiTransferResult | undefined; + + if (nfts?.length) { + const chunks = []; + for (let i = 0; i < nfts.length; i += NFT_BATCH_SIZE) { + chunks.push(nfts.slice(i, i + NFT_BATCH_SIZE)); + } + + for (const chunk of chunks) { + const addresses = chunk.map(({ address }) => address); + const batchResult = await callApi( + 'submitNftTransfers', + global.currentAccountId!, + password, + addresses, + resolvedAddress!, + comment, + chunk, + fee, + ); + + global = getGlobal(); + global = updateCurrentTransfer(global, { + sentNftsCount: (global.currentTransfer.sentNftsCount || 0) + chunk.length, + }); + setGlobal(global); + // TODO - process all responses from the API + result = batchResult; + } + } else { + const { tokenAddress, chain } = selectToken(global, tokenSlug); + + const options: ApiSubmitTransferOptions = { + accountId: global.currentAccountId!, + password, + toAddress: resolvedAddress!, + amount: amount!, + comment: binPayload ?? comment, + tokenAddress, + fee, + shouldEncrypt, + isBase64Data: Boolean(binPayload), + withDiesel, + dieselAmount: diesel?.tokenAmount, + stateInit, + isGaslessWithStars, + }; + + result = await callApi('submitTransfer', chain, options); + } + + global = getGlobal(); + global = updateCurrentTransfer(global, { + isLoading: false, + }); + setGlobal(global); + + if (!result || 'error' in result) { + if (IS_CAPACITOR) { + global = getGlobal(); + global = clearIsPinAccepted(global); + setGlobal(global); + void vibrateOnError(); + } + actions.showError({ error: result?.error }); + } else if (IS_CAPACITOR) { + void vibrateOnSuccess(); + } +}); + +addActionHandler('submitTransferHardware', async (global, actions) => { + const { + toAddress, + resolvedAddress, + comment, + amount, + promiseId, + tokenSlug, + fee, + rawPayload, + parsedPayload, + stateInit, + nfts, + } = global.currentTransfer; + + const accountId = global.currentAccountId!; + + setGlobal(updateCurrentTransfer(getGlobal(), { + isLoading: true, + error: undefined, + state: TransferState.ConfirmHardware, + })); + + const ledgerApi = await import('../../../util/ledger'); + + if (promiseId) { + const message: ApiDappTransfer = { + toAddress: toAddress!, + amount: amount!, + rawPayload, + payload: parsedPayload, + stateInit, + }; + + try { + const signedMessage = await ledgerApi.signLedgerTransactions(accountId, [message]); + void callApi('confirmDappRequest', promiseId, signedMessage); + } catch (err) { + if (err instanceof ApiUserRejectsError) { + setGlobal(updateCurrentTransfer(getGlobal(), { + isLoading: false, + error: 'Canceled by the user', + })); + } else { + void callApi('cancelDappRequest', promiseId, 'Unknown error.'); + } + } + return; + } + + let result: string | { error: ApiTransactionError } | undefined; + let error: string | undefined; + + if (nfts?.length) { + for (const nft of nfts) { + const currentResult = await ledgerApi.submitLedgerNftTransfer({ + accountId: global.currentAccountId!, + nftAddress: nft.address, + password: '', + toAddress: resolvedAddress!, + comment, + nft, + fee, + }); + + global = getGlobal(); + global = updateCurrentTransfer(global, { + sentNftsCount: (global.currentTransfer.sentNftsCount || 0) + 1, + }); + setGlobal(global); + result = currentResult; + } + } else { + const tokenAddress = selectTokenAddress(global, tokenSlug); + const options = { + accountId: global.currentAccountId!, + password: '', + toAddress: resolvedAddress!, + amount: amount!, + comment, + tokenAddress, + fee, + }; + + try { + result = await ledgerApi.submitLedgerTransfer(options, tokenSlug); + } catch (err: any) { + if (err instanceof ApiHardwareBlindSigningNotEnabled) { + error = '$hardware_blind_sign_not_enabled'; + } + } + } + + if (!error && result === undefined) { + error = 'Declined'; + } else if (typeof result === 'object' && 'error' in result) { + actions.showError({ + error: result.error, + }); + } + + setGlobal(updateCurrentTransfer(getGlobal(), { + isLoading: false, + error, + })); +}); + +addActionHandler('cancelTransfer', (global, actions, { shouldReset } = {}) => { + const { promiseId, tokenSlug } = global.currentTransfer; + + if (shouldReset) { + if (promiseId) { + void callApi('cancelDappRequest', promiseId, 'Canceled by the user'); + } + + global = clearCurrentTransfer(global); + global = updateCurrentTransfer(global, { tokenSlug }); + + setGlobal(global); + return; + } + + if (IS_CAPACITOR) { + global = clearIsPinAccepted(global); + } + global = updateCurrentTransfer(global, { state: TransferState.None }); + setGlobal(global); +}); + +addActionHandler('fetchTransferDieselState', async (global, actions, { tokenSlug }) => { + const tokenAddress = selectTokenAddress(global, tokenSlug); + if (!tokenAddress) return; + + const diesel = await callApi('fetchEstimateDiesel', global.currentAccountId!, tokenAddress); + if (!diesel) return; + + global = getGlobal(); + const accountState = selectAccountState(global, global.currentAccountId!); + global = updateCurrentTransfer(global, { diesel }); + if (accountState?.isDieselAuthorizationStarted && diesel.status !== 'not-authorized') { + global = updateAccountState(global, global.currentAccountId!, { isDieselAuthorizationStarted: undefined }); + } + setGlobal(global); +}); diff --git a/src/global/actions/api/wallet.ts b/src/global/actions/api/wallet.ts index dccfe180..6ddd7bad 100644 --- a/src/global/actions/api/wallet.ts +++ b/src/global/actions/api/wallet.ts @@ -1,554 +1,42 @@ -import type { ApiCheckTransactionDraftResult, ApiSubmitMultiTransferResult } from '../../../api/chains/ton/types'; -import type { ApiSubmitTransferOptions, ApiSubmitTransferResult } from '../../../api/methods/types'; import type { ApiActivity, - ApiDappTransfer, ApiSwapAsset, ApiToken, ApiTokenWithPrice, - ApiTransactionError, } from '../../../api/types'; -import { ApiTransactionDraftError } from '../../../api/types'; -import { ActiveTab, TransferState } from '../../types'; -import { IS_CAPACITOR, NFT_BATCH_SIZE } from '../../../config'; -import { vibrateOnError, vibrateOnSuccess } from '../../../util/capacitor'; import { compareActivities } from '../../../util/compareActivities'; -import { fromDecimal, toDecimal } from '../../../util/decimals'; import { buildCollectionByKey, extractKey, findLast, unique, } from '../../../util/iteratees'; -import { callActionInMain, callActionInNative } from '../../../util/multitab'; import { getIsTransactionWithPoisoning } from '../../../util/poisoningHash'; import { onTickEnd, pause } from '../../../util/schedulers'; import { buildUserToken } from '../../../util/tokens'; -import { IS_DELEGATED_BOTTOM_SHEET, IS_DELEGATING_BOTTOM_SHEET } from '../../../util/windowEnvironment'; import { callApi } from '../../../api'; -import { ApiHardwareBlindSigningNotEnabled, ApiUserRejectsError } from '../../../api/errors'; import { getIsSwapId, getIsTinyOrScamTransaction, getIsTxIdLocal } from '../../helpers'; import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { changeBalance, - clearCurrentTransfer, - clearIsPinAccepted, - setIsPinAccepted, updateAccountState, updateActivitiesIsHistoryEndReached, updateActivitiesIsLoading, updateCurrentAccountSettings, updateCurrentAccountState, updateCurrentSignature, - updateCurrentTransfer, - updateCurrentTransferByCheckResult, - updateSendingLoading, updateSettings, } from '../../reducers'; import { updateTokenInfo } from '../../reducers/tokens'; import { - selectAccount, selectAccountState, selectAccountTxTokenSlugs, selectCurrentAccountSettings, selectCurrentAccountState, selectLastMainTxTimestamp, selectToken, - selectTokenAddress, } from '../../selectors'; const IMPORT_TOKEN_PAUSE = 250; -addActionHandler('startTransfer', (global, actions, payload) => { - const isOpen = global.currentTransfer.state !== TransferState.None; - if (IS_DELEGATED_BOTTOM_SHEET && !isOpen) { - callActionInMain('startTransfer', payload); - return; - } - - const { isPortrait, ...rest } = payload ?? {}; - - setGlobal(updateCurrentTransfer(global, { - state: isPortrait ? TransferState.Initial : TransferState.None, - error: undefined, - ...rest, - })); - - if (!isPortrait) { - actions.setLandscapeActionsActiveTabIndex({ index: ActiveTab.Transfer }); - } -}); - -addActionHandler('changeTransferToken', (global, actions, { tokenSlug }) => { - const { amount, tokenSlug: currentTokenSlug } = global.currentTransfer; - const currentToken = currentTokenSlug ? global.tokenInfo.bySlug[currentTokenSlug] : undefined; - const newToken = global.tokenInfo.bySlug[tokenSlug]; - - if (amount && currentToken?.decimals !== newToken?.decimals) { - global = updateCurrentTransfer(global, { - amount: fromDecimal(toDecimal(amount, currentToken?.decimals), newToken?.decimals), - }); - } - - setGlobal(updateCurrentTransfer(global, { - tokenSlug, - fee: undefined, - realFee: undefined, - diesel: undefined, - })); -}); - -addActionHandler('setTransferScreen', (global, actions, payload) => { - const { state } = payload; - - setGlobal(updateCurrentTransfer(global, { state })); -}); - -addActionHandler('setTransferAmount', (global, actions, { amount }) => { - setGlobal( - updateCurrentTransfer(global, { - amount, - }), - ); -}); - -addActionHandler('setTransferToAddress', (global, actions, { toAddress }) => { - setGlobal( - updateCurrentTransfer(global, { - toAddress, - }), - ); -}); - -addActionHandler('setTransferComment', (global, actions, { comment }) => { - setGlobal( - updateCurrentTransfer(global, { - comment, - }), - ); -}); - -addActionHandler('setTransferShouldEncrypt', (global, actions, { shouldEncrypt }) => { - setGlobal( - updateCurrentTransfer(global, { - shouldEncrypt, - }), - ); -}); - -addActionHandler('submitTransferInitial', async (global, actions, payload) => { - if (IS_DELEGATING_BOTTOM_SHEET) { - callActionInNative('submitTransferInitial', payload); - return; - } - - const { - tokenSlug, - toAddress, - amount, - comment, - shouldEncrypt, - nftAddresses, - withDiesel, - stateInit, - isGaslessWithStars, - binPayload, - } = payload; - - setGlobal(updateSendingLoading(global, true)); - - const { tokenAddress, chain } = selectToken(global, tokenSlug); - let result: ApiCheckTransactionDraftResult | undefined; - - if (nftAddresses?.length) { - result = await callApi('checkNftTransferDraft', { - accountId: global.currentAccountId!, - nftAddresses, - toAddress, - comment, - }); - } else { - result = await callApi('checkTransactionDraft', chain, { - accountId: global.currentAccountId!, - tokenAddress, - toAddress, - amount, - data: binPayload ?? comment, - shouldEncrypt, - stateInit, - isBase64Data: Boolean(binPayload), - isGaslessWithStars, - }); - } - - global = getGlobal(); - global = updateSendingLoading(global, false); - - if (result) { - global = updateCurrentTransferByCheckResult(global, result); - } - - if (!result || 'error' in result) { - setGlobal(global); - - if (result?.error === ApiTransactionDraftError.InsufficientBalance && !nftAddresses?.length) { - actions.showDialog({ message: 'The network fee has slightly changed, try sending again.' }); - } else { - actions.showError({ error: result?.error }); - } - - return; - } - - setGlobal(updateCurrentTransfer(global, { - state: TransferState.Confirm, - error: undefined, - toAddress, - chain, - resolvedAddress: result.resolvedAddress, - amount, - comment, - shouldEncrypt, - tokenSlug, - isToNewAddress: result.isToAddressNew, - withDiesel, - isGaslessWithStars, - })); -}); - -addActionHandler('fetchFee', async (global, actions, payload) => { - global = updateCurrentTransfer(global, { isLoading: true, error: undefined }); - setGlobal(global); - - const { - tokenSlug, toAddress, comment, shouldEncrypt, binPayload, stateInit, isGaslessWithStars, - } = payload; - - const { tokenAddress, chain } = selectToken(global, tokenSlug); - - const result = await callApi('checkTransactionDraft', chain, { - accountId: global.currentAccountId!, - toAddress, - data: binPayload ?? comment, - tokenAddress, - shouldEncrypt, - isBase64Data: Boolean(binPayload), - stateInit, - isGaslessWithStars, - }); - - global = getGlobal(); - - if (tokenSlug !== global.currentTransfer.tokenSlug) { - // For cases when the user switches the token before the result arrives - return; - } - - global = updateCurrentTransfer(global, { isLoading: false }); - if (result) { - global = updateCurrentTransferByCheckResult(global, result); - } - setGlobal(global); - - if (result?.error && result.error !== ApiTransactionDraftError.InsufficientBalance) { - actions.showError({ error: result.error }); - } -}); - -addActionHandler('fetchNftFee', async (global, actions, payload) => { - const { toAddress, nftAddresses, comment } = payload; - - global = updateCurrentTransfer(global, { isLoading: true, error: undefined }); - setGlobal(global); - - const result = await callApi('checkNftTransferDraft', { - accountId: global.currentAccountId!, - nftAddresses, - toAddress, - comment, - }); - - global = getGlobal(); - global = updateCurrentTransfer(global, { isLoading: false }); - - if (result?.fee) { - global = updateCurrentTransfer(global, { fee: result.fee }); - } - - setGlobal(global); - - if (result?.error) { - actions.showError({ - error: result?.error === ApiTransactionDraftError.InsufficientBalance - ? 'Insufficient TON for fee.' - : result.error, - }); - } -}); - -addActionHandler('submitTransferConfirm', (global, actions) => { - const accountId = global.currentAccountId!; - const account = selectAccount(global, accountId)!; - - if (account.isHardware) { - actions.resetHardwareWalletConnect(); - global = updateCurrentTransfer(getGlobal(), { state: TransferState.ConnectHardware }); - } else { - global = updateCurrentTransfer(global, { state: TransferState.Password }); - } - - setGlobal(global); -}); - -addActionHandler('submitTransferPassword', async (global, actions, { password }) => { - const { - resolvedAddress, - comment, - amount, - promiseId, - tokenSlug, - fee, - shouldEncrypt, - binPayload, - nfts, - withDiesel, - diesel, - stateInit, - isGaslessWithStars, - } = global.currentTransfer; - - if (!(await callApi('verifyPassword', password))) { - setGlobal(updateCurrentTransfer(getGlobal(), { error: 'Wrong password, please try again.' })); - - return; - } - - global = getGlobal(); - global = updateCurrentTransfer(getGlobal(), { - isLoading: true, - error: undefined, - }); - if (IS_CAPACITOR) { - global = setIsPinAccepted(global); - } - setGlobal(global); - - if (IS_CAPACITOR) { - await vibrateOnSuccess(true); - } - - if (promiseId) { - if (IS_CAPACITOR) { - global = getGlobal(); - global = setIsPinAccepted(global); - setGlobal(global); - } - - void callApi('confirmDappRequest', promiseId, password); - return; - } - - let result: ApiSubmitTransferResult | ApiSubmitMultiTransferResult | undefined; - - if (nfts?.length) { - const chunks = []; - for (let i = 0; i < nfts.length; i += NFT_BATCH_SIZE) { - chunks.push(nfts.slice(i, i + NFT_BATCH_SIZE)); - } - - for (const chunk of chunks) { - const addresses = chunk.map(({ address }) => address); - const batchResult = await callApi( - 'submitNftTransfers', - global.currentAccountId!, - password, - addresses, - resolvedAddress!, - comment, - chunk, - fee, - ); - - global = getGlobal(); - global = updateCurrentTransfer(global, { - sentNftsCount: (global.currentTransfer.sentNftsCount || 0) + chunk.length, - }); - setGlobal(global); - // TODO - process all responses from the API - result = batchResult; - } - } else { - const { tokenAddress, chain } = selectToken(global, tokenSlug); - - const options: ApiSubmitTransferOptions = { - accountId: global.currentAccountId!, - password, - toAddress: resolvedAddress!, - amount: amount!, - comment: binPayload ?? comment, - tokenAddress, - fee, - shouldEncrypt, - isBase64Data: Boolean(binPayload), - withDiesel, - dieselAmount: diesel?.tokenAmount, - stateInit, - isGaslessWithStars, - }; - - result = await callApi('submitTransfer', chain, options); - } - - global = getGlobal(); - global = updateCurrentTransfer(global, { - isLoading: false, - }); - setGlobal(global); - - if (!result || 'error' in result) { - if (IS_CAPACITOR) { - global = getGlobal(); - global = clearIsPinAccepted(global); - setGlobal(global); - void vibrateOnError(); - } - actions.showError({ error: result?.error }); - } else if (IS_CAPACITOR) { - void vibrateOnSuccess(); - } -}); - -addActionHandler('submitTransferHardware', async (global, actions) => { - const { - toAddress, - resolvedAddress, - comment, - amount, - promiseId, - tokenSlug, - fee, - rawPayload, - parsedPayload, - stateInit, - nfts, - } = global.currentTransfer; - - const accountId = global.currentAccountId!; - - setGlobal(updateCurrentTransfer(getGlobal(), { - isLoading: true, - error: undefined, - state: TransferState.ConfirmHardware, - })); - - const ledgerApi = await import('../../../util/ledger'); - - if (promiseId) { - const message: ApiDappTransfer = { - toAddress: toAddress!, - amount: amount!, - rawPayload, - payload: parsedPayload, - stateInit, - }; - - try { - const signedMessage = await ledgerApi.signLedgerTransactions(accountId, [message]); - void callApi('confirmDappRequest', promiseId, signedMessage); - } catch (err) { - if (err instanceof ApiUserRejectsError) { - setGlobal(updateCurrentTransfer(getGlobal(), { - isLoading: false, - error: 'Canceled by the user', - })); - } else { - void callApi('cancelDappRequest', promiseId, 'Unknown error.'); - } - } - return; - } - - let result: string | { error: ApiTransactionError } | undefined; - let error: string | undefined; - - if (nfts?.length) { - for (const nft of nfts) { - const currentResult = await ledgerApi.submitLedgerNftTransfer({ - accountId: global.currentAccountId!, - nftAddress: nft.address, - password: '', - toAddress: resolvedAddress!, - comment, - nft, - fee, - }); - - global = getGlobal(); - global = updateCurrentTransfer(global, { - sentNftsCount: (global.currentTransfer.sentNftsCount || 0) + 1, - }); - setGlobal(global); - result = currentResult; - } - } else { - const tokenAddress = selectTokenAddress(global, tokenSlug); - const options = { - accountId: global.currentAccountId!, - password: '', - toAddress: resolvedAddress!, - amount: amount!, - comment, - tokenAddress, - fee, - }; - - try { - result = await ledgerApi.submitLedgerTransfer(options, tokenSlug); - } catch (err: any) { - if (err instanceof ApiHardwareBlindSigningNotEnabled) { - error = '$hardware_blind_sign_not_enabled'; - } - } - } - - if (!error && result === undefined) { - error = 'Declined'; - } else if (typeof result === 'object' && 'error' in result) { - actions.showError({ - error: result.error, - }); - } - - setGlobal(updateCurrentTransfer(getGlobal(), { - isLoading: false, - error, - })); -}); - -addActionHandler('clearTransferError', (global) => { - setGlobal(updateCurrentTransfer(global, { error: undefined })); -}); - -addActionHandler('cancelTransfer', (global, actions, { shouldReset } = {}) => { - const { promiseId, tokenSlug } = global.currentTransfer; - - if (shouldReset) { - if (promiseId) { - void callApi('cancelDappRequest', promiseId, 'Canceled by the user'); - } - - global = clearCurrentTransfer(global); - global = updateCurrentTransfer(global, { tokenSlug }); - - setGlobal(global); - return; - } - - if (IS_CAPACITOR) { - global = clearIsPinAccepted(global); - } - global = updateCurrentTransfer(global, { state: TransferState.None }); - setGlobal(global); -}); - addActionHandler('fetchTokenTransactions', async (global, actions, { limit, slug, shouldLoadWithBudget }) => { global = updateActivitiesIsLoading(global, true); setGlobal(global); @@ -930,22 +418,6 @@ addActionHandler('addSwapToken', (global, actions, { token }) => { }); }); -addActionHandler('fetchDieselState', async (global, actions, { tokenSlug }) => { - const tokenAddress = selectTokenAddress(global, tokenSlug); - if (!tokenAddress) return; - - const diesel = await callApi('fetchEstimateDiesel', global.currentAccountId!, tokenAddress); - if (!diesel) return; - - global = getGlobal(); - const accountState = selectAccountState(global, global.currentAccountId!); - global = updateCurrentTransfer(global, { diesel }); - if (accountState?.isDieselAuthorizationStarted && diesel.status !== 'not-authorized') { - global = updateAccountState(global, global.currentAccountId!, { isDieselAuthorizationStarted: undefined }); - } - setGlobal(global); -}); - addActionHandler('apiUpdateWalletVersions', (global, actions, params) => { const { accountId, versions, currentVersion } = params; global = { diff --git a/src/global/actions/apiUpdates/activities.ts b/src/global/actions/apiUpdates/activities.ts index 483d3ff9..aca3cd77 100644 --- a/src/global/actions/apiUpdates/activities.ts +++ b/src/global/actions/apiUpdates/activities.ts @@ -49,7 +49,7 @@ addActionHandler('apiUpdate', (global, actions, update) => { } if (getIsTonToken(transaction.slug)) { - actions.fetchDieselState({ tokenSlug: transaction.slug }); + actions.fetchTransferDieselState({ tokenSlug: transaction.slug }); } } diff --git a/src/global/actions/index.ts b/src/global/actions/index.ts index 31396e9a..00ce9548 100644 --- a/src/global/actions/index.ts +++ b/src/global/actions/index.ts @@ -5,6 +5,7 @@ import './api/staking'; import './api/dapps'; import './api/swap'; import './api/tokens'; +import './api/transfer'; import './api/nfts'; import './api/vesting'; import './api/notifications'; @@ -19,5 +20,6 @@ import './ui/swap'; import './ui/nfts'; import './ui/vesting'; import './ui/tokens'; +import './ui/transfer'; import './ui/shared'; import './ui/notifications'; diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index fdf9f5bd..beb11ca6 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -89,22 +89,24 @@ addActionHandler('showActivityInfo', (global, actions, { id }) => { }); addActionHandler('showAnyAccountTx', async (global, actions, { txId, accountId, network }) => { - if (accountId === global.currentAccountId) { - actions.showActivityInfo({ id: txId }); + if (IS_DELEGATED_BOTTOM_SHEET) { + callActionInMain('showAnyAccountTx', { txId, accountId, network }); return; } await switchAccount(global, accountId, network); + actions.showActivityInfo({ id: txId }); }); addActionHandler('showAnyAccountTokenActivity', async (global, actions, { slug, accountId, network }) => { - if (accountId === global.currentAccountId) { - actions.showTokenActivity({ slug }); + if (IS_DELEGATED_BOTTOM_SHEET) { + callActionInMain('showAnyAccountTokenActivity', { slug, accountId, network }); return; } await switchAccount(global, accountId, network); + actions.showTokenActivity({ slug }); }); diff --git a/src/global/actions/ui/notifications.ts b/src/global/actions/ui/notifications.ts index 79ba50b9..604006dc 100644 --- a/src/global/actions/ui/notifications.ts +++ b/src/global/actions/ui/notifications.ts @@ -1,137 +1,11 @@ -import type { - SubscribeNotificationsProps, - SubscribeNotificationsResult, - UnsubscribeNotificationsProps, -} from '../../../api/methods'; - import { MAX_PUSH_NOTIFICATIONS_ACCOUNT_COUNT } from '../../../config'; -import { createAbortableFunction } from '../../../util/createAbortableFunction'; import { callApi } from '../../../api'; import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { - createNotificationAccount, deleteAllNotificationAccounts, - deleteNotificationAccount, - updateNotificationAccount, } from '../../reducers/notifications'; import { selectAccounts } from '../../selectors'; -import { selectNotificationTonAddresses } from '../../selectors/notifications'; - -const abortableSubscribeNotifications = createAbortableFunction( - { aborted: true }, - (request: SubscribeNotificationsProps) => { - return callApi('subscribeNotifications', request); - }, -); - -const abortableUnsubscribeNotifications = createAbortableFunction( - { aborted: true }, - (request: UnsubscribeNotificationsProps) => { - return callApi('unsubscribeNotifications', request); - }, -); - -addActionHandler('toggleNotifications', async (global, actions, { isEnabled }) => { - const { - enabledAccounts = {}, userToken = '', platform, isAvailable, - } = global.pushNotifications; - - if (!isAvailable) { - return; - } - - let accountIds = Object.keys(enabledAccounts); - - if (isEnabled) { - accountIds = Object.keys(selectAccounts(global) || {}) - .slice(0, MAX_PUSH_NOTIFICATIONS_ACCOUNT_COUNT); - for (const newAccountId of accountIds) { - global = createNotificationAccount(global, newAccountId, {}); - } - } else { - global = deleteAllNotificationAccounts(global); - } - - setGlobal(global); - if (!accountIds.length) { - return; - } - - const props = { userToken, addresses: selectNotificationTonAddresses(global, accountIds), platform: platform! }; - const result = isEnabled - ? await abortableSubscribeNotifications(props) - : await abortableUnsubscribeNotifications(props); - - if (result && 'aborted' in result) { - return; - } - - global = getGlobal(); - - if (!result || !('ok' in result)) { - if (isEnabled) { - global = deleteAllNotificationAccounts(global); - } else { - for (const accountId of accountIds) { - global = createNotificationAccount(global, accountId, enabledAccounts[accountId]); - } - } - setGlobal(global); - return; - } - - if (isEnabled && 'addressKeys' in result) { - const addressKeys = (result as SubscribeNotificationsResult).addressKeys; - for (const accountId of accountIds) { - const address = global.accounts!.byId[accountId].addressByChain.ton; - - if (addressKeys[address]) { - global = createNotificationAccount(global, accountId, addressKeys[address]); - } - } - } - - setGlobal(global); -}); - -addActionHandler('createNotificationAccount', async (global, actions, { accountId, withAbort }) => { - const { userToken, platform } = global.pushNotifications; - - if (!userToken || !platform) { - return; - } - - setGlobal(createNotificationAccount( - global, - accountId, - {}, - )); - - const props = { userToken, platform, addresses: selectNotificationTonAddresses(global, [accountId]) }; - const result = withAbort - ? await abortableSubscribeNotifications(props) - : await callApi('subscribeNotifications', props); - - if (result && 'aborted' in result) { - return; - } - - global = getGlobal(); - - if (!result || !('ok' in result)) { - setGlobal(deleteNotificationAccount( - global, - accountId, - )); - return; - } - - setGlobal(updateNotificationAccount( - global, - accountId, - result.addressKeys[global.accounts!.byId[accountId].addressByChain.ton], - )); -}); +import { selectNotificationTonAddressesSlow } from '../../selectors/notifications'; addActionHandler('tryAddNotificationAccount', (global, actions, { accountId }) => { if ( @@ -153,39 +27,6 @@ addActionHandler('renameNotificationAccount', (global, actions, { accountId }) = } }); -addActionHandler('deleteNotificationAccount', async (global, actions, { accountId, withAbort }) => { - const { userToken, enabledAccounts } = global.pushNotifications; - const pushNotificationsAccount = enabledAccounts[accountId]!; - - if (!userToken) { - return; - } - - setGlobal(deleteNotificationAccount(global, accountId)); - - const props = { userToken, addresses: selectNotificationTonAddresses(global, [accountId]) }; - const result = withAbort - ? await abortableUnsubscribeNotifications(props) - : await callApi('unsubscribeNotifications', props); - - if (result && 'aborted' in result) { - return; - } - - global = getGlobal(); - - if (!result || !('ok' in result)) { - setGlobal(createNotificationAccount( - global, - accountId, - pushNotificationsAccount, - )); - return; - } - - setGlobal(deleteNotificationAccount(global, accountId)); -}); - addActionHandler('toggleNotificationAccount', (global, actions, { accountId }) => { const { enabledAccounts, userToken, platform, @@ -226,7 +67,7 @@ addActionHandler('deleteAllNotificationAccounts', async (global, actions, props) 'unsubscribeNotifications', { userToken, - addresses: selectNotificationTonAddresses(global, accountIds), + addresses: selectNotificationTonAddressesSlow(global, accountIds), }, ); diff --git a/src/global/actions/ui/transfer.ts b/src/global/actions/ui/transfer.ts new file mode 100644 index 00000000..55838031 --- /dev/null +++ b/src/global/actions/ui/transfer.ts @@ -0,0 +1,103 @@ +import { ActiveTab, TransferState } from '../../types'; + +import { fromDecimal, toDecimal } from '../../../util/decimals'; +import { callActionInMain } from '../../../util/multitab'; +import { IS_DELEGATED_BOTTOM_SHEET } from '../../../util/windowEnvironment'; +import { addActionHandler, getGlobal, setGlobal } from '../../index'; +import { updateCurrentTransfer } from '../../reducers'; +import { selectAccount } from '../../selectors'; + +addActionHandler('startTransfer', (global, actions, payload) => { + const isOpen = global.currentTransfer.state !== TransferState.None; + if (IS_DELEGATED_BOTTOM_SHEET && !isOpen) { + callActionInMain('startTransfer', payload); + return; + } + + const { isPortrait, ...rest } = payload ?? {}; + + setGlobal(updateCurrentTransfer(global, { + state: isPortrait ? TransferState.Initial : TransferState.None, + error: undefined, + ...rest, + })); + + if (!isPortrait) { + actions.setLandscapeActionsActiveTabIndex({ index: ActiveTab.Transfer }); + } +}); + +addActionHandler('changeTransferToken', (global, actions, { tokenSlug }) => { + const { amount, tokenSlug: currentTokenSlug } = global.currentTransfer; + const currentToken = currentTokenSlug ? global.tokenInfo.bySlug[currentTokenSlug] : undefined; + const newToken = global.tokenInfo.bySlug[tokenSlug]; + + if (amount && currentToken?.decimals !== newToken?.decimals) { + global = updateCurrentTransfer(global, { + amount: fromDecimal(toDecimal(amount, currentToken?.decimals), newToken?.decimals), + }); + } + + setGlobal(updateCurrentTransfer(global, { + tokenSlug, + fee: undefined, + realFee: undefined, + diesel: undefined, + })); +}); + +addActionHandler('setTransferScreen', (global, actions, payload) => { + const { state } = payload; + + setGlobal(updateCurrentTransfer(global, { state })); +}); + +addActionHandler('setTransferAmount', (global, actions, { amount }) => { + setGlobal( + updateCurrentTransfer(global, { + amount, + }), + ); +}); + +addActionHandler('setTransferToAddress', (global, actions, { toAddress }) => { + setGlobal( + updateCurrentTransfer(global, { + toAddress, + }), + ); +}); + +addActionHandler('setTransferComment', (global, actions, { comment }) => { + setGlobal( + updateCurrentTransfer(global, { + comment, + }), + ); +}); + +addActionHandler('setTransferShouldEncrypt', (global, actions, { shouldEncrypt }) => { + setGlobal( + updateCurrentTransfer(global, { + shouldEncrypt, + }), + ); +}); + +addActionHandler('submitTransferConfirm', (global, actions) => { + const accountId = global.currentAccountId!; + const account = selectAccount(global, accountId)!; + + if (account.isHardware) { + actions.resetHardwareWalletConnect(); + global = updateCurrentTransfer(getGlobal(), { state: TransferState.ConnectHardware }); + } else { + global = updateCurrentTransfer(global, { state: TransferState.Password }); + } + + setGlobal(global); +}); + +addActionHandler('clearTransferError', (global) => { + setGlobal(updateCurrentTransfer(global, { error: undefined })); +}); diff --git a/src/global/reducers/index.ts b/src/global/reducers/index.ts index a3d0e64a..11e9f14d 100644 --- a/src/global/reducers/index.ts +++ b/src/global/reducers/index.ts @@ -5,4 +5,5 @@ export * from './dapp'; export * from './activities'; export * from './nfts'; export * from './swap'; +export * from './transfer'; export * from './vesting'; diff --git a/src/global/reducers/misc.ts b/src/global/reducers/misc.ts index 03bfe0b6..ce51603d 100644 --- a/src/global/reducers/misc.ts +++ b/src/global/reducers/misc.ts @@ -153,16 +153,6 @@ export function changeBalance(global: GlobalState, accountId: string, slug: stri }); } -export function updateSendingLoading(global: GlobalState, isLoading: boolean): GlobalState { - return { - ...global, - currentTransfer: { - ...global.currentTransfer, - isLoading, - }, - }; -} - export function updateTokens( global: GlobalState, partial: Record, diff --git a/src/global/reducers/notifications.ts b/src/global/reducers/notifications.ts index 6362f55b..a676422d 100644 --- a/src/global/reducers/notifications.ts +++ b/src/global/reducers/notifications.ts @@ -1,4 +1,4 @@ -import type { NotificationsAccountValue } from '../../api/methods'; +import type { ApiNotificationsAccountValue } from '../../api/types'; import type { GlobalState } from '../types'; export function deleteNotificationAccount( @@ -33,7 +33,7 @@ export function deleteAllNotificationAccounts( export function createNotificationAccount( global: GlobalState, accountId: string, - value: Partial = {}, + value: Partial = {}, ): GlobalState { const currentEnabledAccounts = global.pushNotifications.enabledAccounts; @@ -51,7 +51,7 @@ export function createNotificationAccount( export function updateNotificationAccount( global: GlobalState, accountId: string, - value: NotificationsAccountValue, + value: ApiNotificationsAccountValue, ): GlobalState { const newEnabledAccounts = global.pushNotifications.enabledAccounts; newEnabledAccounts[accountId] = { ...newEnabledAccounts[accountId], ...value }; diff --git a/src/global/reducers/transfer.ts b/src/global/reducers/transfer.ts new file mode 100644 index 00000000..38224d41 --- /dev/null +++ b/src/global/reducers/transfer.ts @@ -0,0 +1,55 @@ +import type { ApiCheckTransactionDraftResult } from '../../api/chains/ton/types'; +import type { GlobalState } from '../types'; + +import { pick } from '../../util/iteratees'; +import { INITIAL_STATE } from '../initialState'; +import { selectCurrentTransferMaxAmount } from '../selectors'; + +export function updateCurrentTransferByCheckResult(global: GlobalState, result: ApiCheckTransactionDraftResult) { + const nextGlobal = updateCurrentTransfer(global, { + toAddressName: result.addressName, + ...pick(result, ['fee', 'realFee', 'isScam', 'isMemoRequired', 'diesel']), + }); + return preserveMaxTransferAmount(global, nextGlobal); +} + +export function updateCurrentTransfer(global: GlobalState, update: Partial) { + return { + ...global, + currentTransfer: { + ...global.currentTransfer, + ...update, + }, + }; +} + +export function clearCurrentTransfer(global: GlobalState) { + return { + ...global, + currentTransfer: INITIAL_STATE.currentTransfer, + }; +} + +/** + * Preserves the maximum transfer amount, if it was selected. + * Returns a modified version of `nextGlobal`. + */ +function preserveMaxTransferAmount(prevGlobal: GlobalState, nextGlobal: GlobalState) { + const previousMaxAmount = selectCurrentTransferMaxAmount(prevGlobal); + const wasMaxAmountSelected = prevGlobal.currentTransfer.amount === previousMaxAmount; + if (!wasMaxAmountSelected) { + return nextGlobal; + } + const nextMaxAmount = selectCurrentTransferMaxAmount(nextGlobal); + return updateCurrentTransfer(nextGlobal, { amount: nextMaxAmount }); +} + +export function updateCurrentTransferLoading(global: GlobalState, isLoading: boolean): GlobalState { + return { + ...global, + currentTransfer: { + ...global.currentTransfer, + isLoading, + }, + }; +} diff --git a/src/global/reducers/wallet.ts b/src/global/reducers/wallet.ts index 5b5f48a8..1b45343b 100644 --- a/src/global/reducers/wallet.ts +++ b/src/global/reducers/wallet.ts @@ -1,36 +1,9 @@ -import type { ApiCheckTransactionDraftResult } from '../../api/chains/ton/types'; import type { GlobalState } from '../types'; -import { pick } from '../../util/iteratees'; -import { INITIAL_STATE } from '../initialState'; -import { selectAccountState, selectCurrentAccountState, selectCurrentTransferMaxAmount } from '../selectors'; +import { selectAccountState, selectCurrentAccountState } from '../selectors'; import { updateAccountState, updateCurrentAccountId, updateCurrentAccountState } from './misc'; import { clearCurrentSwap } from './swap'; - -export function updateCurrentTransferByCheckResult(global: GlobalState, result: ApiCheckTransactionDraftResult) { - const nextGlobal = updateCurrentTransfer(global, { - toAddressName: result.addressName, - ...pick(result, ['fee', 'realFee', 'isScam', 'isMemoRequired', 'diesel']), - }); - return preserveMaxTransferAmount(global, nextGlobal); -} - -export function updateCurrentTransfer(global: GlobalState, update: Partial) { - return { - ...global, - currentTransfer: { - ...global.currentTransfer, - ...update, - }, - }; -} - -export function clearCurrentTransfer(global: GlobalState) { - return { - ...global, - currentTransfer: INITIAL_STATE.currentTransfer, - }; -} +import { clearCurrentTransfer } from './transfer'; export function updateCurrentSignature(global: GlobalState, update: Partial) { return { @@ -96,20 +69,6 @@ export function updateActivitiesIsLoadingByAccount(global: GlobalState, accountI }); } -/** - * Preserves the maximum transfer amount, if it was selected. - * Returns a modified version of `nextGlobal`. - */ -function preserveMaxTransferAmount(prevGlobal: GlobalState, nextGlobal: GlobalState) { - const previousMaxAmount = selectCurrentTransferMaxAmount(prevGlobal); - const wasMaxAmountSelected = prevGlobal.currentTransfer.amount === previousMaxAmount; - if (!wasMaxAmountSelected) { - return nextGlobal; - } - const nextMaxAmount = selectCurrentTransferMaxAmount(nextGlobal); - return updateCurrentTransfer(nextGlobal, { amount: nextMaxAmount }); -} - export function switchAccountAndClearGlobal(global: GlobalState, accountId: string) { let newGlobal = updateCurrentAccountId(global, accountId); newGlobal = clearCurrentTransfer(newGlobal); diff --git a/src/global/selectors/notifications.ts b/src/global/selectors/notifications.ts index 8a73966a..63b300f3 100644 --- a/src/global/selectors/notifications.ts +++ b/src/global/selectors/notifications.ts @@ -1,17 +1,11 @@ -import type { ApiChain } from '../../api/types'; +import type { ApiChain, ApiNotificationAddress } from '../../api/types'; import type { GlobalState } from '../types'; -export interface NotificationAddress { - title?: string; - address: string; - chain: ApiChain; -} - -// Do not use with memorized React components -export function selectNotificationTonAddresses( +// This selector is not optimized for usage with React components wrapped by withGlobal +export function selectNotificationTonAddressesSlow( global: GlobalState, accountIds: string[], -): NotificationAddress[] { +): ApiNotificationAddress[] { return accountIds.map((accountId) => { const account = global.accounts!.byId[accountId]; diff --git a/src/global/types.ts b/src/global/types.ts index 987cb842..f6431afd 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -1,5 +1,4 @@ import type { ApiFetchEstimateDieselResult, ApiTonWalletVersion } from '../api/chains/ton/types'; -import type { NotificationsAccountValue } from '../api/methods'; import type { ApiTonConnectProof } from '../api/tonConnect/types'; import type { ApiActivity, @@ -15,6 +14,7 @@ import type { ApiLedgerDriver, ApiNetwork, ApiNft, + ApiNotificationsAccountValue, ApiParsedPayload, ApiPriceHistoryPeriod, ApiSignedTransfer, @@ -683,7 +683,7 @@ export type GlobalState = { isAvailable?: boolean; userToken?: string; platform?: CapacitorPlatform; - enabledAccounts: Record>; + enabledAccounts: Record>; }; isManualLockActive?: boolean; @@ -762,7 +762,7 @@ export interface ActionPayloads { stateInit?: string; } | undefined; changeTransferToken: { tokenSlug: string }; - fetchFee: { + fetchTransferFee: { tokenSlug: string; toAddress: string; comment?: string; @@ -812,7 +812,7 @@ export interface ActionPayloads { clearAccountLoading: undefined; verifyHardwareAddress: undefined; authorizeDiesel: undefined; - fetchDieselState: { tokenSlug: string }; + fetchTransferDieselState: { tokenSlug: string }; setIsAuthLoading: { isLoading?: boolean }; fetchTokenTransactions: { limit: number; slug: string; shouldLoadWithBudget?: boolean }; @@ -879,6 +879,7 @@ export interface ActionPayloads { fetchStakingHistory: { limit?: number; offset?: number } | undefined; fetchStakingFee: { amount: bigint }; openStakingInfo: undefined; + openAnyAccountStakingInfo: { accountId: string; network: ApiNetwork; stakingId: string }; closeStakingInfo: undefined; changeCurrentStaking: { stakingId: string; shouldReopenModal?: boolean }; startStakingClaim: undefined; diff --git a/src/styles/index.scss b/src/styles/index.scss index 1f8c58f1..5d720ee4 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -39,6 +39,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + -webkit-touch-callout: none; } html { @@ -92,6 +93,7 @@ html { .custom-scroll-x { // Fix scroll lock on iOS pointer-events: auto; + scroll-behavior: smooth; // Primarily to smooth the scroll that happens when a virtual keyboard appears transition: scrollbar-color 300ms; @@ -115,6 +117,10 @@ html { background-color: rgba(90, 90, 90, 0.3); } } + + :global(html.animation-level-0) & { + scroll-behavior: initial; + } } html:not(.is-ios) { diff --git a/src/styles/mtwCustomCard.scss b/src/styles/mtwCustomCard.scss index 29f8b40f..56679d43 100644 --- a/src/styles/mtwCustomCard.scss +++ b/src/styles/mtwCustomCard.scss @@ -93,7 +93,6 @@ background: linear-gradient(90deg, rgba(255, 255, 255, 0.9) 0%, #5B5C64 100%); -webkit-background-clip: text; background-clip: text; - mix-blend-mode: multiply; -webkit-text-fill-color: transparent; @@ -130,7 +129,11 @@ } .MtwCard__black & { - background-image: linear-gradient(90deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.15) 100%); + background-image: linear-gradient(90deg, rgba(255, 255, 255, 0.8) 50%, rgba(255, 255, 255, 0.15) 100%); + + html.theme-dark & { + background-image: linear-gradient(90deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.15) 100%); + } > .icon { background-color: transparent; diff --git a/src/styles/_variables.scss b/src/styles/variables.scss similarity index 97% rename from src/styles/_variables.scss rename to src/styles/variables.scss index dfc84d02..8cc5946a 100644 --- a/src/styles/_variables.scss +++ b/src/styles/variables.scss @@ -211,6 +211,9 @@ --color-beidge-background: #D9EAF5; + --sticky-card-height: 3.75rem; + --tabs-container-height: 2.75rem; + &.is-ios { --layer-transition: 650ms cubic-bezier(0.22, 1, 0.36, 1); --layer-transition-behind: 650ms cubic-bezier(0.33, 1, 0.68, 1); @@ -221,8 +224,22 @@ --slide-transition: 350ms cubic-bezier(0.16, 1, 0.3, 1); } - &:global(.theme-dark), - :global(.component-theme-dark) { + &.with-safe-area-top { + --sticky-card-height: 2.625rem; + } + + // Fix for opera, dead zone of 37 pixels in extension window on windows + &.is-windows.is-opera.is-extension { + --sticky-card-height: 4.75rem; + } + + // Sticky header height - electron header height + &.is-electron { + --sticky-card-height: 0.75rem; + } + + &.theme-dark, + .component-theme-dark { --color-accent: #469CEC; --color-black: #F6F7F8; --color-gray-1: #BFC0C2; diff --git a/src/util/accentColor.ts b/src/util/accentColor.ts index f5e6a528..2fd99541 100644 --- a/src/util/accentColor.ts +++ b/src/util/accentColor.ts @@ -43,7 +43,10 @@ export function useAccentColor( const accentColor = accentColorIndex ? ACCENT_COLORS[appTheme][accentColorIndex] : undefined; useLayoutEffect(() => { - setExtraStyles(elementRefOrBody === 'body' ? document.body : elementRefOrBody.current, { + const element = elementRefOrBody === 'body' ? document.body : elementRefOrBody.current; + if (!element) return; + + setExtraStyles(element, { '--color-accent': accentColor || 'inherit', '--color-accent-10o': accentColor ? `${accentColor}${HEX_10_PERCENT}` : 'inherit', '--color-accent-button-background': accentColor || 'inherit', diff --git a/src/util/capacitor/focusScroll.ts b/src/util/capacitor/focusScroll.ts new file mode 100644 index 00000000..aee59e7e --- /dev/null +++ b/src/util/capacitor/focusScroll.ts @@ -0,0 +1,91 @@ +import { requestMeasure } from '../../lib/fasterdom/fasterdom'; +import { IS_ANDROID_APP } from '../windowEnvironment'; +import { onVirtualKeyboardOpen } from '../windowSize'; + +const focusScroller = createFocusScroller(); + +/** + * Implements a custom scroll behavior on input focus. Designed for Capacitor platforms only. Meets the following goals: + * - Allow to customize where to scroll the focused element to (via the `data-focus-scroll-position` HTML attribute). + * - Scroll to show the focused element if it's behind by the virtual keyboard at the moment when it opens. + * - Scroll to the focused element smoothly whenever possible. + * + * Warning: this functions requires the @capacitor/keyboard plugin to be activated. It also relies on a + * `scroll-behavior: smooth;` style set in scrollable elements to implement smooth scrolling depending on the animation + * preferences. + */ +export function initFocusScrollController() { + // `scrollToActiveElement` shouldn't be called in `keyboardWillShow` because the size won't be patched yet, so + // if the focused input at the bottom of the scrollable element, it won't be scrolled to. + // `keyboardWillShow` could be used if the screen scroll height increased (instead of decreasing the height). + // Note: on iOS `onVirtualKeyboardOpen` calls the callback on every focus (it doesn't affect the scroll behavior). + onVirtualKeyboardOpen(() => { + // The manual focus scroller is activated only when the virtual keyboard is open it order to avoid a scroll when the + // keyboard starts opening (it would scroll the 2nd time when the keyboard has opened). + // This has a side effect of keeping the natural scroll behavior when a hardware keyboard is used (it's ok). + focusScroller.install(); + scrollToActiveElement(); + }); + + void import('@capacitor/keyboard').then(({ Keyboard }) => { + Keyboard.addListener('keyboardWillHide', focusScroller.uninstall); + }); +} + +function createFocusScroller() { + let originalFocus: HTMLElement['focus'] | undefined; + let isProgrammaticFocus = false; + + // Prevents native scroll for all `focus()` calls, because if the element is outside the viewport, `focus()` scrolls + // the screen instantly to place the element in the middle (we don't want that). + const patchedFocus: HTMLElement['focus'] = function focus(this: HTMLElement, options) { + isProgrammaticFocus = true; + // Does not work on Android, because it doesn't support `preventScroll` + originalFocus?.call(this, { ...options, preventScroll: true }); + }; + + const handleFocus = () => { + // Because Android doesn't support `preventScroll`, we make the scroll instant to prevent excessive back-and-forth + // scrolls. Doing it only for programmatic `focus` calls allows to have smooth scrolling when the user focuses the + // input by tapping it while the keyboard is open. + scrollToActiveElement(IS_ANDROID_APP && isProgrammaticFocus); + isProgrammaticFocus = false; + }; + + return { + install() { + if (originalFocus) { + return; // This branch is reached when the scroller is already installed + } + + originalFocus = HTMLElement.prototype.focus; + HTMLElement.prototype.focus = patchedFocus; + + window.addEventListener('focusin', handleFocus); + }, + uninstall() { + if (!originalFocus) { + return; // This branch is reached when the scroller is already uninstalled + } + + HTMLElement.prototype.focus = originalFocus; + originalFocus = undefined; + + window.removeEventListener('focusin', handleFocus); + }, + }; +} + +function scrollToActiveElement(enforceInstantScroll?: boolean) { + const scrollTarget = document.activeElement; + if (!scrollTarget) { + return; + } + const scrollPosition = scrollTarget.dataset.focusScrollPosition ?? 'nearest'; + requestMeasure(() => { + scrollTarget.scrollIntoView({ + block: scrollPosition, + behavior: enforceInstantScroll ? 'instant' : 'auto', + }); + }); +} diff --git a/src/util/capacitor/index.ts b/src/util/capacitor/index.ts index 873ffe92..29a305c8 100644 --- a/src/util/capacitor/index.ts +++ b/src/util/capacitor/index.ts @@ -21,6 +21,7 @@ import { pause } from '../schedulers'; import { IS_ANDROID_APP, IS_BIOMETRIC_AUTH_SUPPORTED, IS_DELEGATED_BOTTOM_SHEET, IS_IOS, } from '../windowEnvironment'; +import { initFocusScrollController } from './focusScroll'; import { initNotificationsWithGlobal } from './notifications'; import { getCapacitorPlatform, setCapacitorPlatform } from './platform'; @@ -103,6 +104,8 @@ export async function initCapacitor() { if (launchUrl) { void processDeeplink(launchUrl); } + + initFocusScrollController(); } export async function initCapacitorWithGlobal(authConfig?: AuthConfig) { diff --git a/src/util/capacitor/notifications.ts b/src/util/capacitor/notifications.ts index fc70efdf..414e7078 100644 --- a/src/util/capacitor/notifications.ts +++ b/src/util/capacitor/notifications.ts @@ -3,10 +3,11 @@ import type { ActionPerformed, Token } from '@capacitor/push-notifications'; import { PushNotifications } from '@capacitor/push-notifications'; import { getActions, getGlobal, setGlobal } from '../../global'; +import type { ApiStakingType } from '../../api/types'; import type { GlobalState } from '../../global/types'; import { selectAccountIdByAddress } from '../../global/selectors'; -import { selectNotificationTonAddresses } from '../../global/selectors/notifications'; +import { selectNotificationTonAddressesSlow } from '../../global/selectors/notifications'; import { callApi } from '../../api'; import { MINUTE } from '../../api/constants'; import { logDebugError } from '../logs'; @@ -17,16 +18,22 @@ interface BaseMessageData { } interface ShowTxMessageData extends BaseMessageData { - action: 'swap' | 'staking' | 'nativeTx'; + action: 'swap' | 'nativeTx'; txId: string; } +interface StakingMessageData extends BaseMessageData { + action: 'staking'; + stakingType: ApiStakingType; + stakingId: string; + logId: string; +} + interface OpenActivityMessageData extends BaseMessageData { action: 'jettonTx'; slug: string; } - -type MessageData = OpenActivityMessageData | ShowTxMessageData; +type MessageData = StakingMessageData | OpenActivityMessageData | ShowTxMessageData; let nextUpdatePushNotifications = 0; @@ -64,7 +71,7 @@ export async function initNotificationsWithGlobal(global: GlobalState) { } if (notificationStatus.receive !== 'granted') { - // For request IOS returns 'denied', but 'granted' follows immediately without a new requests. + // For request iOS returns 'denied', but 'granted' follows immediately without new requests return; } @@ -72,7 +79,7 @@ export async function initNotificationsWithGlobal(global: GlobalState) { } function handlePushNotificationActionPerformed(notification: ActionPerformed) { - const { showAnyAccountTx, showAnyAccountTokenActivity } = getActions(); + const { showAnyAccountTx, showAnyAccountTokenActivity, openAnyAccountStakingInfo } = getActions(); const global = getGlobal(); const notificationData = notification.notification.data as MessageData; const { action, address } = notificationData; @@ -84,12 +91,17 @@ function handlePushNotificationActionPerformed(notification: ActionPerformed) { if (!accountId) return; + const network = 'mainnet'; + if (action === 'nativeTx' || action === 'swap') { const { txId } = notificationData; - showAnyAccountTx({ accountId, txId, network: 'mainnet' }); + showAnyAccountTx({ accountId, txId, network }); } else if (action === 'jettonTx') { const { slug } = notificationData; - showAnyAccountTokenActivity({ accountId, slug, network: 'mainnet' }); + showAnyAccountTokenActivity({ accountId, slug, network }); + } else if (action === 'staking') { + const { stakingId } = notificationData; + openAnyAccountStakingInfo({ accountId, network, stakingId }); } } @@ -105,7 +117,7 @@ function handlePushNotificationRegistration(token: Token) { await callApi('subscribeNotifications', { userToken, platform: getCapacitorPlatform()!, - addresses: selectNotificationTonAddresses(global, notificationAccounts), + addresses: selectNotificationTonAddressesSlow(global, notificationAccounts), }); nextUpdatePushNotifications = Date.now() + (60 * MINUTE); } diff --git a/src/util/createAbortableFunction.ts b/src/util/createAbortableFunction.ts index 48328920..52a26a11 100644 --- a/src/util/createAbortableFunction.ts +++ b/src/util/createAbortableFunction.ts @@ -6,9 +6,8 @@ export function createAbortableFunction Promise { let abort = new Deferred(); - // eslint-disable-next-line func-names - return function (...args: TArgs): Promise { - abort.resolve(toReturnOnAbort); // "Cancels" the current function execution, if there is one + return function abortableFunction(...args: TArgs): Promise { + abort.resolve(toReturnOnAbort); abort = new Deferred(); return Promise.race([fn(...args), abort.promise]); diff --git a/src/util/ledger/index.ts b/src/util/ledger/index.ts index 3d9cfe4e..5c133ddd 100644 --- a/src/util/ledger/index.ts +++ b/src/util/ledger/index.ts @@ -450,7 +450,7 @@ export async function submitLedgerUnstake(accountId: string, state: ApiStakingSt } case 'liquid': { const tokenWalletAddress = await callApi('resolveTokenWalletAddress', network, address, LIQUID_JETTON); - const mode = state.instantAvailable + const mode = !state.instantAvailable ? ApiLiquidUnstakeMode.BestRate : ApiLiquidUnstakeMode.Default; diff --git a/src/util/swipeController.ts b/src/util/swipeController.ts index 02268faa..769a923f 100644 --- a/src/util/swipeController.ts +++ b/src/util/swipeController.ts @@ -10,6 +10,8 @@ import { IS_IOS } from './windowEnvironment'; const INERTIA_DURATION = 300; const INERTIA_EASING = timingFunctions.easeOutCubic; +export const SWIPE_DISABLED_CLASS_NAME = 'swipe-disabled'; + let isSwipeActive = false; let swipeOffsets: MoveOffsets | undefined; let onDrag: ((offsets: MoveOffsets) => void) | undefined; @@ -26,6 +28,8 @@ export function captureControlledSwipe( return captureEvents(element, { swipeThreshold: 10, + excludedClosestSelector: `.${SWIPE_DISABLED_CLASS_NAME}`, + onSwipe(e, direction, offsets) { if (direction === SwipeDirection.Left) { options.onSwipeLeftStart?.(); diff --git a/src/util/windowSize.ts b/src/util/windowSize.ts index 7c17abc5..5f2f45ed 100644 --- a/src/util/windowSize.ts +++ b/src/util/windowSize.ts @@ -1,14 +1,16 @@ import { IS_CAPACITOR } from '../config'; import { requestMutation } from '../lib/fasterdom/fasterdom'; import { applyStyles } from './animation'; +import safeExec from './safeExec'; import { throttle } from './schedulers'; -import { IS_ANDROID, IS_ANDROID_APP, IS_IOS } from './windowEnvironment'; +import { IS_ANDROID, IS_IOS } from './windowEnvironment'; const WINDOW_RESIZE_THROTTLE_MS = 250; const WINDOW_ORIENTATION_CHANGE_THROTTLE_MS = IS_IOS ? 350 : 250; const SAFE_AREA_INITIALIZATION_DELAY = 1000; const initialHeight = window.innerHeight; +const virtualKeyboardOpenListeners: NoneToVoidFunction[] = []; let currentWindowSize = updateSizes(); @@ -22,15 +24,19 @@ if (!IS_IOS) { }, WINDOW_RESIZE_THROTTLE_MS, true)); } -if (IS_ANDROID_APP) { +if (IS_CAPACITOR) { import('@capacitor/keyboard') .then(({ Keyboard }) => { - Keyboard.addListener('keyboardDidShow', (info) => { - patchAndroidAppVh(info.keyboardHeight); + Keyboard.addListener('keyboardDidShow', async (info) => { + await patchCapacitorAppVh(info.keyboardHeight); + + for (const cb of virtualKeyboardOpenListeners) { + safeExec(cb); + } }); Keyboard.addListener('keyboardWillHide', () => { - patchAndroidAppVh(0); + void patchCapacitorAppVh(0); }); }); } @@ -66,6 +72,11 @@ export default { getIsKeyboardVisible: () => initialHeight > currentWindowSize.height, }; +// Registers a callback that will be fired each time the virtual keyboard is opened and the size is adjusted +export function onVirtualKeyboardOpen(cb: NoneToVoidFunction) { + virtualKeyboardOpenListeners.push(cb); +} + function patchVh() { if (!(IS_IOS || IS_ANDROID) || IS_CAPACITOR) return; @@ -77,9 +88,12 @@ function patchVh() { }); } -function patchAndroidAppVh(keyboardHeight: number) { - requestMutation(() => { - applyStyles(document.body, { paddingBottom: keyboardHeight ? `${keyboardHeight}px` : '' }); +function patchCapacitorAppVh(keyboardHeight: number) { + return new Promise((resolve) => { + requestMutation(() => { + applyStyles(document.body, { paddingBottom: keyboardHeight ? `${keyboardHeight}px` : '' }); + resolve(); + }); }); }