Merge pull request #5687 from nymtech/feature/test-v2

Tauri V2 - Wallet Migration
This commit is contained in:
Tommy Verrall
2025-04-09 12:09:41 +01:00
committed by GitHub
104 changed files with 12080 additions and 4233 deletions
+6 -2
View File
@@ -16,8 +16,12 @@ jobs:
CARGO_TERM_COLOR: always
RUSTUP_PERMIT_COPY_RENAME: 1
steps:
- name: Install Dependencies (Linux)
run: sudo apt-get update && sudo apt-get -y install libwebkit2gtk-4.0-dev build-essential curl wget libssl-dev libgtk-3-dev squashfs-tools
- name: Install system dependencies
run: |
sudo apt-get update && sudo apt-get install -y libdbus-1-dev libmnl-dev libnftnl-dev \
libwebkit2gtk-4.1-dev build-essential curl wget libssl-dev jq \
libgtk-3-dev squashfs-tools libayatana-appindicator3-dev make libfuse2 unzip librsvg2-dev file \
libsoup-3.0-dev libjavascriptcoregtk-4.1-dev
continue-on-error: true
- name: Check out repository code
+25 -29
View File
@@ -18,11 +18,7 @@ jobs:
runs-on: ${{ matrix.platform }}
outputs:
release_id: ${{ steps.create-release.outputs.id }}
release_date: ${{ fromJSON(steps.create-release.outputs.assets)[0].created_at }}
version: ${{ steps.release-info.outputs.version }}
filename: ${{ steps.release-info.outputs.filename }}
file_hash: ${{ steps.release-info.outputs.file_hash }}
release_tag: ${{ github.ref_name }}
steps:
- uses: actions/checkout@v4
@@ -33,10 +29,16 @@ jobs:
node-version: 21
- name: Install Rust stable
uses: actions-rs/toolchain@v1
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
- name: Add Rust target for x86_64-apple-darwin
run: rustup target add x86_64-apple-darwin
- name: Set Cargo build target to x86_64
run: echo "CARGO_BUILD_TARGET=x86_64-apple-darwin" >> $GITHUB_ENV
- name: Install the Apple developer certificate for code signing
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -66,12 +68,6 @@ jobs:
fileName: '.env'
encodedString: ${{ secrets.WALLET_ADMIN_ADDRESS }}
- name: Add Rust target for x86_64-apple-darwin
run: rustup target add x86_64-apple-darwin
- name: Set Cargo build target to x86_64
run: echo "CARGO_BUILD_TARGET=x86_64-apple-darwin" >> $GITHUB_ENV
- name: Yarn cache clean
shell: bash
run: cd .. && yarn cache clean
@@ -94,10 +90,22 @@ jobs:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_IDENTITY_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
# Tauri v2 specific environment variables
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
TAURI_NOTARIZATION_USERNAME: ${{ secrets.APPLE_ID }}
TAURI_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
TAURI_NOTARIZATION_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
yarn build-macx86
yarn build-macx86
- name: Create app tarball
run: |
# Navigate to where the app bundle is and create the tarball
cd target/x86_64-apple-darwin/release/bundle/macos
echo "Creating tarball from app bundle"
tar -czf nym-wallet.app.tar.gz NymWallet.app
cd -
- name: Upload Artifact
uses: actions/upload-artifact@v4
@@ -120,22 +128,10 @@ jobs:
nym-wallet/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
nym-wallet/target/x86_64-apple-darwin/release/bundle/macos/*.app.tar.gz*
- name: Deploy artifacts to CI www
continue-on-error: true
uses: easingthemes/ssh-deploy@main
env:
SSH_PRIVATE_KEY: ${{ secrets.CI_WWW_SSH_PRIVATE_KEY }}
ARGS: "-avzr"
SOURCE: "nym-wallet/target/x86_64-apple-darwin/release/bundle/macos/nym-wallet.app.tar.gz"
REMOTE_HOST: ${{ secrets.CI_WWW_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.CI_WWW_REMOTE_USER }}
TARGET: ${{ secrets.CI_WWW_REMOTE_TARGET }}/builds/${{ github.ref_name }}/nym-wallet
EXCLUDE: "/dist/, /node_modules/"
push-release-data:
if: ${{ (startsWith(github.ref, 'refs/tags/nym-wallet-') && github.event_name == 'release') || github.event_name == 'workflow_dispatch' }}
uses: ./.github/workflows/release-calculate-hash.yml
needs: publish-tauri
with:
release_tag: ${{ github.ref_name }}
secrets: inherit
release_tag: ${{ needs.publish-tauri.outputs.release_tag || github.ref_name }}
secrets: inherit
+81 -42
View File
@@ -3,71 +3,108 @@ on:
workflow_dispatch:
release:
types: [created]
defaults:
run:
working-directory: nym-wallet
jobs:
publish-tauri:
if: ${{ (startsWith(github.ref, 'refs/tags/nym-wallet-') && github.event_name == 'release') || github.event_name == 'workflow_dispatch' }}
strategy:
fail-fast: false
matrix:
platform: [custom-ubuntu-22.04]
platform: [ubuntu-22.04]
runs-on: ${{ matrix.platform }}
outputs:
release_id: ${{ steps.create-release.outputs.id }}
release_date: ${{ fromJSON(steps.create-release.outputs.assets)[0].created_at }}
version: ${{ steps.release-info.outputs.version }}
filename: ${{ steps.release-info.outputs.filename }}
file_hash: ${{ steps.release-info.outputs.file_hash }}
release_tag: ${{ github.ref_name }}
steps:
- uses: actions/checkout@v4
- name: Tauri dependencies
run: >
sudo apt-get update &&
sudo apt-get install -y webkit2gtk-4.0
continue-on-error: true
- name: Install system dependencies
run: |
sudo apt-get update && sudo apt-get install -y libdbus-1-dev libmnl-dev libnftnl-dev \
libwebkit2gtk-4.1-dev build-essential curl wget libssl-dev jq \
libgtk-3-dev squashfs-tools libayatana-appindicator3-dev make libfuse2 unzip librsvg2-dev file \
libsoup-3.0-dev libjavascriptcoregtk-4.1-dev
- name: Node
uses: actions/setup-node@v4
with:
node-version: 21
cache: 'yarn'
- name: Install Rust stable
uses: actions-rs/toolchain@v1
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
- name: Install project dependencies
shell: bash
run: cd .. && yarn --network-timeout 100000
- name: Install app dependencies
run: yarn
- name: Create env file
uses: timheuer/base64-to-file@v1.2
with:
fileName: '.env'
encodedString: ${{ secrets.WALLET_ADMIN_ADDRESS }}
- name: Build app
run: yarn build
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: Check bundle directory
run: |
echo "Checking bundle directory structure"
ls -la target/release/bundle || echo "Bundle directory not found"
if [ -d "target/release/bundle/appimage" ]; then
echo "AppImage bundle directory exists, checking contents:"
ls -la target/release/bundle/appimage
else
echo "AppImage bundle directory not found, checking alternatives:"
find target/release/bundle -type d -name "*appimage*" -o -name "*AppImage*" || echo "No AppImage directories found"
find target/release/bundle -name "*.AppImage" -o -name "*.appimage" || echo "No AppImage files found"
fi
- name: Create AppImage tarball if needed
run: |
# Find the AppImage file
APPIMAGE_FILE=$(find target/release/bundle -name "*.AppImage" | head -n 1)
if [ -n "$APPIMAGE_FILE" ]; then
echo "Found AppImage file: $APPIMAGE_FILE"
APPIMAGE_DIR=$(dirname "$APPIMAGE_FILE")
APPIMAGE_NAME=$(basename "$APPIMAGE_FILE")
# Create tarball if it doesn't exist
if [ ! -f "${APPIMAGE_FILE}.tar.gz" ]; then
echo "Creating tarball for $APPIMAGE_NAME"
cd "$APPIMAGE_DIR"
tar -czf "${APPIMAGE_NAME}.tar.gz" "$APPIMAGE_NAME"
cd -
echo "Created tarball: ${APPIMAGE_FILE}.tar.gz"
else
echo "Tarball already exists: ${APPIMAGE_FILE}.tar.gz"
fi
else
echo "WARNING: No AppImage file found!"
fi
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: nym-wallet_1.0.0_amd64.AppImage.tar.gz
path: nym-wallet/target/release/bundle/appimage/nym-wallet*.AppImage.tar.gz
name: nym-wallet-appimage.tar.gz
path: |
nym-wallet/target/release/bundle/appimage/*.AppImage.tar.gz
nym-wallet/target/release/bundle/*/nym-wallet*.AppImage.tar.gz
retention-days: 30
- id: create-release
name: Upload to release based on tag name
uses: softprops/action-gh-release@v2
@@ -75,24 +112,26 @@ jobs:
with:
files: |
nym-wallet/target/release/bundle/appimage/*.AppImage
nym-wallet/target/release/bundle/appimage/*.AppImage.tar.gz*
- name: Deploy artifacts to CI www
continue-on-error: true
uses: easingthemes/ssh-deploy@main
env:
SSH_PRIVATE_KEY: ${{ secrets.CI_WWW_SSH_PRIVATE_KEY }}
ARGS: "-avzr"
SOURCE: "nym-wallet/target/release/bundle/appimage/nym-wallet*.AppImage.tar.gz"
REMOTE_HOST: ${{ secrets.CI_WWW_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.CI_WWW_REMOTE_USER }}
TARGET: ${{ secrets.CI_WWW_REMOTE_TARGET }}/builds/${{ github.ref_name }}/nym-wallet
EXCLUDE: "/dist/, /node_modules/"
nym-wallet/target/release/bundle/appimage/*.AppImage.tar.gz
nym-wallet/target/release/bundle/*/nym-wallet*.AppImage
nym-wallet/target/release/bundle/*/nym-wallet*.AppImage.tar.gz
- name: Find AppImage tarball path for deployment
id: find-appimage
run: |
APPIMAGE_TARBALL=$(find target/release/bundle -name "*.AppImage.tar.gz" | head -n 1)
if [ -n "$APPIMAGE_TARBALL" ]; then
echo "Found AppImage tarball: $APPIMAGE_TARBALL"
echo "appimage_path=$APPIMAGE_TARBALL" >> $GITHUB_OUTPUT
else
echo "WARNING: No AppImage tarball found for deployment!"
echo "appimage_path=target/release/bundle/appimage/nym-wallet*.AppImage.tar.gz" >> $GITHUB_OUTPUT
fi
push-release-data:
if: ${{ (startsWith(github.ref, 'refs/tags/nym-wallet-') && github.event_name == 'release') || github.event_name == 'workflow_dispatch' }}
uses: ./.github/workflows/release-calculate-hash.yml
needs: publish-tauri
with:
release_tag: ${{ github.ref_name }}
secrets: inherit
release_tag: ${{ needs.publish-tauri.outputs.release_tag || github.ref_name }}
secrets: inherit
+110 -61
View File
@@ -1,6 +1,12 @@
name: publish-nym-wallet-win11
on:
workflow_dispatch:
inputs:
sign:
description: "Sign this build using SSL.com. Signing is billed per signature so be careful"
required: false
type: boolean
default: true
release:
types: [created]
@@ -18,53 +24,61 @@ jobs:
runs-on: ${{ matrix.platform }}
outputs:
release_id: ${{ steps.create-release.outputs.id }}
release_date: ${{ fromJSON(steps.create-release.outputs.assets)[0].created_at }}
version: ${{ steps.release-info.outputs.version }}
filename: ${{ steps.release-info.outputs.filename }}
file_hash: ${{ steps.release-info.outputs.file_hash }}
release_tag: ${{ github.ref_name }}
steps:
- name: Clean up first
continue-on-error: true
working-directory: .
run: |
cd ..
del /s /q /A:H nym
rmdir /s /q nym
- uses: actions/checkout@v4
- name: Import signing certificate
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
run: |
New-Item -ItemType directory -Path certificate
Set-Content -Path certificate/tempCert.txt -Value $env:WINDOWS_CERTIFICATE
certutil -decode certificate/tempCert.txt certificate/certificate.pfx
Remove-Item -path certificate -include tempCert.txt
Import-PfxCertificate -FilePath certificate/certificate.pfx -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -Force -AsPlainText)
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
- name: Setup MSBuild.exe
uses: microsoft/setup-msbuild@v2
- name: Node
uses: actions/setup-node@v4
with:
node-version: 21
- name: Install Rust stable
uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: Create env file
uses: timheuer/base64-to-file@v1.2
with:
fileName: '.env'
encodedString: ${{ secrets.WALLET_ADMIN_ADDRESS }}
- name: Install Yarn
run: npm install -g yarn
- name: Download EV CodeSignTool from ssl.com
working-directory: nym-wallet/src-tauri
if: ${{ inputs.sign }}
shell: bash
run: |
curl -L0 https://www.ssl.com/download/codesigntool-for-linux-and-macos/ -o codesigntool.zip
unzip codesigntool.zip
- name: Get EV certificate credential id
working-directory: nym-wallet/src-tauri
if: ${{ inputs.sign }}
id: get_credential_ids
shell: bash
run: |
echo "SSL_COM_CREDENTIAL_ID=$(./CodeSignTool.sh get_credential_ids -username=${{ secrets.SSL_COM_USERNAME }} -password=${{ secrets.SSL_COM_PASSWORD }} | sed -n '1!p' | sed 's/- //')" >> "$GITHUB_OUTPUT"
- name: Add custom sign command to tauri.conf.json
working-directory: nym-wallet/src-tauri
if: ${{ inputs.sign }}
shell: bash
run: |
yq eval --inplace '.bundle.windows +=
{
"signCommand": {
"cmd": "C:\Program Files\Git\bin\bash.EXE",
"args": [
"/c/actions-runner/_work/nym/nym/nym-wallet/src-tauri/CodeSignTool.sh",
"sign",
"-username ${{ secrets.SSL_COM_USERNAME }}",
"-password ${{ secrets.SSL_COM_PASSWORD }}",
"-credential_id ${{ steps.get_credential_ids.outputs.SSL_COM_CREDENTIAL_ID }}",
"-totp_secret ${{ secrets.SSL_COM_TOTP_SECRET }}",
"-program_name NymWallet",
"-input_file_path",
"%1",
"-override"
]
}
}' tauri.conf.json
- name: Install project dependencies
shell: bash
run: cd .. && yarn --network-timeout 100000
@@ -77,18 +91,50 @@ jobs:
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ENABLE_CODE_SIGNING: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: yarn build
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
SSL_COM_USERNAME: ${{ inputs.sign && secrets.SSL_COM_USERNAME }}
SSL_COM_PASSWORD: ${{ inputs.sign && secrets.SSL_COM_PASSWORD }}
SSL_COM_CREDENTIAL_ID: ${{ inputs.sign && steps.get_credential_ids.outputs.SSL_COM_CREDENTIAL_ID }}
SSL_COM_TOTP_SECRET: ${{ inputs.sign && secrets.SSL_COM_TOTP_SECRET }}
run: |
echo "Starting build process..."
yarn build
- name: Check bundle directory
shell: bash
run: |
echo "Checking bundle directory structure"
# Check standard location
if [ -d "target/release/bundle" ]; then
echo "Found bundle directory at standard location"
ls -la target/release/bundle || echo "Failed to list bundle directory"
fi
# Check src-tauri location
if [ -d "src-tauri/target/release/bundle" ]; then
echo "Found bundle directory in src-tauri"
ls -la src-tauri/target/release/bundle || echo "Failed to list src-tauri bundle directory"
# Use this path for future steps
echo "BUNDLE_PATH=src-tauri/target/release/bundle" >> $GITHUB_ENV
else
echo "Using standard bundle path"
echo "BUNDLE_PATH=target/release/bundle" >> $GITHUB_ENV
fi
# Check for MSI files in any location
find . -name "*.msi" -type f
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: nym-wallet_1.0.0_x64_en-US.msi
path: nym-wallet/target/release/bundle/msi/nym-wallet_1.*.msi
name: nym-wallet.msi
path: |
nym-wallet/${{ env.BUNDLE_PATH }}/msi/*.msi
nym-wallet/${{ env.BUNDLE_PATH }}/*/nym-wallet*.msi
nym-wallet/src-tauri/target/release/bundle/msi/*.msi
retention-days: 30
- id: create-release
@@ -97,25 +143,28 @@ jobs:
if: github.event_name == 'release'
with:
files: |
nym-wallet/target/release/bundle/msi/*.msi
nym-wallet/target/release/bundle/msi/*.msi.zip*
- name: Deploy artifacts to CI www
continue-on-error: true
uses: easingthemes/ssh-deploy@main
env:
SSH_PRIVATE_KEY: ${{ secrets.CI_WWW_SSH_PRIVATE_KEY }}
ARGS: "-avzr"
SOURCE: "nym-wallet/target/release/bundle/msi/nym-wallet_1.*.msi"
REMOTE_HOST: ${{ secrets.CI_WWW_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.CI_WWW_REMOTE_USER }}
TARGET: ${{ secrets.CI_WWW_REMOTE_TARGET }}/builds/${{ github.ref_name }}/nym-wallet
EXCLUDE: "/dist/, /node_modules/"
nym-wallet/${{ env.BUNDLE_PATH }}/msi/*.msi
nym-wallet/${{ env.BUNDLE_PATH }}/msi/*.msi.zip*
nym-wallet/${{ env.BUNDLE_PATH }}/*/nym-wallet*.msi
nym-wallet/src-tauri/target/release/bundle/msi/*.msi
- name: Find MSI path for deployment
id: find-msi
shell: bash
run: |
MSI_FILE=$(find . -name "*.msi" -type f | head -n 1)
if [ -n "$MSI_FILE" ]; then
echo "Found MSI file: $MSI_FILE"
echo "msi_path=$MSI_FILE" >> $GITHUB_OUTPUT
else
echo "WARNING: No MSI file found for deployment!"
echo "msi_path=${{ env.BUNDLE_PATH }}/msi/nym-wallet*.msi" >> $GITHUB_OUTPUT
fi
push-release-data:
if: ${{ (startsWith(github.ref, 'refs/tags/nym-wallet-') && github.event_name == 'release') || github.event_name == 'workflow_dispatch' }}
uses: ./.github/workflows/release-calculate-hash.yml
needs: publish-tauri
with:
release_tag: ${{ github.ref_name }}
secrets: inherit
release_tag: ${{ needs.publish-tauri.outputs.release_tag || github.ref_name }}
secrets: inherit
+2763 -1516
View File
File diff suppressed because it is too large Load Diff
+11 -6
View File
@@ -1,12 +1,12 @@
{
"name": "@nymproject/nym-wallet-app",
"version": "1.2.17",
"version": "1.2.18",
"license": "MIT",
"main": "index.js",
"scripts": {
"build": "run-s webpack:prod tauri:build",
"dev": "run-p tauri:dev webpack:dev",
"build-macx86": "run-s webpack:prod tauri:buildx86",
"dev": "run-p tauri:dev webpack:dev",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"prebuild": "yarn --cwd .. build",
@@ -15,14 +15,15 @@
"storybook": "start-storybook -p 6006",
"storybook:build": "build-storybook",
"tauri:build": "yarn tauri build",
"tauri:buildx86": "yarn tauri build --target x86_64-apple-darwin",
"tauri:dev": "yarn tauri dev",
"tauri:buildx86": "yarn tauri build --target x86_64-apple-darwin",
"tsc": "tsc --noEmit true",
"tsc:watch": "tsc --noEmit true --watch",
"webpack:dev": "yarn webpack serve --config webpack.dev.js",
"webpack:prod": "yarn webpack --progress --config webpack.prod.js"
},
"dependencies": {
"@babel/helper-simple-access": "^7.25.9",
"@emotion/react": "^11.7.0",
"@emotion/styled": "^11.6.0",
"@hookform/resolvers": "^2.8.0",
@@ -35,7 +36,11 @@
"@nymproject/react": "^1.0.0",
"@nymproject/types": "^1.0.0",
"@storybook/react": "^6.5.15",
"@tauri-apps/api": "^1.2.0",
"@tauri-apps/api": "^2.4.0",
"@tauri-apps/plugin-clipboard-manager": "^2.2.2",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-shell": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.0.0",
"@tauri-apps/tauri-forage": "^1.0.0-beta.2",
"big.js": "^6.2.1",
"bs58": "^4.0.1",
@@ -69,7 +74,7 @@
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.4",
"@storybook/react": "^6.5.15",
"@svgr/webpack": "^6.1.1",
"@tauri-apps/cli": "^1.0.5",
"@tauri-apps/cli": "^2.4.0",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.0.0",
"@types/big.js": "^6.1.6",
@@ -127,4 +132,4 @@
"webpack-merge": "^5.8.0"
},
"private": false
}
}
+2
View File
@@ -0,0 +1,2 @@
[capabilities]
shell = { open = true }
+12 -9
View File
@@ -1,25 +1,27 @@
[package]
name = "nym_wallet"
version = "1.2.17"
name = "NymWallet"
version = "1.2.18"
description = "Nym Native Wallet"
authors = ["Nym Technologies SA"]
license = ""
repository = ""
default-run = "nym_wallet"
default-run = "NymWallet"
edition = "2021"
build = "src/build.rs"
rust-version = "1.76"
rust-version = "1.85"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "=1.2.1", features = [] }
tauri-codegen = "=1.2.1"
tauri-macros = "=1.2.1"
tauri-build = { version = "2.1.1", features = [] }
[dependencies]
async-trait = "0.1.68"
tauri-plugin-updater = "2.7.0"
tauri-plugin-clipboard-manager = "2.0.0"
tauri-plugin-shell = "2.2.1"
tauri-plugin-process = "2.2.1"
tauri-plugin-opener = "2.2.6"
bip39 = { version = "2.0.0", features = ["zeroize", "rand"] }
cfg-if = "1.0.0"
colored = "2.0"
@@ -31,6 +33,7 @@ futures = "0.3.15"
itertools = "0.10"
log = { version = "0.4", features = ["serde"] }
once_cell = "1.7.2"
open = "5.3.2"
pretty_env_logger = "0.4"
reqwest = { version = "0.12.4", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
@@ -38,7 +41,7 @@ serde_json = "1.0"
serde_repr = "0.1"
strum = { version = "0.23", features = ["derive"] }
tap = "1"
tauri = { version = "=1.2.3", features = ["clipboard-all", "shell-open", "updater", "window-maximize", "window-print"] }
tauri = { version = "2", features = [] }
#tendermint-rpc = "0.23.0"
time = { version = "0.3.30", features = ["local-offset"] }
thiserror = "1.0"
@@ -0,0 +1,37 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-capability",
"description": "Default capability for Nym Wallet main window",
"windows": [
"main",
"nymWalletApp",
"log"
],
"platforms": [
"linux",
"macOS",
"windows"
],
"permissions": [
"core:default",
"core:path:default",
"core:event:default",
"core:window:default",
"core:app:default",
"core:resources:default",
"core:menu:default",
"core:tray:default",
"opener:allow-open-url",
"opener:allow-default-urls",
"core:window:allow-set-title",
"core:app:allow-version",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
"updater:default",
"updater:allow-check",
"updater:allow-download",
"updater:allow-download-and-install",
"updater:allow-install",
"core:event:allow-listen"
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"main-capability":{"identifier":"main-capability","description":"Default capability for Nym Wallet main window","local":true,"windows":["main","nymWalletApp","log"],"permissions":["core:default","core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","opener:allow-open-url","opener:allow-default-urls","core:window:allow-set-title","core:app:allow-version","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","updater:default","updater:allow-check","updater:allow-download","updater:allow-download-and-install","updater:allow-install","core:event:allow-listen"],"platforms":["linux","macOS","windows"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 557 B

After

Width:  |  Height:  |  Size: 1008 B

-24
View File
@@ -1,24 +0,0 @@
# Regenerating icons
> **Note**: This is likely to be temporary until `tauri icon` is put back into the CLI.
The Tauri Docs say to use the CLI to generate icons: https://tauri.studio/docs/api/cli/#icon. However `1.0.0-rc.X` appears to not have this command. `1.0.0-beta.6` does 🎉!
Do the following to regenerate the icons:
```
cd ~
git clone nym ...
cd nym
docker run -v "$(pwd)":/workspace -it node:16 /bin/bash
npm i -g @tauri-apps/cli@1.0.0-beta.6
cd /workspace/nym-wallet/src-tauri
tauri icon /workspace/assets/appicon/appicon.png
exit
```
Reasons to use docker:
- you can't destroy your dev environments `npm` cache
- if you mess it up, kill the container, try again
- inside the `src-tauri` directory, `node` will resolve to the nearest `node_modules` directory and you'll get the wrong `tauri` cli
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 701 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+14 -3
View File
@@ -105,7 +105,17 @@ impl NetworkConfig {
impl Config {
fn root_directory() -> PathBuf {
tauri::api::path::config_dir().expect("Failed to get config directory")
// tauri v1 (via `tauri::api::path::config_dir()`) was internally calling `dirs_next::config_dir()`
// which ultimately was getting resolved to
// - **Linux:** Resolves to `$XDG_CONFIG_HOME` or `$HOME/.config`.
// - **macOS:** Resolves to `$HOME/Library/Application Support`.
// - **Windows:** Resolves to `{FOLDERID_RoamingAppData}`.
//
// tauri v2 calls `dirs::config_dir().ok_or(Error::UnknownPath)` which ultimately does the same thing,
// however, it changed its API so that it's called on a `PathResolver`.
// but, to instantiate one here would be a hassle as we don't need those specific functionalities,
// so let's just recreate tauri's behaviour
dirs::config_dir().expect("Failed to get config directory")
}
fn config_directory() -> PathBuf {
@@ -243,6 +253,7 @@ impl Config {
}
}
#[allow(clippy::to_string_in_format_args)]
pub fn set_default_nyxd_url(&mut self, nyxd_url: Url, network: &WalletNetwork) {
log::debug!(
"set default nyxd URL for {network} {}",
@@ -300,7 +311,7 @@ impl Config {
self.networks.get(&network.as_key()).and_then(|config| {
log::debug!(
"get selected nyxd url for {} {:?}",
network.to_string(),
network,
config.selected_nyxd_url,
);
config.selected_nyxd_url.clone()
@@ -311,7 +322,7 @@ impl Config {
self.networks.get(&network.as_key()).and_then(|config| {
log::debug!(
"get default nyxd url for {} {:?}",
network.to_string(),
network,
config.default_nyxd_url,
);
config.default_nyxd_url.clone()
+2 -2
View File
@@ -3,7 +3,7 @@ use std::str::FromStr;
use fern::colors::{Color, ColoredLevelConfig};
use serde::Serialize;
use serde_repr::{Deserialize_repr, Serialize_repr};
use tauri::Manager;
use tauri::Emitter;
use time::{format_description, OffsetDateTime};
fn formatted_time() -> String {
@@ -61,7 +61,7 @@ pub fn setup_logging(app_handle: tauri::AppHandle) -> Result<(), log::SetLoggerE
message: record.args().to_string(),
level: record.level().into(),
};
app_handle.emit_all("log://log", msg).unwrap();
app_handle.emit("log://log", msg).unwrap();
}));
base_config
+34 -9
View File
@@ -3,11 +3,14 @@
windows_subsystem = "windows"
)]
use tauri::{Manager, Menu};
use nym_mixnet_contract_common::{Gateway, MixNode};
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
use tauri::Manager;
use tauri_plugin_opener::init as init_opener;
use tauri_plugin_shell::init as init_shell;
use tauri_plugin_updater::Builder as UpdaterBuilder;
use crate::menu::AddDefaultSubmenus;
use crate::menu::SHOW_LOG_WINDOW;
use crate::operations::app;
use crate::operations::help;
use crate::operations::mixnet;
@@ -34,8 +37,13 @@ fn main() {
let context = tauri::generate_context!();
tauri::Builder::default()
.plugin(init_shell())
.plugin(init_opener())
.plugin(UpdaterBuilder::new().build())
.plugin(tauri_plugin_clipboard_manager::init())
.manage(WalletState::default())
.invoke_handler(tauri::generate_handler![
app::link::open_url,
app::version::check_version,
mixnet::account::add_account_for_password,
mixnet::account::archive_wallet_file,
@@ -121,7 +129,6 @@ fn main() {
nym_api::status::compute_mixnode_reward_estimation,
nym_api::status::gateway_core_node_status,
nym_api::status::mixnode_core_node_status,
nym_api::status::mixnode_inclusion_probability,
nym_api::status::mixnode_reward_estimation,
nym_api::status::mixnode_stake_saturation,
nym_api::status::mixnode_status,
@@ -210,13 +217,31 @@ fn main() {
app::react::set_react_state,
app::react::get_react_state,
])
.menu(Menu::os_default(&context.package_info().name).add_default_app_submenus())
.on_menu_event(|event| {
if event.menu_item_id() == menu::SHOW_LOG_WINDOW {
let _r = help::log::help_log_toggle_window(event.window().app_handle());
.menu(|app| {
// Create a menu builder
let menu_builder = MenuBuilder::new(app);
if ::std::env::var("NYM_WALLET_ENABLE_LOG").is_ok() {
let help_text = MenuItemBuilder::with_id(SHOW_LOG_WINDOW, "Show logs")
.build(app)
.expect("Failed to create menu item");
let submenu = SubmenuBuilder::new(app, "Help")
.items(&[&help_text])
.build()
.expect("Failed to create help submenu");
menu_builder.item(&submenu).build()
} else {
// Build a default menu without the submenu
menu_builder.build()
}
})
.setup(|app| Ok(log::setup_logging(app.app_handle())?))
.on_menu_event(|app, event| {
if event.id() == SHOW_LOG_WINDOW {
let _r = help::log::help_log_toggle_window(app.app_handle().clone());
}
})
.setup(|app| Ok(log::setup_logging(app.app_handle().clone())?))
.run(context)
.expect("error while running tauri application");
}
+23 -9
View File
@@ -1,22 +1,36 @@
use tauri::Menu;
use tauri::{CustomMenuItem, Submenu};
use tauri::menu::Menu;
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
pub const SHOW_LOG_WINDOW: &str = "show_log_window";
pub trait AddDefaultSubmenus {
#[allow(dead_code)]
fn add_default_app_submenus(self) -> Self;
}
impl AddDefaultSubmenus for Menu {
impl<R: tauri::Runtime> AddDefaultSubmenus for Menu<R> {
#[allow(dead_code)]
fn add_default_app_submenus(self) -> Self {
if ::std::env::var("NYM_WALLET_ENABLE_LOG").is_ok() {
let submenu = Submenu::new(
"Help",
Menu::new().add_item(CustomMenuItem::new(SHOW_LOG_WINDOW, "Show logs")),
);
return self.add_submenu(submenu);
let app_handle = self.app_handle();
let help_text = MenuItemBuilder::with_id(SHOW_LOG_WINDOW, "Show logs")
.build(app_handle)
.expect("Failed to create menu item");
let submenu = SubmenuBuilder::new(app_handle, "Help")
.items(&[&help_text])
.build()
.expect("Failed to create help submenu");
let menu_builder = MenuBuilder::new(app_handle);
match menu_builder.item(&submenu).build() {
Ok(new_menu) => new_menu,
Err(_) => self,
}
} else {
self
}
self
}
}
@@ -0,0 +1,11 @@
use tauri_plugin_opener::OpenerExt;
#[tauri::command]
pub async fn open_url(url: String, app_handle: tauri::AppHandle) -> Result<(), String> {
println!("Opening URL: {}", url);
match app_handle.opener().open_url(&url, None::<&str>) {
Ok(_) => Ok(()),
Err(err) => Err(format!("Failed to open URL: {}", err)),
}
}
@@ -1,3 +1,4 @@
pub mod link;
pub mod react;
pub mod version;
pub mod window;
@@ -3,27 +3,46 @@
use crate::error::BackendError;
use nym_wallet_types::app::AppVersion;
use tauri_plugin_updater::UpdaterExt;
#[tauri::command]
pub async fn check_version(handle: tauri::AppHandle) -> Result<AppVersion, BackendError> {
log::info!(">>> Getting app version info");
let res = tauri::updater::builder(handle)
.check()
.await
.map(|u| AppVersion {
current_version: u.current_version().to_string(),
latest_version: u.latest_version().to_owned(),
is_update_available: u.is_update_available(),
let updater = handle.updater().map_err(|e| {
log::error!("Failed to get updater: {}", e);
BackendError::CheckAppVersionError
})?;
// Then check for updates
let update_info = updater.check().await.map_err(|e| {
log::error!("An error occurred while checking for app update {}", e);
BackendError::CheckAppVersionError
})?;
// Process the result
if let Some(update) = update_info {
log::debug!(
"<<< update available: [true], current version {}, latest version {}",
update.current_version,
update.version
);
Ok(AppVersion {
current_version: update.current_version.to_string(),
latest_version: update.version,
is_update_available: true,
})
.map_err(|e| {
log::error!("An error ocurred while checking for app update {}", e);
BackendError::CheckAppVersionError
})?;
log::debug!(
"<<< update available: [{}], current version {}, latest version {}",
res.is_update_available,
res.current_version,
res.latest_version
);
Ok(res)
} else {
// No update available
let current_version = handle.package_info().version.to_string();
log::debug!(
"<<< update available: [false], current version {}",
current_version
);
Ok(AppVersion {
current_version: current_version.clone(),
latest_version: current_version,
is_update_available: false,
})
}
}
@@ -26,30 +26,30 @@ async fn create_window(
) -> Result<(), BackendError> {
// create the new window first, to stop the app process from exiting
log::info!("Creating {} window...", new_window_label);
match tauri::WindowBuilder::new(
match tauri::WebviewWindowBuilder::new(
&app_handle,
new_window_label,
tauri::WindowUrl::App(new_window_url.into()),
tauri::WebviewUrl::App(new_window_url.into()),
)
.title("Nym Wallet")
.build()
{
Ok(window) => {
if let Err(err) = window.set_focus() {
log::error!("Unable to focus log window: {err}");
log::error!("Unable to focus window: {err}");
}
if let Err(err) = window.maximize() {
log::error!("Could not maximize window: {err}");
}
}
Err(err) => {
log::error!("Unable to create log window: {err}");
log::error!("Unable to create window: {err}");
return Err(BackendError::NewWindowError);
}
}
// close the old window
match app_handle.windows().get(try_close_window_label) {
match app_handle.get_webview_window(try_close_window_label) {
Some(try_close_window) => {
if let Err(err) = try_close_window.close() {
log::error!("Could not close window: {err}")
@@ -3,7 +3,7 @@ use tauri::Manager;
#[tauri::command]
pub fn help_log_toggle_window(app_handle: tauri::AppHandle) -> Result<(), BackendError> {
if let Some(current_log_window) = app_handle.windows().get("log") {
if let Some(current_log_window) = app_handle.get_webview_window("log") {
log::info!("Closing log window...");
if let Err(err) = current_log_window.close() {
log::error!("Unable to close log window: {err}");
@@ -12,9 +12,13 @@ pub fn help_log_toggle_window(app_handle: tauri::AppHandle) -> Result<(), Backen
}
log::info!("Creating log window...");
match tauri::WindowBuilder::new(&app_handle, "log", tauri::WindowUrl::App("log.html".into()))
.title("Nym Wallet Logs")
.build()
match tauri::WebviewWindowBuilder::new(
&app_handle,
"log",
tauri::WebviewUrl::App("log.html".into()),
)
.title("Nym Wallet Logs")
.build()
{
Ok(window) => {
if let Err(err) = window.set_focus() {
@@ -407,14 +407,22 @@ pub async fn get_all_mix_delegations(
d.height
);
let timestamp = client
.nyxd
.get_block_timestamp(Some(d.height as u32))
.await
.tap_err(|err| {
.nyxd
.get_block_timestamp(Some(d.height as u32))
.await
.tap_err(|err| {
let error_message = err.to_string();
// Check if the error is related to height not being available (pruning)
if error_message.contains("height") && error_message.contains("not available") {
let str_err = "Due to pruning strategies from validators, please navigate to the Settings tab and change your RPC node for your validator to retrieve your delegations.";
log::error!(" <<< {}", str_err);
error_strings.push(str_err.to_string());
} else {
let str_err = format!("Failed to get block timestamp for height = {} for delegation to mix_id = {}. Error: {}", d.height, d.mix_id, err);
log::error!(" <<< {}", str_err);
error_strings.push(str_err);
}).ok();
}
}).ok();
let delegated_on_iso_datetime = timestamp.map(|ts| ts.to_rfc3339());
log::trace!(
" <<< timestamp = {:?}, delegated_on_iso_datetime = {:?}",
@@ -32,7 +32,7 @@ pub async fn send(
.nyxd
.send(&to_address, vec![amount_base], memo, fee)
.await?;
log::info!("<<< tx hash = {}", raw_res.hash.to_string());
log::info!("<<< tx hash = {}", raw_res.hash);
let res = SendTxResult::new(
raw_res,
TransactionDetails::new(amount, from_address, to_address.to_string()),
@@ -109,18 +109,6 @@ pub async fn mixnode_stake_saturation(
.await?)
}
// TODO: fix later (yeah...)
#[allow(deprecated)]
#[tauri::command]
pub async fn mixnode_inclusion_probability(
mix_id: NodeId,
state: tauri::State<'_, WalletState>,
) -> Result<nym_validator_client::models::InclusionProbabilityResponse, BackendError> {
Ok(api_client!(state)
.get_mixnode_inclusion_probability(mix_id)
.await?)
}
#[tauri::command]
pub async fn get_nymnode_role(
node_id: NodeId,
+11 -1
View File
@@ -40,7 +40,17 @@ pub(crate) const DEFAULT_LOGIN_ID: &str = "default";
pub(crate) const DEFAULT_FIRST_ACCOUNT_NAME: &str = "Account 1";
fn get_storage_directory() -> Result<PathBuf, BackendError> {
tauri::api::path::local_data_dir()
// tauri v1 (via `tauri::api::path::local_data_dir()`) was internally calling `dirs_next::data_local_dir()`
// which ultimately was getting resolved to
// - **Linux:** Resolves to `$XDG_DATA_HOME` or `$HOME/.local/share`.
// - **macOS:** Resolves to `$HOME/Library/Application Support`.
// - **Windows:** Resolves to `{FOLDERID_LocalAppData}`.
//
// tauri v2 calls `dirs::data_local_dir().ok_or(Error::UnknownPath)` which ultimately does the same thing,
// however, it changed its API so that it's called on a `PathResolver`.
// but, to instantiate one here would be a hassle as we don't need those specific functionalities,
// so let's just recreate tauri's behaviour
dirs::data_local_dir()
.map(|dir| dir.join(STORAGE_DIR_NAME))
.ok_or(BackendError::UnknownStorageDirectory)
}
+52 -60
View File
@@ -1,78 +1,70 @@
{
"package": {
"productName": "nym-wallet",
"version": "1.2.17"
},
"build": {
"distDir": "../dist",
"devPath": "http://localhost:9000",
"beforeDevCommand": "",
"beforeBuildCommand": ""
},
"tauri": {
"bundle": {
"active": true,
"targets": "all",
"identifier": "net.nymtech.wallet",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [],
"externalBin": [],
"copyright": "Copyright © 2021-2023 Nym Technologies SA",
"category": "Business",
"shortDescription": "Nym desktop wallet allows you to manage your NYM tokens",
"longDescription": "",
"bundle": {
"active": true,
"windows": {
"certificateThumbprint": "6DB77B1F529A0804FE0E6843A3EB8A8CECFFD408",
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.comodoca.com"
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [],
"externalBin": [],
"copyright": "Copyright © 2021-2025 Nym Technologies SA",
"category": "Business",
"shortDescription": "Nym desktop wallet allows you to manage your NYM tokens",
"longDescription": "",
"macOS": {
"frameworks": [],
"minimumSystemVersion": "15.3.2",
"exceptionDomain": "",
"signingIdentity": "Developer ID Application: Nym Technologies SA (VW5DZLFHM5)",
"entitlements": null
},
"linux": {
"deb": {
"depends": []
},
"macOS": {
"frameworks": [],
"minimumSystemVersion": "",
"exceptionDomain": "",
"signingIdentity": "Developer ID Application: Nym Technologies SA (VW5DZLFHM5)",
"entitlements": null
},
"windows": {
"certificateThumbprint": "6DB77B1F529A0804FE0E6843A3EB8A8CECFFD408",
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.comodoca.com"
}
},
"createUpdaterArtifacts": "v1Compatible"
},
"build": {
"beforeBuildCommand": "",
"frontendDist": "../dist",
"beforeDevCommand": "",
"devUrl": "http://localhost:9000"
},
"productName": "NymWallet",
"mainBinaryName": "NymWallet",
"version": "1.2.18",
"identifier": "net.nymtech.wallet",
"plugins": {
"updater": {
"active": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IENCNzQ2M0E5N0VFODE2NApSV1JrZ2U2WE9rYTNETTg1OTBKdE5uWUEra0hML2syOVUvQ2lxZmFZRzZ1T3NWbGM0eVRzUTVhVwo=",
"endpoints": [
"https://nymtech.net/.wellknown/wallet/updater.json"
]
}
},
"app": {
"security": {
"capabilities": [
"main-capability"
],
"dialog": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IENCNzQ2M0E5N0VFODE2NApSV1JrZ2U2WE9rYTNETTg1OTBKdE5uWUEra0hML2syOVUvQ2lxZmFZRzZ1T3NWbGM0eVRzUTVhVwo="
},
"allowlist": {
"window": {
"maximize": true,
"print": true
},
"clipboard": {
"all": true
},
"shell": {
"open": true
}
"csp": "default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'; connect-src ipc: http://ipc.localhost"
},
"windows": [
{
"title": "Nym Wallet",
"width": 1268,
"height": 768,
"resizable": true
"resizable": true,
"useHttpsScheme": true
}
],
"security": {
"csp": "default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'"
}
]
}
}
@@ -1,5 +1,6 @@
import React, { useContext } from 'react';
import EditIcon from '@mui/icons-material/Create';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import {
Box,
IconButton,
@@ -9,6 +10,8 @@ import {
ListItemText,
Tooltip,
Typography,
alpha,
useTheme,
} from '@mui/material';
import { useClipboard } from 'use-clipboard-copy';
import { AccountsContext } from 'src/context';
@@ -25,29 +28,94 @@ export const AccountItem = ({
}) => {
const { selectedAccount, setDialogToDisplay, setAccountMnemonic, handleAccountToEdit } = useContext(AccountsContext);
const { copy, copied } = useClipboard({ copiedTimeout: 1000 });
const theme = useTheme();
const isSelected = selectedAccount?.id === name;
return (
<ListItem
disablePadding
disableGutters
sx={selectedAccount?.id === name ? { bgcolor: 'rgba(33, 208, 115, 0.1)' } : {}}
sx={{
borderRadius: 2,
my: 0.5,
mx: 1,
overflow: 'hidden',
position: 'relative',
bgcolor: isSelected
? alpha(theme.palette.nym.highlight, theme.palette.mode === 'dark' ? 0.15 : 0.08)
: 'transparent',
borderLeft: isSelected ? `3px solid ${theme.palette.nym.highlight}` : '3px solid transparent',
}}
secondaryAction={
<IconButton
sx={{ mr: 2, color: 'nym.text.dark' }}
onClick={() => {
handleAccountToEdit(name);
setDialogToDisplay('Edit');
}}
>
<EditIcon fontSize="small" />
</IconButton>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{isSelected && (
<CheckCircleIcon
sx={{
color: theme.palette.nym.highlight,
fontSize: 18,
}}
/>
)}
<IconButton
sx={{
mr: 1.5,
color: theme.palette.mode === 'dark' ? 'nym.text.dark' : theme.palette.text.primary,
backgroundColor: alpha(theme.palette.text.primary, 0.05),
'&:hover': {
backgroundColor: alpha(theme.palette.text.primary, 0.1),
},
width: 30,
height: 30,
}}
onClick={() => {
handleAccountToEdit(name);
setDialogToDisplay('Edit');
}}
>
<EditIcon fontSize="small" />
</IconButton>
</Box>
}
>
<ListItemButton disableRipple onClick={onSelectAccount}>
<ListItemAvatar sx={{ minWidth: 0, mr: 2 }}>
<ListItemButton
disableRipple
onClick={onSelectAccount}
sx={{
py: 1,
transition: 'background-color 0.2s',
'&:hover': {
backgroundColor: isSelected
? alpha(theme.palette.nym.highlight, theme.palette.mode === 'dark' ? 0.2 : 0.12)
: alpha(theme.palette.nym.nymWallet.hover.background, 0.5),
},
}}
>
{/* Account avatar with box wrapper to apply styling */}
<ListItemAvatar
sx={{
minWidth: 0,
mr: 2,
'& .MuiAvatar-root': {
border: isSelected ? `2px solid ${theme.palette.nym.highlight}` : '2px solid transparent',
transition: 'all 0.2s',
},
}}
>
<AccountAvatar name={name} />
</ListItemAvatar>
<ListItemText
primary={name}
primary={
<Typography
variant="subtitle1"
sx={{
fontWeight: isSelected ? 600 : 400,
color: theme.palette.text.primary,
}}
>
{name}
</Typography>
}
secondary={
<Box>
<Tooltip title={copied ? 'Copied!' : `Click to copy address ${address}`}>
@@ -58,7 +126,18 @@ export const AccountItem = ({
e.stopPropagation();
copy(address);
}}
sx={{ '&:hover': { color: 'grey.900' } }}
sx={{
fontFamily: 'monospace',
cursor: 'pointer',
color:
theme.palette.mode === 'dark'
? theme.palette.nym.nymWallet.text.muted
: alpha(theme.palette.text.primary, 0.7),
'&:hover': {
color: theme.palette.text.primary,
textDecoration: 'underline',
},
}}
>
{address}
</Typography>
@@ -67,7 +146,19 @@ export const AccountItem = ({
<Typography
variant="body2"
component="span"
sx={{ textDecoration: 'underline', mb: 0.5, '&:hover': { color: 'primary.main' } }}
sx={{
textDecoration: 'underline',
mb: 0.5,
cursor: 'pointer',
color:
theme.palette.mode === 'dark'
? alpha(theme.palette.nym.highlight, 0.9)
: theme.palette.nym.highlight,
'&:hover': {
color: theme.palette.nym.highlight,
fontWeight: 500,
},
}}
onClick={(e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
setDialogToDisplay('Mnemonic');
@@ -2,7 +2,6 @@ import React, { useContext, useState } from 'react';
import {
Box,
Button,
Paper,
Dialog,
DialogActions,
DialogContent,
@@ -10,9 +9,11 @@ import {
IconButton,
Typography,
Divider,
alpha,
useTheme,
List,
} from '@mui/material';
import { Add, ArrowDownwardSharp, Close } from '@mui/icons-material';
import { useTheme } from '@mui/material/styles';
import { Add, ArrowDownwardSharp, Close, SwapHorizOutlined } from '@mui/icons-material';
import { AccountsContext } from 'src/context';
import { AccountItem } from '../AccountItem';
import { ConfirmPasswordModal } from './ConfirmPasswordModal';
@@ -52,23 +53,91 @@ export const AccountsModal = () => {
open={dialogToDisplay === 'Accounts'}
onClose={handleClose}
fullWidth
maxWidth="sm"
PaperProps={{
style: { border: `1px solid ${theme.palette.nym.nymWallet.modal.border}` },
elevation: 4,
sx: {
borderRadius: 2,
overflow: 'hidden',
border: `1px solid ${theme.palette.nym.nymWallet.modal.border}`,
display: 'flex',
flexDirection: 'column',
maxHeight: '80vh', // Limit maximum height
...(theme.palette.mode === 'dark' && {
backgroundImage: 'linear-gradient(180deg, rgba(50, 55, 61, 0.8), rgba(36, 43, 45, 0.95))',
}),
},
}}
>
<Paper>
<DialogTitle>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6">Accounts</Typography>
<IconButton onClick={handleClose}>
<Close />
</IconButton>
<DialogTitle sx={{ pb: 1, flexShrink: 0 }}>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Box display="flex" alignItems="center" gap={1}>
<SwapHorizOutlined
sx={{
color: theme.palette.nym.highlight,
backgroundColor: alpha(theme.palette.nym.highlight, 0.1),
borderRadius: '50%',
p: 0.5,
fontSize: 24,
}}
/>
<Typography variant="h6" fontWeight={600}>
Accounts
</Typography>
</Box>
<Typography fontSize="small" sx={{ color: 'grey.600' }}>
Switch between accounts
</Typography>
</DialogTitle>
<DialogContent sx={{ padding: 0 }}>
<IconButton
onClick={handleClose}
size="small"
sx={{
backgroundColor: alpha(theme.palette.text.primary, 0.05),
'&:hover': {
backgroundColor: alpha(theme.palette.text.primary, 0.1),
},
width: 30,
height: 30,
}}
>
<Close fontSize="small" />
</IconButton>
</Box>
<Typography
variant="body2"
sx={{
color:
theme.palette.mode === 'dark'
? theme.palette.nym.nymWallet.text.muted
: alpha(theme.palette.text.primary, 0.6),
pl: 4.5,
}}
>
Switch between accounts
</Typography>
</DialogTitle>
<DialogContent
sx={{
px: 1,
pt: 0,
flexGrow: 1,
overflowY: 'auto', // Enable vertical scrolling
minHeight: '100px', // Ensure minimum height for content
'&::-webkit-scrollbar': {
width: '8px',
height: '8px',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor:
theme.palette.mode === 'dark'
? alpha(theme.palette.nym.nymWallet.background.greyStroke, 0.8)
: alpha(theme.palette.nym.nymWallet.background.greyStroke, 0.5),
borderRadius: '4px',
},
'&::-webkit-scrollbar-track': {
backgroundColor: 'transparent',
},
}}
>
<List sx={{ py: 1 }}>
{accounts?.map(({ id, address }) => (
<AccountItem
name={id}
@@ -81,22 +150,64 @@ export const AccountsModal = () => {
}}
/>
))}
</DialogContent>
<Divider variant="middle" sx={{ mt: 3 }} />
<DialogActions sx={{ p: 3 }}>
<Button startIcon={<ArrowDownwardSharp />} onClick={() => setDialogToDisplay('Import')}>
</List>
</DialogContent>
<Box sx={{ flexShrink: 0 }}>
<Divider
variant="middle"
sx={{
my: 1.5,
opacity: 0.6,
}}
/>
<DialogActions
sx={{
p: 3,
justifyContent: 'space-between',
}}
>
<Button
startIcon={<ArrowDownwardSharp />}
onClick={() => setDialogToDisplay('Import')}
sx={{
borderRadius: 1.5,
transition: 'all 0.2s',
px: 2,
py: 0.75,
color: theme.palette.text.primary,
'&:hover': {
backgroundColor: alpha(theme.palette.text.primary, 0.05),
},
}}
>
Import account
</Button>
<Button
disableElevation
variant="contained"
startIcon={<Add fontSize="small" />}
startIcon={<Add fontSize="medium" />}
onClick={() => setDialogToDisplay('Add')}
sx={{
px: 2,
py: 0.75,
borderRadius: 1.5,
background: theme.palette.nym.nymWallet.gradients.primary || theme.palette.nym.highlight,
fontWeight: 600,
boxShadow: 'none',
transition: 'all 0.2s',
'&:hover': {
boxShadow:
theme.palette.mode === 'dark' ? '0 4px 12px rgba(0, 0, 0, 0.2)' : '0 4px 12px rgba(0, 0, 0, 0.1)',
transform: 'translateY(-1px)',
},
}}
>
Create account
</Button>
</DialogActions>
</Paper>
</Box>
</Dialog>
);
};
@@ -1,7 +1,6 @@
/* eslint-disable react/no-array-index-key */
import React, { useContext } from 'react';
import { useTheme } from '@mui/material/styles';
import { Box, Stack, Tooltip, Typography } from '@mui/material';
import { Box, Stack, Tooltip, Typography, Skeleton } from '@mui/material';
import { format } from 'date-fns';
import { AppContext } from 'src/context';
@@ -17,7 +16,10 @@ const Marker: FCWithChildren<{ tooltipText: string; color: string; position: str
</Tooltip>
);
export const VestingTimeline: FCWithChildren<{ percentageComplete: number }> = ({ percentageComplete }) => {
export const VestingTimeline: FCWithChildren<{ percentageComplete: number; isLoading?: boolean }> = ({
percentageComplete,
isLoading,
}) => {
const {
userBalance: { currentVestingPeriod, vestingAccountInfo },
} = useContext(AppContext);
@@ -32,34 +34,48 @@ export const VestingTimeline: FCWithChildren<{ percentageComplete: number }> = (
return (
<Box>
<Stack direction="row" gap={1} alignItems="center">
<Typography variant="body2">{percentageComplete}%</Typography>
<svg width="100%" height="12">
<rect y="2" width="100%" height="6" rx="0" fill="#E6E6E6" />
<rect y="2" width={`${percentageComplete}%`} height="6" rx="0" fill={theme.palette.success.main} />
{vestingAccountInfo?.periods.map((period, i, arr) => (
<Marker
position={`${calculateMarkerPosition(arr.length, i)}%`}
color={
Math.ceil(+percentageComplete) >= calculateMarkerPosition(arr.length, i)
? theme.palette.success.main
: '#B9B9B9'
}
tooltipText={format(new Date(Number(period.start_time) * 1000), 'HH:mm do MMM yyyy')}
key={i}
/>
))}
<Marker
position="calc(100% - 4px)"
color={percentageComplete === 100 ? theme.palette.success.main : '#B9B9B9'}
tooltipText="End of vesting schedule"
/>
</svg>
{isLoading ? (
<>
<Skeleton width={40} />
<Skeleton width="100%" height={12} />
</>
) : (
<>
<Typography variant="body2">{percentageComplete}%</Typography>
<svg width="100%" height="12">
<rect y="2" width="100%" height="6" rx="0" fill="#E6E6E6" />
<rect y="2" width={`${percentageComplete}%`} height="6" rx="0" fill={theme.palette.success.main} />
{vestingAccountInfo?.periods.map((period, i, arr) => (
<Marker
position={`${calculateMarkerPosition(arr.length, i)}%`}
color={
Math.ceil(+percentageComplete) >= calculateMarkerPosition(arr.length, i)
? theme.palette.success.main
: '#B9B9B9'
}
tooltipText={format(new Date(Number(period.start_time) * 1000), 'HH:mm do MMM yyyy')}
key={`period-${period.start_time}`}
/>
))}
<Marker
position="calc(100% - 4px)"
color={percentageComplete === 100 ? theme.palette.success.main : '#B9B9B9'}
tooltipText="End of vesting schedule"
/>
</svg>
</>
)}
</Stack>
{!!nextPeriod && (
{!!nextPeriod && !isLoading && (
<Typography variant="caption" sx={{ color: 'nym.text.muted', ml: 6 }}>
Next vesting period: {format(new Date(nextPeriod * 1000), 'HH:mm do MMM yyyy')}
</Typography>
)}
{isLoading && (
<Box sx={{ ml: 6, mt: 1 }}>
<Skeleton width={240} height={14} />
</Box>
)}
</Box>
);
};
@@ -1,5 +1,5 @@
import React from 'react';
import { Card, Stack, Button } from '@mui/material';
import { Card, Stack, Button, Skeleton } from '@mui/material';
import { ModalListItem } from 'src/components/Modals/ModalListItem';
export const TokenTransfer = ({
@@ -7,20 +7,27 @@ export const TokenTransfer = ({
unlockedTokens,
unlockedRewards,
unlockedTransferable,
isLoading,
}: {
unlockedTokens?: string;
unlockedRewards?: string;
unlockedTransferable?: string;
onTransfer: () => void;
isLoading?: boolean;
}) => (
<Card variant="outlined" sx={{ p: 3, height: '100%' }}>
<Stack justifyContent="space-between" sx={{ height: '100%' }}>
<Stack gap={1} sx={{ mb: 2 }}>
<ModalListItem label="Unlocked tokens" value={unlockedTokens} />
<ModalListItem label="Unlocked rewards" value={unlockedRewards} divider />
<ModalListItem fontSize={16} label="Transferable tokens" value={unlockedTransferable} fontWeight={600} />
<ModalListItem label="Unlocked tokens" value={isLoading ? <Skeleton width={80} /> : unlockedTokens} />
<ModalListItem label="Unlocked rewards" value={isLoading ? <Skeleton width={80} /> : unlockedRewards} divider />
<ModalListItem
fontSize={16}
label="Transferable tokens"
value={isLoading ? <Skeleton width={100} /> : unlockedTransferable}
fontWeight={600}
/>
</Stack>
<Button size="large" fullWidth variant="contained" onClick={onTransfer} disableElevation>
<Button size="large" fullWidth variant="contained" onClick={onTransfer} disableElevation disabled={isLoading}>
Transfer
</Button>
</Stack>
@@ -9,6 +9,7 @@ import {
Typography,
TableCellProps,
Card,
Skeleton,
} from '@mui/material';
import { Period } from '@nymproject/types';
import { AppContext } from 'src/context';
@@ -28,7 +29,7 @@ const vestingPeriod = (current?: Period, original?: number) => {
return 'N/A';
};
export const VestingSchedule = () => {
export const VestingSchedule = ({ isLoading }: { isLoading?: boolean }) => {
const { userBalance, clientDetails } = useContext(AppContext);
const [vestedPercentage, setVestedPercentage] = useState(0);
@@ -73,8 +74,14 @@ export const VestingSchedule = () => {
textTransform: 'uppercase',
}}
>
{userBalance.tokenAllocation?.vesting || 'n/a'} / {userBalance.originalVesting?.amount.amount}{' '}
{clientDetails?.display_mix_denom.toUpperCase()}
{isLoading ? (
<Skeleton width={100} />
) : (
<>
{userBalance.tokenAllocation?.vesting || 'n/a'} / {userBalance.originalVesting?.amount.amount}{' '}
{clientDetails?.display_mix_denom.toUpperCase()}
</>
)}
</TableCell>
<TableCell
align="left"
@@ -83,7 +90,11 @@ export const VestingSchedule = () => {
borderBottom: 'none',
}}
>
{vestingPeriod(userBalance.currentVestingPeriod, userBalance.originalVesting?.number_of_periods)}
{isLoading ? (
<Skeleton width={40} />
) : (
vestingPeriod(userBalance.currentVestingPeriod, userBalance.originalVesting?.number_of_periods)
)}
</TableCell>
<TableCell
sx={{
@@ -93,8 +104,14 @@ export const VestingSchedule = () => {
}}
align="right"
>
{userBalance.tokenAllocation?.vested || 'n/a'} / {userBalance.originalVesting?.amount.amount}{' '}
{clientDetails?.display_mix_denom.toUpperCase()}
{isLoading ? (
<Skeleton width={100} />
) : (
<>
{userBalance.tokenAllocation?.vested || 'n/a'} / {userBalance.originalVesting?.amount.amount}{' '}
{clientDetails?.display_mix_denom.toUpperCase()}
</>
)}
</TableCell>
</TableRow>
</TableBody>
@@ -103,7 +120,7 @@ export const VestingSchedule = () => {
<Typography variant="body2" sx={{ color: 'nym.text.muted', mb: 3 }}>
Percentage
</Typography>
<VestingTimeline percentageComplete={vestedPercentage} />
<VestingTimeline percentageComplete={vestedPercentage} isLoading={isLoading} />
</Card>
);
};
@@ -1,6 +1,6 @@
import React from 'react';
import { Stack, Typography } from '@mui/material';
import { Link } from '@nymproject/react/link/Link';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
import { ConfirmationModal } from 'src/components';
export type TTransactionDetails = { amount: string; url: string };
+1 -1
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { Link } from '@nymproject/react/link/Link';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
import { Box, Button, Typography } from '@mui/material';
import { NymCard } from '../NymCard';
@@ -1,6 +1,6 @@
import React from 'react';
import { Box, Button, Stack, Tooltip, Typography } from '@mui/material';
import { Link } from '@nymproject/react/link/Link';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
import { urls } from 'src/context';
import { NymCard } from 'src/components';
import { Network } from 'src/types';
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Button, Chip, Stack, Tooltip, Typography } from '@mui/material';
import { Link } from '@nymproject/react/link/Link';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
import { Network } from 'src/types';
import { urls } from 'src/context';
import { NymCard } from 'src/components';
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Button, Chip, Stack, Tooltip, Typography } from '@mui/material';
import { Link } from '@nymproject/react/link/Link';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
import { Network } from 'src/types';
import { urls } from 'src/context';
import { NymCard } from 'src/components';
@@ -1,46 +0,0 @@
import React from 'react';
import { Box } from '@mui/material';
import { NodeTypeSelector } from 'src/components';
import { CurrencyDenom, TNodeType } from '@nymproject/types';
import { GatewayAmount, GatewayData } from 'src/pages/bonding/types';
import GatewayInitForm from './GatewayInitForm';
import GatewayAmountForm from './GatewayAmountForm';
export const BondGatewayForm = ({
step,
denom,
gatewayData,
amountData,
hasVestingTokens,
onSelectNodeType,
onValidateGatewayData,
onValidateAmountData,
}: {
step: 1 | 2 | 3 | 4;
gatewayData: GatewayData;
amountData: GatewayAmount;
denom: CurrencyDenom;
hasVestingTokens: boolean;
onSelectNodeType: (nodeType: TNodeType) => void;
onValidateGatewayData: (data: GatewayData) => void;
onValidateAmountData: (data: GatewayAmount) => Promise<void>;
}) => (
<>
{step === 1 && (
<>
<Box sx={{ mb: 2 }}>
<NodeTypeSelector disabled={false} setNodeType={onSelectNodeType} nodeType="gateway" />
</Box>
<GatewayInitForm onNext={onValidateGatewayData} gatewayData={gatewayData} />
</>
)}
{step === 2 && (
<GatewayAmountForm
denom={denom}
amountData={amountData}
hasVestingTokens={hasVestingTokens}
onNext={onValidateAmountData}
/>
)}
</>
);
@@ -1,84 +0,0 @@
import React, { useEffect } from 'react';
import { CurrencyDenom } from '@nymproject/types';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup/dist/yup';
import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField';
import { Box, Stack } from '@mui/material';
import { amountSchema } from './gatewayValidationSchema';
import { checkHasEnoughFunds, checkHasEnoughLockedTokens } from '../../../../utils';
import { GatewayAmount } from '../../../../pages/bonding/types';
import { TokenPoolSelector } from '../../../TokenPoolSelector';
const GatewayAmountForm = ({
denom,
amountData,
hasVestingTokens,
onNext,
}: {
denom: CurrencyDenom;
amountData: GatewayAmount;
hasVestingTokens: boolean;
onNext: (data: any) => void;
}) => {
const {
formState: { errors },
handleSubmit,
setValue,
getValues,
setError,
} = useForm({ resolver: yupResolver(amountSchema), defaultValues: amountData });
const handleRequestValidation = async (event: { detail: { step: number } }) => {
let hasSufficientTokens = true;
const values = getValues();
if (values.tokenPool === 'balance') {
hasSufficientTokens = await checkHasEnoughFunds(values.amount.amount);
}
if (values.tokenPool === 'locked') {
hasSufficientTokens = await checkHasEnoughLockedTokens(values.amount.amount);
}
if (event.detail.step === 2 && hasSufficientTokens) {
handleSubmit(onNext)();
} else {
setError('amount.amount', { message: 'Not enough tokens' });
}
};
useEffect(() => {
window.addEventListener('validate_bond_gateway_step' as any, handleRequestValidation);
return () => window.removeEventListener('validate_bond_gateway_step' as any, handleRequestValidation);
}, []);
return (
<Stack gap={3}>
<Box display="flex" gap={3} justifyContent="center" sx={{ mt: 2 }}>
{hasVestingTokens && <TokenPoolSelector disabled={false} onSelect={(pool) => setValue('tokenPool', pool)} />}
<CurrencyFormField
required
fullWidth
label="Amount"
autoFocus
onChanged={(newValue) => setValue('amount', newValue, { shouldValidate: true })}
validationError={errors.amount?.amount?.message}
denom={denom}
initialValue={amountData.amount.amount}
/>
<CurrencyFormField
required
fullWidth
label="Operator Cost"
autoFocus
onChanged={(newValue) => setValue('operatorCost', newValue, { shouldValidate: true })}
validationError={errors.operatorCost?.amount?.message}
denom={denom}
initialValue={amountData.operatorCost.amount}
/>
</Box>
</Stack>
);
};
export default GatewayAmountForm;
@@ -1,124 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { clean } from 'semver';
import { Checkbox, FormControlLabel, Stack, TextField } from '@mui/material';
import { IdentityKeyFormField } from '@nymproject/react/mixnodes/IdentityKeyFormField';
import { yupResolver } from '@hookform/resolvers/yup/dist/yup';
import { GatewayData } from '../../../../pages/bonding/types';
import { gatewayValidationSchema } from './gatewayValidationSchema';
const GatewayInitForm = ({
gatewayData,
onNext,
}: {
gatewayData: GatewayData;
onNext: (data: GatewayData) => void;
}) => {
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const {
register,
formState: { errors },
handleSubmit,
setValue,
} = useForm({ resolver: yupResolver(gatewayValidationSchema), defaultValues: gatewayData });
const handleRequestValidation = (event: { detail: { step: number } }) => {
if (event.detail.step === 1) {
handleSubmit((data) => {
const validatedData = {
...data,
version: clean(data.version) as string,
};
onNext(validatedData);
})();
}
};
useEffect(() => {
window.addEventListener('validate_bond_gateway_step' as any, handleRequestValidation);
return () => window.removeEventListener('validate_bond_gateway_step' as any, handleRequestValidation);
}, []);
return (
<Stack gap={3}>
<IdentityKeyFormField
required
fullWidth
label="Identity Key"
initialValue={gatewayData?.identityKey}
errorText={errors.identityKey?.message}
onChanged={(value) => setValue('identityKey', value)}
showTickOnValid={false}
/>
<TextField
{...register('sphinxKey')}
name="sphinxKey"
label="Sphinx key"
error={Boolean(errors.sphinxKey)}
helperText={errors.sphinxKey?.message}
InputLabelProps={{ shrink: true }}
/>
<TextField
{...register('location')}
name="location"
label="Location"
error={Boolean(errors.location)}
helperText={errors.location?.message}
required
InputLabelProps={{ shrink: true }}
sx={{ flexBasis: '50%' }}
/>
<Stack direction="row" gap={3}>
<TextField
{...register('host')}
name="host"
label="Host"
error={Boolean(errors.host)}
helperText={errors.host?.message}
required
InputLabelProps={{ shrink: true }}
sx={{ flexBasis: '50%' }}
/>
<TextField
{...register('version')}
name="version"
label="Version"
error={Boolean(errors.version)}
helperText={errors.version?.message}
required
InputLabelProps={{ shrink: true }}
sx={{ flexBasis: '50%' }}
/>
</Stack>
<FormControlLabel
control={<Checkbox onChange={() => setShowAdvancedOptions((show) => !show)} checked={showAdvancedOptions} />}
label="Show advanced options"
/>
{showAdvancedOptions && (
<Stack direction="row" gap={3} sx={{ mb: 2 }}>
<TextField
{...register('mixPort')}
name="mixPort"
label="Mix port"
error={Boolean(errors.mixPort)}
helperText={errors.mixPort?.message}
fullWidth
InputLabelProps={{ shrink: true }}
/>
<TextField
{...register('clientsPort')}
name="clientsPort"
label="Client WS API port"
error={Boolean(errors.clientsPort)}
helperText={errors.clientsPort?.message}
fullWidth
InputLabelProps={{ shrink: true }}
/>
</Stack>
)}
</Stack>
);
};
export default GatewayInitForm;
@@ -1,125 +0,0 @@
import React, { useContext, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Box, FormHelperText, Stack, TextField, Typography } from '@mui/material';
import { yupResolver } from '@hookform/resolvers/yup/dist/yup';
import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField';
import { CurrencyDenom } from '@nymproject/types';
import { amountSchema } from './mixnodeValidationSchema';
import { MixnodeAmount } from '../../../../pages/bonding/types';
import { AppContext } from '../../../../context';
import { checkHasEnoughFunds, checkHasEnoughLockedTokens } from '../../../../utils';
import { TokenPoolSelector } from '../../../TokenPoolSelector';
import { ModalListItem } from '../../../Modals/ModalListItem';
const MixnodeAmountForm = ({
amountData,
hasVestingTokens,
denom,
onNext,
}: {
amountData: MixnodeAmount;
hasVestingTokens: boolean;
denom: CurrencyDenom;
onNext: (data: MixnodeAmount) => void;
}) => {
const { mixnetContractParams } = useContext(AppContext);
const {
register,
formState: { errors },
handleSubmit,
setValue,
getValues,
setError,
} = useForm({ resolver: yupResolver(amountSchema(mixnetContractParams)), defaultValues: amountData });
const { userBalance } = useContext(AppContext);
const handleRequestValidation = async (event: { detail: { step: number } }) => {
let hasSufficientTokens = true;
const values = getValues();
if (values.tokenPool === 'balance') {
hasSufficientTokens = await checkHasEnoughFunds(values.amount.amount);
}
if (values.tokenPool === 'locked') {
hasSufficientTokens = await checkHasEnoughLockedTokens(values.amount.amount);
}
if (event.detail.step === 2 && hasSufficientTokens) {
handleSubmit(onNext)();
} else {
setError('amount.amount', { message: 'Not enough tokens' });
}
};
useEffect(() => {
window.addEventListener('validate_bond_mixnode_step' as any, handleRequestValidation);
return () => window.removeEventListener('validate_bond_mixnode_step' as any, handleRequestValidation);
}, []);
return (
<Stack gap={3}>
<Box display="flex" gap={3} justifyContent="center">
{hasVestingTokens && <TokenPoolSelector disabled={false} onSelect={(pool) => setValue('tokenPool', pool)} />}
<CurrencyFormField
required
fullWidth
label="Amount"
autoFocus
onChanged={(newValue) => {
setValue('amount', newValue, { shouldValidate: true });
}}
validationError={errors.amount?.amount?.message}
denom={denom}
initialValue={amountData.amount.amount}
/>
</Box>
<Box>
<CurrencyFormField
required
fullWidth
label="Operating cost"
onChanged={(newValue) => {
setValue('operatorCost', newValue, { shouldValidate: true });
}}
validationError={errors.operatorCost?.amount?.message}
denom={denom}
initialValue={amountData.operatorCost.amount}
/>
<FormHelperText>
Monthly operational costs of running your node. If your node is in the active set the amount will be paid back
to you from the rewards.
</FormHelperText>
</Box>
<Box>
<TextField
{...register('profitMargin')}
name="profitMargin"
label="Profit margin"
error={Boolean(errors.profitMargin)}
helperText={errors.profitMargin?.message}
fullWidth
/>
<FormHelperText>
The percentage of node rewards that you as the node operator take before rewards are distributed to operator
and delegators.
</FormHelperText>
</Box>
<Box sx={{ mb: 1 }}>
{!hasVestingTokens && (
<ModalListItem
divider
label="Account balance"
value={userBalance.balance?.printable_balance.toUpperCase()}
fontWeight={600}
/>
)}
<Typography variant="body2">Est. fee for this transaction will be calculated in the next page</Typography>
</Box>
</Stack>
);
};
export default MixnodeAmountForm;
@@ -1,117 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { clean } from 'semver';
import { Checkbox, FormControlLabel, Stack, TextField } from '@mui/material';
import { IdentityKeyFormField } from '@nymproject/react/mixnodes/IdentityKeyFormField';
import { yupResolver } from '@hookform/resolvers/yup/dist/yup';
import { mixnodeValidationSchema } from './mixnodeValidationSchema';
import { MixnodeData } from '../../../../pages/bonding/types';
const MixnodeInitForm = ({ mixnodeData, onNext }: { mixnodeData: MixnodeData; onNext: (data: any) => void }) => {
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const {
register,
formState: { errors },
handleSubmit,
setValue,
} = useForm({ resolver: yupResolver(mixnodeValidationSchema), defaultValues: mixnodeData });
const handleRequestValidation = (event: { detail: { step: number } }) => {
if (event.detail.step === 1) {
handleSubmit((data) => {
const validatedData = {
...data,
version: clean(data.version),
};
onNext(validatedData);
})();
}
};
useEffect(() => {
window.addEventListener('validate_bond_mixnode_step' as any, handleRequestValidation);
return () => window.removeEventListener('validate_bond_mixnode_step' as any, handleRequestValidation);
}, []);
return (
<Stack gap={3}>
<IdentityKeyFormField
required
fullWidth
label="Identity Key"
initialValue={mixnodeData?.identityKey}
errorText={errors.identityKey?.message}
onChanged={(value) => setValue('identityKey', value)}
showTickOnValid={false}
/>
<TextField
{...register('sphinxKey')}
name="sphinxKey"
label="Sphinx key"
error={Boolean(errors.sphinxKey)}
helperText={errors.sphinxKey?.message}
InputLabelProps={{ shrink: true }}
/>
<Stack direction="row" gap={3}>
<TextField
{...register('host')}
name="host"
label="Host"
error={Boolean(errors.host)}
helperText={errors.host?.message}
required
InputLabelProps={{ shrink: true }}
sx={{ flexBasis: '50%' }}
/>
<TextField
{...register('version')}
name="version"
label="Version"
error={Boolean(errors.version)}
helperText={errors.version?.message}
required
InputLabelProps={{ shrink: true }}
sx={{ flexBasis: '50%' }}
/>
</Stack>
<FormControlLabel
control={<Checkbox onChange={() => setShowAdvancedOptions((show) => !show)} checked={showAdvancedOptions} />}
label="Show advanced options"
/>
{showAdvancedOptions && (
<Stack direction="row" gap={3} sx={{ mb: 2 }}>
<TextField
{...register('mixPort')}
name="mixPort"
label="Mix port"
error={Boolean(errors.mixPort)}
helperText={errors.mixPort?.message}
fullWidth
InputLabelProps={{ shrink: true }}
/>
<TextField
{...register('verlocPort')}
name="verlocPort"
label="Verloc port"
error={Boolean(errors.verlocPort)}
helperText={errors.verlocPort?.message}
fullWidth
InputLabelProps={{ shrink: true }}
/>
<TextField
{...register('httpApiPort')}
name="httpApiPort"
label="HTTP api port"
error={Boolean(errors.httpApiPort)}
helperText={errors.httpApiPort?.message}
fullWidth
InputLabelProps={{ shrink: true }}
/>
</Stack>
)}
</Stack>
);
};
export default MixnodeInitForm;
@@ -1,91 +0,0 @@
import * as Yup from 'yup';
import {
isLessThan,
isValidHostname,
validateAmount,
validateKey,
validateLocation,
validateRawPort,
validateVersion,
} from 'src/utils';
export const gatewayValidationSchema = Yup.object().shape({
identityKey: Yup.string()
.required('An identity key is required')
.test('valid-id-key', 'A valid identity key is required', (value) => validateKey(value || '', 32)),
sphinxKey: Yup.string()
.required('A sphinx key is required')
.test('valid-sphinx-key', 'A valid sphinx key is required', (value) => validateKey(value || '', 32)),
host: Yup.string()
.required('A host is required')
.test('no-whitespace', 'Host cannot contain whitespace', (value) => !/\s/.test(value || ''))
.test('valid-host', 'A valid host is required', (value) => (value ? isValidHostname(value) : false)),
version: Yup.string()
.required('A version is required')
.test('no-whitespace', 'A version cannot contain whitespace', (value) => !/\s/.test(value || ''))
.test('valid-version', 'A valid version is required', (value) => (value ? validateVersion(value) : false)),
location: Yup.string()
.required('A location is required')
.test('valid-location', 'A valid location is required', (locationValueTest) =>
locationValueTest ? validateLocation(locationValueTest) : false,
),
mixPort: Yup.number()
.required('A mixport is required')
.test('valid-mixport', 'A valid mixport is required', (value) => (value ? validateRawPort(value) : false)),
clientsPort: Yup.number()
.required('A clients port is required')
.test('valid-clients', 'A valid clients port is required', (value) => (value ? validateRawPort(value) : false)),
});
export const amountSchema = Yup.object().shape({
amount: Yup.object().shape({
amount: Yup.string()
.required('An amount is required')
.test('valid-amount', 'Pledge error', async function isValidAmount(this, value) {
const isValid = await validateAmount(value || '', '100');
if (!isValid) {
return this.createError({ message: 'A valid amount is required (min 100)' });
}
return true;
}),
}),
operatorCost: Yup.object().shape({
amount: Yup.string()
.required('An operating cost is required')
// eslint-disable-next-line
.test('valid-operating-cost', 'A valid amount is required (min 40)', async function isValidAmount(this, value) {
if (value && (!Number(value) || isLessThan(+value, 40))) {
return false;
}
return true;
}),
}),
});
export const updateGatewayValidationSchema = Yup.object().shape({
host: Yup.string()
.required('A host is required')
.test('no-whitespace', 'Host cannot contain whitespace', (value) => !/\s/.test(value || ''))
.test('valid-host', 'A valid host is required', (value) => (value ? isValidHostname(value) : false)),
mixPort: Yup.number()
.required('A mixport is required')
.test('valid-mixport', 'A valid mixport is required', (value) => (value ? validateRawPort(value) : false)),
httpApiPort: Yup.number()
.required('A clients port is required')
.test('valid-clients', 'A valid clients port is required', (value) => (value ? validateRawPort(value) : false)),
location: Yup.string().test('valid-location', 'A valid location is required', (value) =>
value ? validateLocation(value) : false,
),
version: Yup.string()
.test('no-whitespace', 'A version cannot contain whitespace', (value) => !/\s/.test(value || ''))
.test('valid-version', 'A valid version is required', (value) => (value ? validateVersion(value) : false)),
});
@@ -1,15 +1,16 @@
import React from 'react';
import * as Yup from 'yup';
import { Stack, TextField, FormControlLabel, Checkbox } from '@mui/material';
import { Stack, FormControlLabel, Checkbox } from '@mui/material';
import { useForm } from 'react-hook-form';
import { IdentityKeyFormField } from '@nymproject/react/mixnodes/IdentityKeyFormField';
import { yupResolver } from '@hookform/resolvers/yup';
import { SimpleModal } from 'src/components/Modals/SimpleModal';
import { HookFormTextFieldWithPaste } from 'src/components/Clipboard/ClipboardFormFields';
import { useFormContext } from './FormContext';
import { settingsValidationSchema } from './settingsValidationSchema';
type NymNodeDataProps = {
onClose: () => void;
// eslint-disable-next-line react/no-unused-prop-types
onBack: () => void;
onNext: () => Promise<void>;
step: number;
@@ -53,23 +54,24 @@ const NymNodeData = ({ onClose, onNext, step }: NymNodeDataProps) => {
okDisabled={Object.keys(errors).length > 0}
>
<Stack gap={3}>
<IdentityKeyFormField
autoFocus
{/* Identity Key Field with Paste Button */}
<HookFormTextFieldWithPaste
name="identity_key"
register={register}
setValue={setValue}
errors={errors}
required
fullWidth
InputLabelProps={{ shrink: true }}
label="Identity Key"
initialValue={nymNodeData.identity_key}
errorText={errors.identity_key?.message?.toString()}
onChanged={(value) => setValue('identity_key', value, { shouldValidate: true })}
showTickOnValid={false}
/>
<TextField
{...register('host')}
{/* Host Field with Built-in Paste */}
<HookFormTextFieldWithPaste
name="host"
label="Host"
error={Boolean(errors.host)}
helperText={errors.host?.message}
register={register}
setValue={setValue}
errors={errors}
required
InputLabelProps={{ shrink: true }}
/>
@@ -78,14 +80,15 @@ const NymNodeData = ({ onClose, onNext, step }: NymNodeDataProps) => {
control={<Checkbox onChange={() => setShowAdvancedOptions((show) => !show)} checked={showAdvancedOptions} />}
label="Show advanced options"
/>
{showAdvancedOptions && (
<Stack direction="row" gap={3} sx={{ mb: 2 }}>
<TextField
{...register('custom_http_port')}
<HookFormTextFieldWithPaste
name="custom_http_port"
label="Custom HTTP port"
error={Boolean(errors.custom_http_port)}
helperText={errors.custom_http_port?.message}
register={register}
setValue={setValue}
errors={errors}
fullWidth
InputLabelProps={{ shrink: true }}
/>
@@ -1,8 +1,8 @@
import React, { useEffect, useState } from 'react';
import * as yup from 'yup';
import { Stack, TextField, Typography } from '@mui/material';
import { Stack, Typography } from '@mui/material';
import { useForm } from 'react-hook-form';
import { CopyToClipboard } from 'src/components/CopyToClipboard';
import { CopyToClipboard, TextFieldWithPaste } from 'src/components/Clipboard';
import { ErrorModal } from 'src/components/Modals/ErrorModal';
import { SimpleModal } from 'src/components/Modals/SimpleModal';
import { useBondingContext } from 'src/context';
@@ -41,6 +41,7 @@ const NymNodeSignature = ({
register,
formState: { errors },
handleSubmit,
setValue,
} = useForm<Signature>({
defaultValues: {
signature,
@@ -60,7 +61,6 @@ const NymNodeSignature = ({
setMessage(msg);
}
} catch (e) {
console.error(e);
setError('Something went wrong while generating the payload signature');
}
};
@@ -73,6 +73,11 @@ const NymNodeSignature = ({
handleSubmit(onNext)();
};
const handleSignatureChange = (value: string) => {
setSignature(value);
setValue('signature', value, { shouldValidate: true });
};
if (error) {
return <ErrorModal open message={error} onClose={() => {}} />;
}
@@ -98,14 +103,18 @@ const NymNodeSignature = ({
<br />
Then paste the signature in the next field.
</Typography>
<TextField id="outlined-multiline-static" multiline rows={7} value={message} fullWidth disabled />
<TextFieldWithPaste id="outlined-multiline-static" multiline rows={7} value={message} fullWidth disabled />
<Stack direction="row" alignItems="center" gap={1} justifyContent="end">
<Typography fontWeight={600}>Copy Message</Typography>
{message && <CopyToClipboard text={message} iconButton />}
</Stack>
<TextField
<TextFieldWithPaste
{...register('signature')}
onChange={(e) => setSignature(e.target.value)}
onPasteValue={handleSignatureChange}
onChange={(e) => handleSignatureChange(e.target.value)}
id="outlined-multiline-static"
name="signature"
rows={3}
@@ -115,6 +124,7 @@ const NymNodeSignature = ({
multiline
fullWidth
required
value={signature}
/>
</Stack>
</SimpleModal>
@@ -1,130 +0,0 @@
import React, { useContext, useEffect, useState } from 'react';
import { Box } from '@mui/material';
import { CurrencyDenom, TNodeType } from '@nymproject/types';
import { ConfirmTx } from 'src/components/ConfirmTX';
import { ModalListItem } from 'src/components/Modals/ModalListItem';
import { SimpleModal } from 'src/components/Modals/SimpleModal';
import { useGetFee } from 'src/hooks/useGetFee';
import { GatewayAmount, GatewayData } from 'src/pages/bonding/types';
import { BalanceWarning } from 'src/components/FeeWarning';
import { AppContext } from 'src/context';
import { BondGatewayForm } from '../forms/legacyForms/BondGatewayForm';
const defaultGatewayValues: GatewayData = {
identityKey: '',
sphinxKey: '',
ownerSignature: '',
location: '',
host: '',
version: '',
mixPort: 1789,
clientsPort: 9000,
};
const defaultAmountValues = (denom: CurrencyDenom) => ({
amount: { amount: '100', denom },
operatorCost: { amount: '40', denom },
tokenPool: 'balance',
});
export const BondGatewayModal = ({
denom,
hasVestingTokens,
onSelectNodeType,
onClose,
onError,
}: {
denom: CurrencyDenom;
hasVestingTokens: boolean;
onSelectNodeType: (type: TNodeType) => void;
onClose: () => void;
onError: (e: string) => void;
}) => {
const [step, setStep] = useState<1 | 2 | 3>(1);
const [gatewayData, setGatewayData] = useState<GatewayData>(defaultGatewayValues);
const [amountData, setAmountData] = useState<GatewayAmount>(defaultAmountValues(denom));
const { fee, resetFeeState, feeError } = useGetFee();
const { userBalance } = useContext(AppContext);
useEffect(() => {
if (feeError) {
onError(feeError);
}
}, [feeError]);
const validateStep = async (s: number) => {
const event = new CustomEvent('validate_bond_gateway_step', { detail: { step: s } });
window.dispatchEvent(event);
};
const handleBack = () => {
if (step === 2) {
setStep(1);
} else if (step === 3) {
setStep(2);
}
};
const handleUpdateGatwayData = (data: GatewayData) => {
setGatewayData(data);
setStep(2);
};
const handleUpdateAmountData = async (data: GatewayAmount) => {
setAmountData(data);
setStep(3);
};
const handleConfirm = async () => {};
if (fee) {
return (
<ConfirmTx
open
header="Bond details"
fee={fee}
onClose={onClose}
onPrev={resetFeeState}
onConfirm={handleConfirm}
>
<ModalListItem label="Node identity key" value={gatewayData.identityKey} divider />
<ModalListItem
label="Amount"
value={`${amountData.amount.amount} ${amountData.amount.denom.toUpperCase()}`}
divider
/>
{fee.amount?.amount && userBalance.balance && (
<BalanceWarning fee={fee.amount?.amount} tx={amountData.amount.amount} />
)}
</ConfirmTx>
);
}
return (
<SimpleModal
open
onOk={async () => {
await validateStep(step);
}}
onBack={step === 2 ? handleBack : undefined}
onClose={onClose}
header="Bond gateway"
subHeader={`Step ${step}/3`}
okLabel="Next"
>
<Box sx={{ mb: 2 }}>
<BondGatewayForm
step={step}
denom={denom}
gatewayData={gatewayData}
amountData={amountData}
hasVestingTokens={hasVestingTokens}
onValidateGatewayData={handleUpdateGatwayData}
onValidateAmountData={handleUpdateAmountData}
onSelectNodeType={onSelectNodeType}
/>
</Box>
</SimpleModal>
);
};
@@ -1,6 +1,6 @@
import React from 'react';
import { Stack, Typography, SxProps } from '@mui/material';
import { Link } from '@nymproject/react/link/Link';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
import { ConfirmationModal } from 'src/components/Modals/ConfirmationModal';
import { ErrorModal } from 'src/components/Modals/ErrorModal';
@@ -1,14 +1,15 @@
import * as React from 'react';
import { useState } from 'react';
import { Stack, TextField, Typography } from '@mui/material';
import { Stack, Typography, Box } from '@mui/material';
import { useBuyContext } from 'src/context';
import { SimpleModal } from '../Modals/SimpleModal';
import { ErrorModal } from '../Modals/ErrorModal';
import { CopyToClipboard } from '../CopyToClipboard';
import { TextFieldWithPaste } from '../Clipboard/ClipboardFormFields';
import { CopyToClipboard } from '../Clipboard/ClipboardActions';
export const SignMessageModal = ({ onClose }: { onClose: () => void }) => {
const [message, setMessage] = useState<string>();
const [signature, setSignature] = useState<string>();
const [message, setMessage] = useState<string>('');
const [signature, setSignature] = useState<string>('');
const { signMessage, loading, refresh, error } = useBuyContext();
@@ -16,13 +17,17 @@ export const SignMessageModal = ({ onClose }: { onClose: () => void }) => {
if (!message) {
return;
}
signMessage(message).then((sig) => {
setSignature(sig);
});
try {
const sig = await signMessage(message);
setSignature(sig || '');
} catch (err) {
console.error('Signing failed:', err);
setSignature('');
}
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setMessage(event.target.value);
const handleMessageChange = (value: string) => {
setMessage(value);
};
if (error) {
@@ -38,34 +43,57 @@ export const SignMessageModal = ({ onClose }: { onClose: () => void }) => {
}
return (
<SimpleModal open header="Sign message" okLabel="Sign" onOk={handleSign} onClose={onClose} okDisabled={loading}>
<Stack gap={2}>
<TextField
id="outlined-multiline-static"
label="Message"
multiline
rows={8}
placeholder="Paste your message here"
fullWidth
value={message}
onChange={handleChange}
/>
<TextField
id="outlined-multiline-static"
multiline
rows={3}
value={signature}
placeholder="Signature"
fullWidth
disabled
/>
<Stack direction="row" alignItems="center" alignSelf="flex-end">
<Typography variant="body2" component="span" fontWeight={600} sx={{ mr: 1, color: 'text.primary' }}>
Copy signature
<SimpleModal
open
header="Sign message"
okLabel="Sign"
onOk={handleSign}
onClose={onClose}
okDisabled={loading || !message}
>
<Stack spacing={3} sx={{ width: '100%' }}>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
Message to Sign
</Typography>
<CopyToClipboard text={signature} iconButton />
</Stack>
<TextFieldWithPaste
label=""
multiline
rows={8}
placeholder="Enter or paste your message"
fullWidth
value={message}
onPasteValue={handleMessageChange}
onChange={(e) => handleMessageChange(e.target.value)}
InputLabelProps={{ shrink: false }}
/>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
Signature
</Typography>
<Box sx={{ position: 'relative' }}>
<TextFieldWithPaste
label=""
multiline
rows={3}
value={signature}
placeholder="Signature will appear here"
fullWidth
disabled
InputLabelProps={{ shrink: false }}
InputProps={{
readOnly: true,
endAdornment: signature ? (
<Box sx={{ position: 'absolute', top: 8, right: 8 }}>
<CopyToClipboard text={signature} iconButton />
</Box>
) : undefined,
}}
/>
</Box>
</Box>
</Stack>
</SimpleModal>
);
@@ -0,0 +1,254 @@
import React, { useEffect, useState, useRef } from 'react';
import { Button, IconButton, Tooltip } from '@mui/material';
import { Check, ContentCopy, ContentPaste } from '@mui/icons-material';
import { writeText, readText } from '@tauri-apps/plugin-clipboard-manager';
import { Console } from '../../utils/console';
/**
* ClipboardActions component handles copy and paste operations
* Can be used as standalone buttons or as icons
*/
export const ClipboardActions = ({
text = '',
iconButton = false,
showCopy = true,
showPaste = true,
onPaste,
pasteTooltip = 'Paste',
copyTooltip = 'Copy',
onCopy,
fieldRef,
}: {
text?: string;
iconButton?: boolean;
showCopy?: boolean;
showPaste?: boolean;
onPaste?: (pastedText: string) => void;
pasteTooltip?: string;
copyTooltip?: string;
onCopy?: () => void;
fieldRef?: React.MutableRefObject<HTMLInputElement | HTMLTextAreaElement | null>;
}) => {
const [copied, setCopied] = useState(false);
const [pasted, setPasted] = useState(false);
const handleCopy = async (_text: string) => {
try {
await writeText(_text);
setCopied(true);
onCopy?.();
} catch (e) {
Console.error(`failed to copy: ${e}`);
}
};
const handlePaste = async () => {
try {
// Try Tauri clipboard first
const pastedText = await readText();
if (pastedText) {
onPaste?.(pastedText);
setPasted(true);
}
} catch (e) {
// Fallback to browser clipboard
try {
const pastedText = await navigator.clipboard.readText();
if (pastedText) {
onPaste?.(pastedText);
setPasted(true);
}
} catch (err) {
Console.error(`paste failed: ${err}`);
}
}
};
const wasSelectAllPressed = useRef(false);
useEffect(() => {
if (!fieldRef) return;
const keydownHandler = async (e: KeyboardEvent) => {
// Only handle if the associated field is focused
const { activeElement } = document;
if (fieldRef.current && activeElement === fieldRef.current) {
// Handle Select All (Cmd+A or Ctrl+A)
if ((e.metaKey || e.ctrlKey) && e.key === 'a') {
wasSelectAllPressed.current = true;
return;
}
// Handle paste (Cmd+V or Ctrl+V)
if ((e.metaKey || e.ctrlKey) && e.key === 'v' && onPaste) {
e.preventDefault();
await handlePaste();
return;
}
if ((e.metaKey || e.ctrlKey) && e.key === 'c' && showCopy) {
const field = fieldRef.current;
const selectedText = field.value.substring(field.selectionStart || 0, field.selectionEnd || 0);
if (selectedText || wasSelectAllPressed.current) {
e.preventDefault();
if (wasSelectAllPressed.current && !selectedText) {
field.select();
const textToCopy = field.value;
await writeText(textToCopy);
} else {
await writeText(selectedText);
}
setCopied(true);
onCopy?.();
wasSelectAllPressed.current = false;
} else if (text) {
e.preventDefault();
await writeText(text);
setCopied(true);
onCopy?.();
}
}
}
};
// When field loses focus, reset the Select All tracking
const blurHandler = () => {
wasSelectAllPressed.current = false;
};
// Add keydown and blur event listeners
document.addEventListener('keydown', keydownHandler);
if (fieldRef.current) {
fieldRef.current.addEventListener('blur', blurHandler);
}
// eslint-disable-next-line consistent-return
return () => {
document.removeEventListener('keydown', keydownHandler);
if (fieldRef.current) {
fieldRef.current.removeEventListener('blur', blurHandler);
}
};
}, [onPaste, fieldRef, text, showCopy, onCopy]);
useEffect(() => {
const timer = setTimeout(() => {
setCopied(false);
setPasted(false);
}, 2000);
return () => clearTimeout(timer);
}, [copied, pasted]);
if (iconButton) {
return (
<div style={{ display: 'flex', alignItems: 'center' }}>
{showCopy && (
<Tooltip title={!copied ? copyTooltip : 'Copied!'} leaveDelay={500}>
<IconButton
onClick={() => handleCopy(text)}
size="small"
sx={{
color: 'text.primary',
mr: showPaste ? 0.5 : 0,
}}
disabled={!text}
>
{!copied ? <ContentCopy sx={{ fontSize: 14 }} /> : <Check color="success" sx={{ fontSize: 14 }} />}
</IconButton>
</Tooltip>
)}
{showPaste && (
<Tooltip title={!pasted ? pasteTooltip : 'Pasted!'} leaveDelay={500}>
<IconButton
onClick={handlePaste}
size="small"
sx={{
color: 'text.primary',
}}
>
{!pasted ? <ContentPaste sx={{ fontSize: 14 }} /> : <Check color="success" sx={{ fontSize: 14 }} />}
</IconButton>
</Tooltip>
)}
</div>
);
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{showCopy && (
<Button
variant="outlined"
color="inherit"
sx={{
color: 'text.primary',
borderColor: 'text.primary',
}}
onClick={() => handleCopy(text)}
endIcon={copied && <Check sx={{ color: (theme) => theme.palette.success.light }} />}
disabled={!text}
>
{!copied ? 'Copy' : 'Copied'}
</Button>
)}
{showPaste && (
<Button
variant="outlined"
color="inherit"
sx={{
color: 'text.primary',
borderColor: 'text.primary',
}}
onClick={handlePaste}
endIcon={pasted && <Check sx={{ color: (theme) => theme.palette.success.light }} />}
>
{!pasted ? 'Paste' : 'Pasted'}
</Button>
)}
</div>
);
};
// For backward compatibility
export const CopyToClipboard = ({
text = '',
iconButton,
onPaste,
fieldRef,
}: {
text?: string;
iconButton?: boolean;
onPaste?: (pastedText: string) => void;
fieldRef?: React.MutableRefObject<HTMLInputElement | HTMLTextAreaElement | null>;
}) => (
<ClipboardActions text={text} iconButton={iconButton} onPaste={onPaste} showPaste={!!onPaste} fieldRef={fieldRef} />
);
// Export a paste-only variant for convenience
export const PasteFromClipboard = ({
onPaste,
iconButton = true,
tooltip = 'Paste',
fieldRef,
}: {
onPaste: (pastedText: string) => void;
iconButton?: boolean;
tooltip?: string;
fieldRef?: React.MutableRefObject<HTMLInputElement | HTMLTextAreaElement | null>;
}) => (
<ClipboardActions
iconButton={iconButton}
showCopy={false}
showPaste
onPaste={onPaste}
pasteTooltip={tooltip}
fieldRef={fieldRef}
/>
);
@@ -0,0 +1,463 @@
import React, { useRef, useEffect } from 'react';
import { TextField, InputAdornment, Box, TextFieldProps } from '@mui/material';
import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField';
import { CurrencyDenom, DecCoin } from '@nymproject/types';
import { UseFormRegister, UseFormSetValue, FieldValues, Path, FieldErrors } from 'react-hook-form';
import { writeText, readText } from '@tauri-apps/plugin-clipboard-manager';
import { PasteFromClipboard } from './ClipboardActions';
export const useCopyAllSupport = (
inputRef: React.MutableRefObject<HTMLInputElement | HTMLTextAreaElement | null>,
onPasteValue?: (value: string) => void,
) => {
useEffect(() => {
if (!inputRef.current) return undefined;
const handleKeyDown = async (e: Event) => {
const keyEvent = e as KeyboardEvent;
if (document.activeElement !== inputRef.current) return;
// Handle Cmd+A or Ctrl+A (Select All)
if ((keyEvent.metaKey || keyEvent.ctrlKey) && keyEvent.key === 'a') {
setTimeout(() => {
if (document.activeElement === inputRef.current && inputRef.current) {
inputRef.current.select();
}
}, 0);
}
// Handle Cmd+C or Ctrl+C (Copy)
if ((keyEvent.metaKey || keyEvent.ctrlKey) && keyEvent.key === 'c') {
if (inputRef.current && inputRef.current.selectionStart !== inputRef.current.selectionEnd) {
const selectedText = inputRef.current.value.substring(
inputRef.current.selectionStart || 0,
inputRef.current.selectionEnd || 0,
);
if (selectedText) {
keyEvent.preventDefault();
writeText(selectedText).catch((err) => {
// eslint-disable-next-line no-console
console.error('Failed to copy text:', err);
});
}
}
}
// Handle Cmd+V or Ctrl+V (Paste)
if ((keyEvent.metaKey || keyEvent.ctrlKey) && keyEvent.key === 'v' && onPasteValue) {
try {
keyEvent.preventDefault();
const clipboardText = await readText();
if (clipboardText) {
onPasteValue(clipboardText);
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('Failed to paste text:', err);
}
}
};
const input = inputRef.current;
input.addEventListener('keydown', handleKeyDown);
return () => {
if (input) {
input.removeEventListener('keydown', handleKeyDown);
}
};
}, [inputRef.current, onPasteValue]);
};
export const TextFieldWithPaste = React.forwardRef<
HTMLDivElement,
TextFieldProps & {
onPasteValue?: (value: string) => void;
}
>(({ onPasteValue, ...props }, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
useCopyAllSupport(inputRef, onPasteValue);
const handlePaste = (pastedText: string) => {
onPasteValue?.(pastedText);
if (inputRef.current) {
inputRef.current.focus();
}
};
return (
<TextField
{...props}
ref={ref}
inputRef={inputRef}
InputProps={{
...props.InputProps,
endAdornment: (
<InputAdornment position="end">
{onPasteValue && <PasteFromClipboard onPaste={handlePaste} fieldRef={inputRef} />}
{props.InputProps?.endAdornment}
</InputAdornment>
),
}}
/>
);
});
// Add defaultProps to fix the "require-default-props" warning
TextFieldWithPaste.defaultProps = {
onPasteValue: undefined,
};
export const CurrencyFormFieldWithPaste = ({
label,
fullWidth,
onChanged,
initialValue,
denom,
required,
autoFocus,
validationError,
}: {
label: string;
fullWidth?: boolean;
onChanged: (value: DecCoin) => void;
initialValue?: string;
denom?: CurrencyDenom;
required?: boolean;
autoFocus?: boolean;
validationError?: string;
}) => {
const fieldRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// Process pasted text to clean the value
const processPastedText = (pastedText: string) => {
if (!pastedText) return;
let cleanedValue = pastedText.trim();
// Remove non-numeric characters except decimal point
cleanedValue = cleanedValue.replace(/[^\d.]/g, '');
// Ensure only one decimal point
const parts = cleanedValue.split('.');
if (parts.length > 2) {
cleanedValue = `${parts[0]}.${parts.slice(1).join('')}`;
}
const decCoin: DecCoin = {
amount: cleanedValue,
denom: denom as any,
};
onChanged(decCoin);
if (inputRef.current) {
inputRef.current.focus();
}
};
// Modified to pass the processPastedText function to useCopyAllSupport
useEffect(() => {
if (!inputRef.current) return undefined;
const handleKeyDown = async (e: Event) => {
const keyEvent = e as KeyboardEvent;
if (document.activeElement !== inputRef.current) return;
// Handle Cmd+A (Select All)
if ((keyEvent.metaKey || keyEvent.ctrlKey) && keyEvent.key === 'a' && inputRef.current) {
setTimeout(() => {
if (inputRef.current) inputRef.current.select();
}, 0);
}
// Handle Cmd+C (Copy)
if ((keyEvent.metaKey || keyEvent.ctrlKey) && keyEvent.key === 'c' && inputRef.current) {
if (inputRef.current.selectionStart !== inputRef.current.selectionEnd) {
const selectedText = inputRef.current.value.substring(
inputRef.current.selectionStart || 0,
inputRef.current.selectionEnd || 0,
);
if (selectedText) {
keyEvent.preventDefault();
writeText(selectedText).catch((err) => {
// eslint-disable-next-line no-console
console.error('Failed to copy text:', err);
});
}
}
}
// Handle Cmd+V (Paste)
if ((keyEvent.metaKey || keyEvent.ctrlKey) && keyEvent.key === 'v') {
try {
keyEvent.preventDefault();
const clipboardText = await readText();
if (clipboardText) {
processPastedText(clipboardText);
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('Failed to paste text:', err);
}
}
};
const input = inputRef.current;
if (input) {
input.addEventListener('keydown', handleKeyDown);
}
return () => {
if (input) {
input.removeEventListener('keydown', handleKeyDown);
}
};
}, [inputRef.current, denom, onChanged]);
// Find the input element
useEffect(() => {
const findInputElement = () => {
if (fieldRef.current) {
inputRef.current = fieldRef.current.querySelector('input');
}
};
findInputElement();
const timeoutId = setTimeout(findInputElement, 100);
return () => clearTimeout(timeoutId);
}, []);
return (
<Box position="relative" width="100%" ref={fieldRef}>
<CurrencyFormField
label={label}
fullWidth={fullWidth}
onChanged={onChanged}
initialValue={initialValue}
denom={denom}
required={required}
autoFocus={autoFocus}
validationError={validationError}
/>
<Box
sx={{
position: 'absolute',
right: '14px',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 1,
}}
>
<PasteFromClipboard onPaste={processPastedText} fieldRef={inputRef} />
</Box>
</Box>
);
};
type CurrencyFieldError = {
amount?: {
message?: string;
};
};
export const HookFormTextFieldWithPaste = <TFieldValues extends FieldValues>({
name,
label,
register,
setValue,
errors,
...props
}: {
name: Path<TFieldValues>;
label: string;
register: UseFormRegister<TFieldValues>;
setValue: UseFormSetValue<TFieldValues>;
errors: FieldErrors<TFieldValues>;
} & Omit<TextFieldProps, 'name' | 'label'>) => {
const inputRef = useRef<HTMLInputElement>(null);
const handlePaste = (pastedText: string) => {
setValue(name, pastedText as any, { shouldValidate: true });
if (inputRef.current) {
inputRef.current.focus();
}
};
// Pass handlePaste to useCopyAllSupport for Cmd+V handling
useCopyAllSupport(inputRef, handlePaste);
return (
<TextField
{...register(name)}
name={name}
label={label}
error={Boolean(errors[name])}
helperText={errors[name]?.message?.toString()}
inputRef={inputRef}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<PasteFromClipboard onPaste={handlePaste} fieldRef={inputRef} />
</InputAdornment>
),
...props.InputProps,
}}
{...props}
/>
);
};
export const HookFormCurrencyFieldWithPaste = <TFieldValues extends FieldValues>({
name,
label,
setValue,
errors,
denom,
initialValue,
...props
}: {
name: Path<TFieldValues>;
label: string;
setValue: UseFormSetValue<TFieldValues>;
errors: FieldErrors<TFieldValues>;
denom?: CurrencyDenom;
initialValue?: string;
}) => {
const fieldRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// Handle pasting with number formatting
const handlePaste = (pastedText: string) => {
if (!pastedText) return;
let cleanedValue = pastedText.trim();
cleanedValue = cleanedValue.replace(/[^\d.]/g, '');
const parts = cleanedValue.split('.');
if (parts.length > 2) {
cleanedValue = `${parts[0]}.${parts.slice(1).join('')}`;
}
setValue(`${String(name)}.amount` as Path<TFieldValues>, cleanedValue as any, { shouldValidate: true });
if (inputRef.current) {
inputRef.current.focus();
}
};
// Enable copy-all support for this field with Cmd+V handling
useEffect(() => {
if (!inputRef.current) return undefined;
const handleKeyDown = async (e: Event) => {
const keyEvent = e as KeyboardEvent;
if (document.activeElement !== inputRef.current) return;
// Handle Cmd+A (Select All)
if ((keyEvent.metaKey || keyEvent.ctrlKey) && keyEvent.key === 'a' && inputRef.current) {
setTimeout(() => {
if (inputRef.current) inputRef.current.select();
}, 0);
}
// Handle Cmd+C (Copy)
if ((keyEvent.metaKey || keyEvent.ctrlKey) && keyEvent.key === 'c' && inputRef.current) {
if (inputRef.current.selectionStart !== inputRef.current.selectionEnd) {
const selectedText = inputRef.current.value.substring(
inputRef.current.selectionStart || 0,
inputRef.current.selectionEnd || 0,
);
if (selectedText) {
keyEvent.preventDefault();
writeText(selectedText).catch((err) => {
// eslint-disable-next-line no-console
console.error('Failed to copy text:', err);
});
}
}
}
// Handle Cmd+V (Paste)
if ((keyEvent.metaKey || keyEvent.ctrlKey) && keyEvent.key === 'v') {
try {
keyEvent.preventDefault();
const clipboardText = await readText();
if (clipboardText) {
handlePaste(clipboardText);
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('Failed to paste text:', err);
}
}
};
const input = inputRef.current;
if (input) {
input.addEventListener('keydown', handleKeyDown);
}
return () => {
if (input) {
input.removeEventListener('keydown', handleKeyDown);
}
};
}, [inputRef.current, name, setValue]);
useEffect(() => {
const findInputElement = () => {
if (fieldRef.current) {
inputRef.current = fieldRef.current.querySelector('input');
}
};
findInputElement();
const timeoutId = setTimeout(findInputElement, 100);
return () => clearTimeout(timeoutId);
}, []);
// Safely access error message
const getErrorMessage = (): string | undefined => {
const fieldError = errors[name] as unknown as CurrencyFieldError | undefined;
return fieldError?.amount?.message;
};
return (
<Box position="relative" width="100%" ref={fieldRef}>
<CurrencyFormField
label={label}
fullWidth
onChanged={(value) => {
setValue(`${String(name)}.amount` as Path<TFieldValues>, value.amount as any, { shouldValidate: true });
}}
initialValue={initialValue}
denom={denom}
validationError={getErrorMessage()}
{...props}
/>
<Box
sx={{
position: 'absolute',
right: '14px',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 1,
}}
>
<PasteFromClipboard onPaste={handlePaste} fieldRef={inputRef} />
</Box>
</Box>
);
};
@@ -0,0 +1,2 @@
export * from './ClipboardActions';
export * from './ClipboardFormFields';
@@ -1,60 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Button, IconButton, Tooltip } from '@mui/material';
import { Check, ContentCopy } from '@mui/icons-material';
import { clipboard } from '@tauri-apps/api';
import { Console } from '../utils/console';
export const CopyToClipboard = ({ text = '', iconButton }: { text?: string; iconButton?: boolean }) => {
const [copied, setCopied] = useState(false);
const handleCopy = async (_text: string) => {
try {
await clipboard.writeText(_text);
setCopied(true);
} catch (e) {
Console.error(`failed to copy: ${e}`);
}
};
useEffect(() => {
let timer: NodeJS.Timeout;
if (copied) {
timer = setTimeout(() => {
setCopied(false);
}, 2000);
}
return () => clearTimeout(timer);
}, [copied]);
if (iconButton)
return (
<Tooltip title={!copied ? 'Copy' : 'Copied!'} leaveDelay={500}>
<IconButton
onClick={() => handleCopy(text)}
size="small"
sx={{
color: 'text.primary',
}}
disabled={!text}
>
{!copied ? <ContentCopy sx={{ fontSize: 14 }} /> : <Check color="success" sx={{ fontSize: 14 }} />}
</IconButton>
</Tooltip>
);
return (
<Button
variant="outlined"
color="inherit"
sx={{
color: 'text.primary',
borderColor: 'text.primary',
}}
onClick={() => handleCopy(text)}
endIcon={copied && <Check sx={{ color: (theme) => theme.palette.success.light }} />}
disabled={!text}
>
{!copied ? 'Copy' : 'Copied'}
</Button>
);
};
@@ -0,0 +1,146 @@
import React, { useRef, useEffect } from 'react';
import { Box } from '@mui/material';
import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField';
import { CurrencyDenom, DecCoin } from '@nymproject/types';
import { PasteFromClipboard } from './Clipboard/ClipboardActions';
export const CurrencyFormFieldWithPaste = ({
label,
fullWidth,
onChanged,
initialValue,
denom,
required,
autoFocus,
validationError,
}: {
label: string;
fullWidth?: boolean;
onChanged: (value: DecCoin) => void;
initialValue?: string;
denom?: CurrencyDenom;
required?: boolean;
autoFocus?: boolean;
validationError?: string;
}) => {
const fieldRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const processPastedText = (pastedText: string) => {
if (!pastedText) return;
let cleanedValue = pastedText.trim();
cleanedValue = cleanedValue.replace(/[^\d.]/g, '');
const parts = cleanedValue.split('.');
if (parts.length > 2) {
cleanedValue = `${parts[0]}.${parts.slice(1).join('')}`;
}
const decCoin: DecCoin = {
amount: cleanedValue,
denom: denom as any,
};
onChanged(decCoin);
if (inputRef.current) {
inputRef.current.value = cleanedValue;
const inputEvent = new Event('input', { bubbles: true });
inputRef.current.dispatchEvent(inputEvent);
const changeEvent = new Event('change', { bubbles: true });
inputRef.current.dispatchEvent(changeEvent);
inputRef.current.focus();
}
};
useEffect(() => {
const pasteEventHandler = (e: ClipboardEvent) => {
e.preventDefault();
const { clipboardData } = e;
if (!clipboardData) return;
const pastedText = clipboardData.getData('text');
if (!pastedText) return;
processPastedText(pastedText);
};
const findInputElement = () => {
if (fieldRef.current) {
const input = fieldRef.current.querySelector('input');
if (input) {
inputRef.current = input;
// Set up paste event handler
input.addEventListener('paste', pasteEventHandler as EventListener);
}
}
};
findInputElement();
const timeoutId = setTimeout(findInputElement, 200);
return () => {
clearTimeout(timeoutId);
if (inputRef.current) {
inputRef.current.removeEventListener('paste', pasteEventHandler as EventListener);
}
};
}, [denom, onChanged]);
useEffect(() => {
const handleKeyDown = async (e: KeyboardEvent) => {
if (inputRef.current && document.activeElement === inputRef.current) {
if ((e.metaKey || e.ctrlKey) && e.key === 'v') {
e.preventDefault();
try {
const clipboardText = await navigator.clipboard.readText();
if (clipboardText) {
processPastedText(clipboardText);
}
} catch (err) {
console.error('Error accessing clipboard:', err);
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [denom, onChanged]);
return (
<Box position="relative" width="100%" ref={fieldRef}>
<CurrencyFormField
label={label}
fullWidth={fullWidth}
onChanged={onChanged}
initialValue={initialValue}
denom={denom}
required={required}
autoFocus={autoFocus}
validationError={validationError}
/>
<Box
sx={{
position: 'absolute',
right: '14px',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 1,
}}
>
<PasteFromClipboard onPaste={processPastedText} fieldRef={inputRef} />
</Box>
</Box>
);
};
@@ -1,7 +1,5 @@
import React, { useCallback, useContext, useState } from 'react';
import { Box, Typography, SxProps } from '@mui/material';
import { IdentityKeyFormField } from '@nymproject/react/mixnodes/IdentityKeyFormField';
import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField';
import { Box, SxProps } from '@mui/material';
import { CurrencyDenom, FeeDetails, DecCoin, decimalToFloatApproximation } from '@nymproject/types';
import { Console } from 'src/utils/console';
import { useGetFee } from 'src/hooks/useGetFee';
@@ -13,10 +11,10 @@ import { ModalListItem } from '../Modals/ModalListItem';
import { checkTokenBalance, validateAmount, validateKey } from '../../utils';
import { TokenPoolSelector, TPoolOption } from '../TokenPoolSelector';
import { ConfirmTx } from '../ConfirmTX';
import { getMixnodeStakeSaturation } from '../../requests';
import { ErrorModal } from '../Modals/ErrorModal';
import { BalanceWarning } from '../FeeWarning';
import { TextFieldWithPaste, CurrencyFormFieldWithPaste } from '../Clipboard/ClipboardFormFields';
const MIN_AMOUNT_TO_DELEGATE = 10;
@@ -255,36 +253,24 @@ export const DelegateModal: FCWithChildren<{
backdropProps={backdropProps}
>
<Box sx={{ mt: 3 }}>
<IdentityKeyFormField
required
fullWidth
<TextFieldWithPaste
label="Node identity key"
onChanged={handleIdentityKeyChanged}
initialValue={identityKey}
readOnly={Boolean(initialIdentityKey)}
textFieldProps={{
autoFocus: !initialIdentityKey,
}}
showTickOnValid={false}
fullWidth
value={identityKey}
onChange={(e) => handleIdentityKeyChanged(e.target.value)}
onPasteValue={handleIdentityKeyChanged}
error={Boolean(errorIdentityKey)}
helperText={errorIdentityKey}
InputLabelProps={{ shrink: true }}
/>
</Box>
<Typography
component="div"
textAlign="left"
variant="caption"
sx={{ color: 'error.main', mx: 2, mt: errorIdentityKey && 1 }}
>
{errorIdentityKey}
</Typography>
<Box display="flex" gap={2} alignItems="center" sx={{ mt: 3 }}>
{hasVestingContract && <TokenPoolSelector disabled={false} onSelect={(pool) => setTokenPool(pool)} />}
<CurrencyFormField
required
fullWidth
<CurrencyFormFieldWithPaste
label="Amount"
initialValue={amount}
autoFocus={Boolean(initialIdentityKey)}
fullWidth
onChanged={handleAmountChanged}
initialValue={amount}
denom={denom}
validationError={errorAmount}
/>
@@ -1,6 +1,6 @@
import React from 'react';
import { Box, Chip, IconButton, TableCell, TableRow, Tooltip, Typography } from '@mui/material';
import { Link } from '@nymproject/react/link/Link';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
import { decimalToPercentage, DelegationWithEverything } from '@nymproject/types';
import { LockOutlined, WarningAmberOutlined } from '@mui/icons-material';
import { isDelegation, useDelegationContext } from 'src/context/delegations';
@@ -100,7 +100,7 @@ export const DelegationItem = ({
</Tooltip>
)}
</TableCell>
<TableCell align="right" sx={{ color: 'inherit' }}>
<TableCell align="center" sx={{ color: 'inherit' }}>
{!item.pending_events.length && !nodeIsUnbonded && (
<DelegationsActionsMenu
onActionClick={(action) => (onItemActionClick ? onItemActionClick(item, action) : undefined)}
@@ -117,8 +117,15 @@ export const DelegationItem = ({
<Tooltip
title="Your changes will take effect when the new epoch starts. There is a new epoch every hour."
arrow
componentsProps={{
tooltip: {
sx: {
textAlign: 'left',
},
},
}}
>
<Chip label="Pending Events" />
<Chip label="Pending events" />
</Tooltip>
)}
</TableCell>
@@ -1,9 +1,23 @@
import React from 'react';
import { Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TableSortLabel } from '@mui/material';
import {
Alert,
AlertTitle,
Box,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TableSortLabel,
Typography,
} from '@mui/material';
import { visuallyHidden } from '@mui/utils';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import { DelegationWithEverything } from '@nymproject/types';
import { useSortDelegations } from 'src/hooks/useSortDelegations';
import { useNavigate } from 'react-router-dom';
import { DelegationListItemActions } from './DelegationActions';
import { DelegationItem } from './DelegationItem';
import { PendingDelegationItem } from './PendingDelegationItem';
@@ -15,6 +29,35 @@ export type Order = 'asc' | 'desc';
type AdditionalTypes = { profit_margin_percent: number; operating_cost: number };
export type SortingKeys = keyof AdditionalTypes | keyof DelegationWithEverything;
// Helper function to check if a delegation item should be filtered
const shouldBeFiltered = (item: any): boolean => {
// For regular delegations, filter out placeholders
if (isDelegation(item)) {
// Check if node_identity is empty or just placeholders
if (!item.node_identity || item.node_identity === '-' || item.node_identity === '...') {
return true;
}
// Check if uptime is a placeholder dash
if (typeof item.avg_uptime_percent === 'string' && item.avg_uptime_percent === '-') {
return true;
}
}
// For pending delegations, keep "Delegate" events but filter out "Undelegate" events with empty node_identity
if (isPendingDelegation(item)) {
// If it's an undelegate event with empty node_identity, filter it out
if ((!item.node_identity || item.node_identity === '') && item.event && item.event.kind === 'Undelegate') {
return true;
}
// Keep all other pending events (including new delegation events)
return false;
}
return false;
};
interface EnhancedTableProps {
onRequestSort: (event: React.MouseEvent<unknown>, property: string) => void;
order: Order;
@@ -27,23 +70,25 @@ interface HeadCell {
sortable: boolean;
disablePadding?: boolean;
align: 'left' | 'center' | 'right';
width?: string;
}
const headCells: HeadCell[] = [
{ id: 'node_identity', label: 'Node ID', sortable: true, align: 'left' },
{ id: 'avg_uptime_percent', label: 'Routing score', sortable: true, align: 'left' },
{ id: 'profit_margin_percent', label: 'Profit margin', sortable: true, align: 'left' },
{ id: 'operating_cost', label: 'Operating Cost', sortable: true, align: 'left' },
{ id: 'stake_saturation', label: 'Stake saturation', sortable: true, align: 'left' },
{ id: 'node_identity', label: 'Node ID', sortable: true, align: 'left', width: '15%' },
{ id: 'avg_uptime_percent', label: 'Routing score', sortable: true, align: 'left', width: '10%' },
{ id: 'profit_margin_percent', label: 'Profit margin', sortable: true, align: 'left', width: '10%' },
{ id: 'operating_cost', label: 'Operating Cost', sortable: true, align: 'left', width: '12%' },
{ id: 'stake_saturation', label: 'Stake saturation', sortable: true, align: 'left', width: '10%' },
{
id: 'delegated_on_iso_datetime',
label: 'Delegated on',
sortable: true,
align: 'left',
width: '10%',
},
{ id: 'amount', label: 'Delegation', sortable: true, align: 'left' },
{ id: 'unclaimed_rewards', label: 'Reward', sortable: true, align: 'left' },
{ id: 'uses_locked_tokens', label: '', sortable: false, align: 'left' },
{ id: 'amount', label: 'Delegation', sortable: true, align: 'left', width: '12%' },
{ id: 'unclaimed_rewards', label: 'Reward', sortable: true, align: 'left', width: '10%' },
{ id: 'uses_locked_tokens', label: '', sortable: false, align: 'left', width: '8%' },
];
const EnhancedTableHead: FCWithChildren<EnhancedTableProps> = ({ order, orderBy, onRequestSort }) => {
@@ -61,6 +106,13 @@ const EnhancedTableHead: FCWithChildren<EnhancedTableProps> = ({ order, orderBy,
padding={headCell.disablePadding ? 'none' : 'normal'}
sortDirection={orderBy === headCell.id ? order : false}
color="secondary"
sx={{
width: headCell.width,
minWidth: headCell.id === 'node_identity' ? '120px' : '80px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
<TableSortLabel
active={orderBy === headCell.id}
@@ -77,13 +129,34 @@ const EnhancedTableHead: FCWithChildren<EnhancedTableProps> = ({ order, orderBy,
</TableSortLabel>
</TableCell>
))}
<TableCell />
<TableCell
align="center"
sx={{
width: '10%',
minWidth: '100px',
maxWidth: '120px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textAlign: 'center',
}}
>
<Typography noWrap align="center">
Actions
</Typography>
</TableCell>
</TableRow>
</TableHead>
);
};
// Pin delegations on unbonded nodes to the top of the list
const hasPruningError = (item: any): boolean => {
if (!isDelegation(item) || !item.errors) return false;
return (
(item.errors.includes('height') && item.errors.includes('not available')) ||
item.errors.includes('Due to pruning strategies')
);
};
export const DelegationList: FCWithChildren<{
isLoading?: boolean;
@@ -94,6 +167,7 @@ export const DelegationList: FCWithChildren<{
}> = ({ isLoading, items, onItemActionClick, explorerUrl }) => {
const [order, setOrder] = React.useState<Order>('asc');
const [orderBy, setOrderBy] = React.useState<SortingKeys>('delegated_on_iso_datetime');
const navigate = useNavigate();
const { delegationItemErrors, setDelegationItemErrors } = useDelegationContext();
@@ -103,37 +177,124 @@ export const DelegationList: FCWithChildren<{
setOrderBy(property);
};
// Get sorted items
const sorted = useSortDelegations(items, order, orderBy);
// Filter out empty placeholder rows
const filteredItems = React.useMemo(() => {
if (!sorted) return [];
return sorted.filter((item) => !shouldBeFiltered(item));
}, [sorted]);
// Check if any delegations have pruning errors
const hasPruningErrors = React.useMemo(() => filteredItems?.some((item) => hasPruningError(item)), [filteredItems]);
// Navigate to settings page
const navigateToSettings = () => {
navigate('/settings');
};
// Format error message for display
const formatErrorMessage = (message: string) => {
if (message.includes('height') && message.includes('not available')) {
return 'Due to pruning strategies from validators, please navigate to the Settings tab and change your RPC node for your validator to retrieve your delegations.';
}
return message;
};
return (
<TableContainer>
{isLoading && <LoadingModal text="Please wait. Refreshing..." />}
<ErrorModal
open={Boolean(delegationItemErrors)}
title={`Delegation errors for Node ID ${delegationItemErrors?.nodeId || 'unknown'}`}
message={delegationItemErrors?.errors || 'oops'}
onClose={() => setDelegationItemErrors(undefined)}
/>
<Table sx={{ width: '100%' }}>
<EnhancedTableHead order={order} orderBy={orderBy} onRequestSort={handleRequestSort} />
<TableBody>
{sorted?.length
? sorted.map((item: any) => {
if (isPendingDelegation(item)) return <PendingDelegationItem item={item} explorerUrl={explorerUrl} />;
if (isDelegation(item))
return (
<DelegationItem
item={item}
explorerUrl={explorerUrl}
nodeIsUnbonded={Boolean(!item.node_identity)}
onItemActionClick={onItemActionClick}
/>
);
return null;
})
: null}
</TableBody>
</Table>
</TableContainer>
<>
{/* Display pruning error alert at the top if needed */}
{hasPruningErrors && (
<Alert
severity="warning"
sx={{ mb: 2 }}
action={
<Button color="inherit" size="small" onClick={navigateToSettings}>
Go to Settings
</Button>
}
>
<AlertTitle>Data Pruning Detected</AlertTitle>
<Typography>
Some delegation details cannot be retrieved because of data pruning on the validator. Please navigate to the
Settings tab and change your RPC node to fix this issue.
</Typography>
</Alert>
)}
{/* Add horizontal scrolling to the table container */}
<TableContainer
sx={{
width: '100%',
overflowX: 'auto',
'& .MuiTable-root': {
tableLayout: 'fixed',
minWidth: 650,
},
}}
>
{isLoading && <LoadingModal text="Please wait. Refreshing..." />}
<ErrorModal
open={Boolean(delegationItemErrors)}
title={`Delegation errors for Node ID ${delegationItemErrors?.nodeId || 'unknown'}`}
message={
delegationItemErrors?.errors ? formatErrorMessage(delegationItemErrors.errors) : 'An unknown error occurred'
}
onClose={() => setDelegationItemErrors(undefined)}
/>
<Table>
<EnhancedTableHead order={order} orderBy={orderBy} onRequestSort={handleRequestSort} />
<TableBody>
{filteredItems?.length
? filteredItems.map((item: any, _index: number) => {
if (isPendingDelegation(item)) {
const pendingKey = `pending-${item.event.mix_id}-${
item.event.address
}-${Date.now()}-${Math.random()}`;
if (
item.event &&
item.event.kind === 'Delegate' &&
(!item.node_identity || item.node_identity === '')
) {
return (
<PendingDelegationItem
key={pendingKey}
item={{
...item,
node_identity: `Mix Identity Key ${item.event.mix_id}`,
}}
explorerUrl={explorerUrl}
/>
);
}
return <PendingDelegationItem key={pendingKey} item={item} explorerUrl={explorerUrl} />;
}
if (isDelegation(item)) {
if (!item.node_identity || item.node_identity === '-' || item.node_identity === '...') {
return null;
}
return (
<DelegationItem
key={`delegation-${item.mix_id}`}
item={item}
explorerUrl={explorerUrl}
nodeIsUnbonded={Boolean(!item.node_identity)}
onItemActionClick={onItemActionClick}
/>
);
}
return null;
})
: null}
</TableBody>
</Table>
</TableContainer>
</>
);
};
@@ -1,6 +1,6 @@
import React from 'react';
import { Typography, SxProps, Stack } from '@mui/material';
import { Link } from '@nymproject/react/link/Link';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
import { LoadingModal } from '../Modals/LoadingModal';
import { ConfirmationModal } from '../Modals/ConfirmationModal';
import { ErrorModal } from '../Modals/ErrorModal';
@@ -1,7 +1,7 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
import { DelegationWithEverything } from '@nymproject/types';
import { Link } from '@nymproject/react/link/Link';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
import { DelegationList } from './DelegationList';
import { DelegationListItemActions } from './DelegationActions';
@@ -1,7 +1,7 @@
import React from 'react';
import { Chip, TableCell, TableRow, Tooltip } from '@mui/material';
import { Box, Chip, TableCell, TableRow, Tooltip } from '@mui/material';
import { WrappedDelegationEvent } from '@nymproject/types';
import { Link } from '@nymproject/react/link/Link';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
export const PendingDelegationItem = ({ item, explorerUrl }: { item: WrappedDelegationEvent; explorerUrl: string }) => (
<TableRow key={item.node_identity}>
@@ -19,17 +19,31 @@ export const PendingDelegationItem = ({ item, explorerUrl }: { item: WrappedDele
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>
<Box sx={{ textAlign: 'left' }}>{item.event.amount?.amount} NYM</Box>
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell align="right">
<Tooltip
title={`Your delegation of ${item.event.amount?.amount} ${item.event.amount?.denom} will take effect
when the new epoch starts. There is a new
epoch every hour.`}
arrow
>
<Chip label="Pending Events" />
</Tooltip>
<TableCell sx={{ textAlign: 'center' }}>
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
<Tooltip
title={
<div style={{ textAlign: 'center', width: '100%' }}>
Your delegation of {item.event.amount?.amount} {item.event.amount?.denom} will take effect when the new
epoch starts. There is a new epoch every hour.
</div>
}
arrow
PopperProps={{
sx: {
'& .MuiTooltip-tooltip': {
textAlign: 'center',
},
},
}}
>
<Chip label="Pending Events" />
</Tooltip>
</Box>
</TableCell>
</TableRow>
);
+3 -3
View File
@@ -4,10 +4,10 @@ import { CopyToClipboard } from '@nymproject/react/clipboard/CopyToClipboard';
import { splice } from 'src/utils';
export const IdentityKey = ({ identityKey }: { identityKey: string }) => (
<Stack direction="row">
<Typography variant="body2" component="span" fontWeight={400} sx={{ mr: 1, color: 'text.primary' }}>
<Stack direction="row" alignItems="center" spacing={1}>
<Typography variant="body2" component="span" fontWeight={400} sx={{ color: 'text.primary' }}>
{splice(6, identityKey)}
</Typography>
<CopyToClipboard value={identityKey} sx={{ fontSize: 18 }} />
<CopyToClipboard value={identityKey} smallIcons />
</Stack>
);
@@ -1,9 +1,8 @@
import React, { useContext } from 'react';
import { AppContext } from 'src/context';
import { Box, Stack, SxProps } from '@mui/material';
import { Box, Stack, SxProps, Typography, alpha, useTheme } from '@mui/material';
import QRCode from 'qrcode.react';
import { ClientAddress } from '@nymproject/react/client-address/ClientAddress';
import { ModalListItem } from '../Modals/ModalListItem';
import { SimpleModal } from '../Modals/SimpleModal';
export const ReceiveModal = ({
@@ -16,50 +15,158 @@ export const ReceiveModal = ({
backdropProps?: object;
}) => {
const { clientDetails } = useContext(AppContext);
const theme = useTheme();
const isLightMode = theme.palette.mode === 'light';
const highlightColor = theme.palette.nym.highlight;
const darkBgColor = theme.palette.background.default;
return (
<SimpleModal
header="Receive"
open
onClose={onClose}
okLabel=""
sx={sx}
sx={{
...sx,
'& .MuiPaper-root': {
overflow: 'hidden',
borderRadius: '20px',
boxShadow: '0 12px 48px rgba(0, 0, 0, 0.15)',
maxWidth: '480px',
},
}}
backdropProps={backdropProps}
subHeaderStyles={{ mb: 0 }}
subHeaderStyles={{ mb: 0, px: 3, pt: 2 }}
>
<Stack gap={3} sx={{ position: 'relative', top: '32px' }}>
<ModalListItem
label="Your address"
value={
clientDetails?.client_address && (
<ClientAddress address={clientDetails?.client_address} withCopy showEntireAddress />
)
}
/>
<Stack
alignItems="center"
<Stack
gap={4}
sx={{
position: 'relative',
px: 3,
pb: 3,
pt: 1,
}}
>
<Box>
<Typography
variant="caption"
sx={{
mb: 1.5,
color: 'text.secondary',
fontWeight: 600,
display: 'block',
}}
>
Your address
</Typography>
<Box
sx={{
p: 2,
bgcolor: alpha(theme.palette.primary.main, 0.04),
borderRadius: '12px',
border: `1px solid ${alpha(theme.palette.primary.main, 0.1)}`,
position: 'relative',
}}
>
{clientDetails?.client_address && (
<Box
sx={{
fontSize: '0.9rem',
fontFamily: 'monospace',
letterSpacing: '0.5px',
wordBreak: 'break-all',
}}
>
<ClientAddress address={clientDetails?.client_address} withCopy showEntireAddress />
</Box>
)}
</Box>
</Box>
<Box
sx={{
position: 'relative',
left: '-32px',
width: '598px',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
bgcolor: isLightMode ? alpha(highlightColor, 0.06) : alpha(darkBgColor, 0.7),
borderRadius: '16px',
py: 4,
bgcolor: (t) => (t.palette.mode === 'dark' ? t.palette.background.default : 'rgba(251, 110, 78, 5%)'),
borderRadius: '0px 0px 16px 16px',
px: 2,
}}
>
<Box
sx={{
border: (t) =>
t.palette.mode === 'dark'
? `1px solid ${t.palette.nym.nymWallet.modal.border}`
: `1px solid ${t.palette.nym.highlight}`,
bgcolor: (t) => (t.palette.mode === 'dark' ? 'transparent' : 'white'),
borderRadius: 2,
p: 3,
position: 'relative',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '240px',
height: '240px',
}}
>
{clientDetails && <QRCode data-testid="qr-code" value={clientDetails?.client_address} />}
<Box
sx={{
position: 'absolute',
width: '100%',
height: '100%',
borderRadius: '16px',
background: `radial-gradient(circle, ${alpha(highlightColor, 0.15)} 0%, transparent 70%)`,
}}
/>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: 3,
bgcolor: isLightMode ? 'white' : alpha(darkBgColor, 0.7),
borderRadius: '16px',
border: `2px solid ${isLightMode ? highlightColor : theme.palette.nym.nymWallet.modal.border}`,
boxShadow: `0 10px 32px ${alpha(theme.palette.common.black, 0.1)}`,
transition: 'transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out',
'&:hover': {
transform: 'scale(1.02)',
boxShadow: `0 14px 36px ${alpha(theme.palette.common.black, 0.15)}`,
},
}}
>
{clientDetails && (
<QRCode
data-testid="qr-code"
value={clientDetails?.client_address}
size={200}
level="H"
includeMargin
bgColor={isLightMode ? '#FFFFFF' : theme.palette.background.paper}
fgColor={isLightMode ? '#000000' : highlightColor}
imageSettings={{
src: '',
excavate: true,
width: 32,
height: 32,
}}
/>
)}
</Box>
</Box>
</Stack>
<Typography
variant="body2"
sx={{
mt: 3,
color: 'text.secondary',
textAlign: 'center',
maxWidth: '80%',
}}
>
Scan this QR code with a compatible wallet to receive NYM tokens
</Typography>
</Box>
</Stack>
</SimpleModal>
);
+159 -25
View File
@@ -1,14 +1,28 @@
import React, { useEffect, useState } from 'react';
import { Stack, TextField, Typography, SxProps, FormControlLabel, Checkbox } from '@mui/material';
import { Stack, Typography, SxProps, FormControlLabel, Checkbox, Alert } from '@mui/material';
import Big from 'big.js';
import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField';
import { CurrencyDenom, DecCoin, isValidRawCoin } from '@nymproject/types';
import { validateAmount } from 'src/utils';
import { SimpleModal } from '../Modals/SimpleModal';
import { ModalListItem } from '../Modals/ModalListItem';
import { TextFieldWithPaste } from '../Clipboard/ClipboardFormFields';
import { CurrencyFormFieldWithPaste } from '../CurrencyFormFieldWithPaste';
const maxUserFees = '10.0';
const minUserFees = '0.000001'; // aka 1 unym
const MIN_AMOUNT_TO_SEND = '0.000001'; // Adjust this as needed
// NYM address validation function
const validateNymAddress = (address: string): boolean => {
if (!address) return false;
if (!address.startsWith('n1')) return false;
if (address.length !== 40) return false;
const validCharsRegex = /^[a-z0-9]+$/;
return validCharsRegex.test(address);
};
export const SendInputModal = ({
fromAddress,
@@ -52,28 +66,118 @@ export const SendInputModal = ({
const [isValid, setIsValid] = useState(false);
const [memoIsValid, setMemoIsValid] = useState(true);
const [feeAmountIsValid, setFeeAmountIsValid] = useState(true);
const [addressIsValid, setAddressIsValid] = useState(false);
const [errorAmount, setErrorAmount] = useState<string | undefined>();
const [errorFee, setErrorFee] = useState<string | undefined>();
const validate = async (value: DecCoin) => {
const isValidAmount = await validateAmount(value.amount, '0');
setIsValid(isValidAmount);
// Calculate noAccount at the component root level instead of using useEffect
const noAccount = !balance || balance === '0' || parseFloat(balance) === 0;
const validateSendAmount = async (value: DecCoin) => {
let newValidatedValue = true;
let errorAmountMessage;
if (noAccount) {
newValidatedValue = false;
errorAmountMessage = 'You need to acquire NYMs before sending. Try using the Buy section in the app.';
setIsValid(newValidatedValue);
setErrorAmount(errorAmountMessage);
return newValidatedValue;
}
if (!value.amount) {
newValidatedValue = false;
} else {
// Skip validation for partial decimal inputs during typing
if (value.amount === '.' || value.amount.endsWith('.')) {
newValidatedValue = false;
setIsValid(newValidatedValue);
setErrorAmount(undefined);
return newValidatedValue;
}
if (!(await validateAmount(value.amount, '0'))) {
newValidatedValue = false;
errorAmountMessage = 'Please enter a valid amount';
} else if (Number(value.amount) < Number(MIN_AMOUNT_TO_SEND)) {
newValidatedValue = false;
errorAmountMessage = `Min. send amount: ${MIN_AMOUNT_TO_SEND} ${denom?.toUpperCase()}`;
} else if (balance && value.amount) {
try {
const amountBig = new Big(value.amount);
const balanceBig = new Big(balance);
if (amountBig.gt(balanceBig)) {
newValidatedValue = false;
errorAmountMessage = `Make sure you have sufficient funds. Available: ${balance} ${denom?.toUpperCase()}`;
}
} catch (err) {
if (!/^\d*\.?\d*$/.test(value.amount)) {
newValidatedValue = false;
errorAmountMessage = 'Invalid number format';
}
}
}
}
setIsValid(newValidatedValue);
setErrorAmount(errorAmountMessage);
return newValidatedValue;
};
const validateUserFees = (fees: DecCoin) => {
let feeValid = true;
let errorFeeMessage;
if (noAccount) {
feeValid = false;
errorFeeMessage = 'You need to acquire NYMs before setting fees.';
setFeeAmountIsValid(feeValid);
setErrorFee(errorFeeMessage);
return feeValid;
}
// Skip validation for partial decimal inputs during typing
if (fees.amount === '.' || fees.amount.endsWith('.')) {
setFeeAmountIsValid(false);
setErrorFee(undefined);
return false;
}
if (!isValidRawCoin(fees.amount) || !Number(fees.amount)) {
setFeeAmountIsValid(false);
return;
feeValid = false;
errorFeeMessage = 'Please enter a valid fee amount';
} else {
try {
const f = Big(fees.amount);
if (f.gt(maxUserFees)) {
feeValid = false;
errorFeeMessage = `Max fee: ${maxUserFees} ${denom?.toUpperCase()}`;
} else if (f.lt(minUserFees)) {
feeValid = false;
errorFeeMessage = `Min. fee: ${minUserFees} ${denom?.toUpperCase()}`;
}
} catch (err) {
if (!/^\d*\.?\d*$/.test(fees.amount)) {
feeValid = false;
errorFeeMessage = 'Invalid fee format';
}
}
}
const f = Big(fees.amount);
if (f.gt(maxUserFees) || f.lt(minUserFees)) {
setFeeAmountIsValid(false);
return;
}
setFeeAmountIsValid(true);
setFeeAmountIsValid(feeValid);
setErrorFee(errorFeeMessage);
return feeValid;
};
useEffect(() => {
if (amount) validate(amount);
}, []);
if (amount) validateSendAmount(amount);
}, [amount, balance, noAccount]);
// Effect to validate address whenever it changes
useEffect(() => {
setAddressIsValid(validateNymAddress(toAddress));
}, [toAddress]);
useEffect(() => {
if (memo && !/^(\w|\s)+$/.test(memo)) {
@@ -89,7 +193,7 @@ export const SendInputModal = ({
} else {
setFeeAmountIsValid(true);
}
}, [userFees]);
}, [userFees, noAccount]);
return (
<SimpleModal
@@ -98,30 +202,50 @@ export const SendInputModal = ({
onClose={onClose}
okLabel="Next"
onOk={async () => onNext()}
okDisabled={!isValid || !memoIsValid || !feeAmountIsValid}
okDisabled={!isValid || !memoIsValid || !feeAmountIsValid || !addressIsValid || noAccount}
sx={sx}
backdropProps={backdropProps}
>
{noAccount && (
<Alert severity="warning" sx={{ mb: 3 }}>
To start staking, sending or operating the on the NYM network, you first need to get native NYM tokens.
</Alert>
)}
<Stack gap={3}>
<ModalListItem label="Your address" value={fromAddress} fontWeight="light" />
<TextField
{/* Recipient address field with paste button */}
<TextFieldWithPaste
label="Recipient address"
fullWidth
onChange={(e) => onAddressChange(e.target.value)}
value={toAddress}
error={toAddress !== '' && !addressIsValid}
helperText={
toAddress !== '' && !addressIsValid
? 'Invalid NYM address. Must start with n1 and be exactly 40 characters long.'
: undefined
}
InputLabelProps={{ shrink: true }}
onPasteValue={onAddressChange}
/>
<CurrencyFormField
{/* Amount field with paste button */}
<CurrencyFormFieldWithPaste
label="Amount"
fullWidth
onChanged={(value) => {
onAmountChange(value);
validate(value);
validateSendAmount(value);
}}
initialValue={amount?.amount}
denom={denom}
validationError={errorAmount}
/>
<TextField
{/* Memo field with paste button */}
<TextFieldWithPaste
name="memo"
label="Memo"
onChange={(e) => onMemoChange(e.target.value)}
@@ -133,29 +257,39 @@ export const SendInputModal = ({
}
InputLabelProps={{ shrink: true }}
fullWidth
onPasteValue={onMemoChange}
/>
<Typography fontSize="smaller" sx={{ color: 'error.main' }}>
{error}
</Typography>
</Stack>
<Stack gap={0.5} sx={{ mt: 1 }}>
<ModalListItem label="Account balance" value={balance?.toUpperCase()} divider fontWeight={600} />
<ModalListItem label="Account balance" value={balance ? balance.toUpperCase() : '0'} divider fontWeight={600} />
<Typography fontSize="smaller" sx={{ color: 'text.primary' }}>
Est. fee for this transaction will be shown on the next page
</Typography>
</Stack>
<FormControlLabel
control={<Checkbox onChange={() => setShowMore(!showMore)} checked={showMore} />}
label="More options"
sx={{ mt: 2 }}
/>
{showMore && (
<Stack direction="column" gap={3} mt={2} mb={3}>
<CurrencyFormField
<Stack mt={2} mb={3}>
<CurrencyFormFieldWithPaste
label="Fee"
onChanged={(v) => onUserFeesChange(v)}
onChanged={(v) => {
onUserFeesChange(v);
validateUserFees(v);
}}
initialValue={userFees?.amount}
fullWidth
denom={denom}
validationError={errorFee}
/>
</Stack>
)}
@@ -1,6 +1,6 @@
import React, { useContext } from 'react';
import { Stack, Typography, SxProps } from '@mui/material';
import { Link } from '@nymproject/react/link/Link';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
import { AppContext } from 'src/context';
import { TTransactionDetails } from './types';
import { ConfirmationModal } from '../Modals/ConfirmationModal';
@@ -1,6 +1,6 @@
import React, { useContext, useEffect, useState } from 'react';
import { Button, Stack, Typography } from '@mui/material';
import { checkUpdate } from '@tauri-apps/api/updater';
import { check } from '@tauri-apps/plugin-updater';
import { AppContext } from '../../context';
import { checkVersion } from '../../requests';
import { Console } from '../../utils/console';
@@ -28,7 +28,7 @@ const AppVersion = () => {
try {
// despite the name, this will spawn an external native window with
// an embedded "download and update the Wallet" flow
checkUpdate();
check();
} catch (e) {
Console.error(e);
}
@@ -0,0 +1,21 @@
import React from 'react';
import { Link, LinkProps } from '@nymproject/react/link/Link';
import { openUrl } from '@tauri-apps/plugin-opener';
export const TauriLink: React.FC<LinkProps & any> = (props) => {
const { href, onClick, ...restProps } = props;
const handleClick = async (event: React.MouseEvent<HTMLAnchorElement>) => {
if (onClick) {
onClick(event);
}
if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
event.preventDefault();
console.log('Opening link in browser:', href);
await openUrl(href);
}
};
return <Link href={href} onClick={handleClick} {...restProps} />;
};
+1 -1
View File
@@ -1,6 +1,6 @@
export * from './AppBar';
export * from './ConfirmPassword';
export * from './CopyToClipboard';
export * from './Clipboard';
export * from './ErrorFallback';
export * from './InfoToolTip';
export * from './LoadingPage';
+22 -16
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { Alert, Grid, Typography } from '@mui/material';
import { Link } from '@nymproject/react/link/Link';
import { Alert, Grid, Typography, Skeleton } from '@mui/material';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
import { ClientAddress } from '@nymproject/react/client-address/ClientAddress';
import { Network } from 'src/types';
import { Balance } from '@nymproject/types';
@@ -12,11 +12,13 @@ export const BalanceCard = ({
userBalanceError,
network,
clientAddress,
isLoading,
}: {
userBalance?: Balance;
userBalanceError?: string;
network?: Network;
clientAddress?: string;
isLoading?: boolean;
}) => (
<NymCard
title="Balance"
@@ -31,25 +33,29 @@ export const BalanceCard = ({
{userBalanceError}
</Alert>
)}
{!userBalanceError && (
<Typography
data-testid="refresh-success"
sx={{
color: 'text.primary',
textTransform: 'uppercase',
fontWeight: '600',
fontSize: 28,
}}
variant="h5"
>
{userBalance?.printable_balance}
</Typography>
{isLoading ? (
<Skeleton width={160} height={42} />
) : (
!userBalanceError && (
<Typography
data-testid="refresh-success"
sx={{
color: 'text.primary',
textTransform: 'uppercase',
fontWeight: '600',
fontSize: 28,
}}
variant="h5"
>
{userBalance?.printable_balance || '—'}
</Typography>
)
)}
</Grid>
{network && (
<Grid item>
<Link
href={`${urls(network).mixnetExplorer}/account/${clientAddress}`}
href={`${urls(network).mixnetExplorer}account/${clientAddress}`}
target="_blank"
text="Last transactions"
fontSize={14}
+7 -3
View File
@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { Refresh } from '@mui/icons-material';
import { Grid, IconButton, Typography } from '@mui/material';
import { Grid, IconButton, Typography, Skeleton } from '@mui/material';
import { useSnackbar } from 'notistack';
import { NymCard } from 'src/components';
import { TokenTransfer } from 'src/components/Balance/cards/TokenTransfer';
@@ -15,6 +15,7 @@ export const VestingCard = ({
onTransfer,
fetchBalance,
fetchTokenAllocation,
isLoading,
}: {
unlockedTokens?: string;
unlockedRewards?: string;
@@ -23,6 +24,7 @@ export const VestingCard = ({
fetchTokenAllocation: () => Promise<void>;
fetchBalance: () => Promise<void>;
onTransfer: () => Promise<void>;
isLoading?: boolean;
}) => {
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
@@ -54,14 +56,15 @@ export const VestingCard = ({
await refreshBalances();
enqueueSnackbar('Balances updated', { variant: 'success', preventDuplicate: true });
}}
disabled={isLoading}
>
<Refresh />
{isLoading ? <Skeleton variant="circular" width={24} height={24} /> : <Refresh />}
</IconButton>
}
>
<Grid container spacing={3}>
<Grid item xs={12} md={7} lg={8}>
<VestingSchedule />
<VestingSchedule isLoading={isLoading} />
</Grid>
<Grid item xs={12} md={5} lg={4}>
<TokenTransfer
@@ -69,6 +72,7 @@ export const VestingCard = ({
unlockedTokens={unlockedTokens}
unlockedRewards={unlockedRewards}
unlockedTransferable={unlockedTransferable}
isLoading={isLoading}
/>
</Grid>
</Grid>
@@ -1,254 +0,0 @@
import React, { useContext, useState } from 'react';
import { useForm } from 'react-hook-form';
import { clean } from 'semver';
import { yupResolver } from '@hookform/resolvers/yup';
import { Button, Divider, Typography, TextField, Grid, Box } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import {
simulateUpdateGatewayConfig,
simulateVestingUpdateGatewayConfig,
updateGatewayConfig,
vestingUpdateGatewayConfig,
} from 'src/requests';
import { useBondingContext } from 'src/context/bonding';
import { SimpleModal } from 'src/components/Modals/SimpleModal';
import { Console } from 'src/utils/console';
import { Alert } from 'src/components/Alert';
import { ConfirmTx } from 'src/components/ConfirmTX';
import { useGetFee } from 'src/hooks/useGetFee';
import { LoadingModal } from 'src/components/Modals/LoadingModal';
import { updateGatewayValidationSchema } from 'src/components/Bonding/forms/legacyForms/gatewayValidationSchema';
import { BalanceWarning } from 'src/components/FeeWarning';
import { AppContext } from 'src/context';
import { TBondedGateway } from 'src/requests/gatewayDetails';
export const GeneralGatewaySettings = ({ bondedNode }: { bondedNode: TBondedGateway }) => {
const [openConfirmationModal, setOpenConfirmationModal] = useState<boolean>(false);
const { getFee, fee, resetFeeState } = useGetFee();
const { refresh } = useBondingContext();
const { userBalance } = useContext(AppContext);
const theme = useTheme();
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm({
resolver: yupResolver(updateGatewayValidationSchema),
mode: 'onChange',
defaultValues: {
host: bondedNode.host,
mixPort: bondedNode.mixPort,
httpApiPort: bondedNode.httpApiPort,
version: bondedNode.version,
location: bondedNode.location,
},
});
const onSubmit = async (data: any) => {
resetFeeState();
const { host, mixPort, httpApiPort, version, location } = data;
try {
const GatewayConfigParams = {
host,
mix_port: mixPort,
location,
version: clean(version) as string,
clients_port: httpApiPort,
};
if (bondedNode.proxy) {
await vestingUpdateGatewayConfig(GatewayConfigParams, fee?.fee);
} else {
await updateGatewayConfig(GatewayConfigParams, fee?.fee);
}
setOpenConfirmationModal(true);
} catch (error) {
Console.error(error);
}
};
return (
<Grid container xs item>
{fee && (
<ConfirmTx
open
header="Update node settings"
fee={fee}
onConfirm={handleSubmit((d) => onSubmit(d))}
onPrev={resetFeeState}
onClose={resetFeeState}
>
{fee.amount?.amount && userBalance?.balance?.amount.amount && (
<Box sx={{ mb: 2 }}>
<BalanceWarning fee={fee.amount.amount} />
</Box>
)}
</ConfirmTx>
)}
{isSubmitting && <LoadingModal />}
<Alert
title={
<Box sx={{ fontWeight: 600 }}>
Changing these values will ONLY change the data about your node on the blockchain. Remember to change your
nodes config file with the same values too
</Box>
}
dismissable
/>
<Grid container>
<Grid item container direction="row" alignItems="left" justifyContent="space-between" padding={3}>
<Grid item>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 1 }}>
Port
</Typography>
</Grid>
<Grid spacing={3} item container alignItems="center" xs={12} md={6}>
<Grid item width={1}>
<TextField
{...register('mixPort')}
name="mixPort"
label="Mix Port"
fullWidth
error={!!errors.mixPort}
helperText={errors.mixPort?.message}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item width={1}>
<TextField
{...register('httpApiPort')}
name="httpApiPort"
label="Client Port"
fullWidth
error={!!errors.httpApiPort}
helperText={errors.httpApiPort?.message}
InputLabelProps={{ shrink: true }}
/>
</Grid>
</Grid>
</Grid>
<Grid item container direction="row" alignItems="left" justifyContent="space-between" padding={3}>
<Grid item>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 1 }}>
Host
</Typography>
</Grid>
<Grid spacing={3} item container alignItems="center" xs={12} md={6}>
<Grid item width={1}>
<TextField
{...register('host')}
name="host"
label="Host"
fullWidth
error={!!errors.host}
helperText={errors.host?.message}
InputLabelProps={{ shrink: true }}
/>
</Grid>
</Grid>
</Grid>
<Grid item container direction="row" alignItems="left" justifyContent="space-between" padding={3}>
<Grid item>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 1 }}>
Version
</Typography>
</Grid>
<Grid spacing={3} item container alignItems="center" xs={12} md={6}>
<Grid item width={1}>
<TextField
{...register('version')}
name="version"
label="Version"
fullWidth
error={!!errors.version}
helperText={errors.version?.message}
InputLabelProps={{ shrink: true }}
/>
</Grid>
</Grid>
</Grid>
<Grid item container direction="row" alignItems="left" justifyContent="space-between" padding={3}>
<Grid item>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 1 }}>
Location
</Typography>
</Grid>
<Grid spacing={3} item container alignItems="center" xs={12} md={6}>
<Grid item width={1}>
<TextField
{...register('location')}
name="location"
label="Location"
fullWidth
error={!!errors.location}
helperText={errors.location?.message}
InputLabelProps={{ shrink: true }}
/>
</Grid>
</Grid>
</Grid>
<Divider flexItem />
<Grid container justifyContent="end">
<Button
size="large"
variant="contained"
disabled={isSubmitting || !isDirty || !isValid}
onClick={handleSubmit((data) =>
getFee(bondedNode.proxy ? simulateVestingUpdateGatewayConfig : simulateUpdateGatewayConfig, {
host: data.host,
mix_port: data.mixPort,
clients_port: data.httpApiPort,
location: bondedNode.location!,
version: data.version,
}),
)}
sx={{ m: 3 }}
>
Submit changes to the blockchain
</Button>
</Grid>
</Grid>
<SimpleModal
open={openConfirmationModal}
header="Your changes are submitted to the blockchain"
subHeader="Remember to change the values
on your gatewayss config file too."
okLabel="close"
hideCloseIcon
displayInfoIcon
onOk={async () => {
setOpenConfirmationModal(false);
await refresh();
}}
buttonFullWidth
sx={{
width: '450px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
headerStyles={{
width: '100%',
mb: 1,
textAlign: 'center',
color: theme.palette.nym.nymWallet.text.blue,
fontSize: 16,
}}
subHeaderStyles={{
width: '100%',
mb: 1,
textAlign: 'center',
color: 'main',
fontSize: 14,
}}
/>
</Grid>
);
};
@@ -1,245 +0,0 @@
import React, { useContext, useState } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { clean } from 'semver';
import { Box, Button, Divider, Grid, Stack, TextField, Typography } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import { isMixnode } from 'src/types';
import { simulateUpdateMixnodeConfig, simulateVestingUpdateMixnodeConfig, updateMixnodeConfig } from 'src/requests';
import { SimpleModal } from 'src/components/Modals/SimpleModal';
import { bondedInfoParametersValidationSchema } from 'src/components/Bonding/forms/legacyForms/mixnodeValidationSchema';
import { Console } from 'src/utils/console';
import { Alert } from 'src/components/Alert';
import { vestingUpdateMixnodeConfig } from 'src/requests/vesting';
import { ConfirmTx } from 'src/components/ConfirmTX';
import { useGetFee } from 'src/hooks/useGetFee';
import { LoadingModal } from 'src/components/Modals/LoadingModal';
import { BalanceWarning } from 'src/components/FeeWarning';
import { AppContext } from 'src/context';
import { TBondedMixnode } from 'src/requests/mixnodeDetails';
export const GeneralMixnodeSettings = ({ bondedNode }: { bondedNode: TBondedMixnode }) => {
const [openConfirmationModal, setOpenConfirmationModal] = useState<boolean>(false);
const { getFee, fee, resetFeeState } = useGetFee();
const { userBalance } = useContext(AppContext);
const theme = useTheme();
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm({
resolver: yupResolver(bondedInfoParametersValidationSchema),
mode: 'onChange',
defaultValues: isMixnode(bondedNode) ? bondedNode : {},
});
const onSubmit = async (data: {
host?: string;
version?: string;
mixPort?: number;
verlocPort?: number;
httpApiPort?: number;
}) => {
resetFeeState();
const { host, version, mixPort, verlocPort, httpApiPort } = data;
if (host && version && mixPort && verlocPort && httpApiPort) {
const MixNodeConfigParams = {
host,
mix_port: mixPort,
verloc_port: verlocPort,
http_api_port: httpApiPort,
version: clean(version) as string,
};
try {
if (bondedNode.proxy) {
await vestingUpdateMixnodeConfig(MixNodeConfigParams);
} else {
await updateMixnodeConfig(MixNodeConfigParams);
}
setOpenConfirmationModal(true);
} catch (error) {
Console.error(error);
}
}
};
return (
<Grid container xs>
{fee && (
<ConfirmTx
open
header="Update node settings"
fee={fee}
onConfirm={handleSubmit((d) => onSubmit(d))}
onPrev={resetFeeState}
onClose={resetFeeState}
>
{fee.amount?.amount && userBalance?.balance?.amount.amount && (
<Box sx={{ mb: 2 }}>
<BalanceWarning fee={fee.amount.amount} />
</Box>
)}
</ConfirmTx>
)}
{isSubmitting && <LoadingModal />}
<Alert
title={
<Stack>
<Typography fontWeight={600}>
Changing these values will ONLY change the data about your node on the blockchain.
</Typography>
<Typography>Remember to change your nodes config file with the same values too.</Typography>
</Stack>
}
bgColor={`${theme.palette.nym.nymWallet.text.blue}0D !important`}
dismissable
/>
<Grid container mt={2}>
<Grid item container direction="row" alignItems="left" justifyContent="space-between" padding={3}>
<Grid item>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 1 }}>
Port
</Typography>
</Grid>
<Grid spacing={3} item container alignItems="center" xs={12} md={6}>
<Grid item width={1}>
<TextField
{...register('mixPort')}
name="mixPort"
label="Mix Port"
fullWidth
error={!!errors.mixPort}
helperText={errors.mixPort?.message}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item width={1}>
<TextField
{...register('verlocPort')}
name="verlocPort"
label="Verloc Port"
fullWidth
error={!!errors.verlocPort}
helperText={errors.verlocPort?.message}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item width={1}>
<TextField
{...register('httpApiPort')}
name="httpApiPort"
label="HTTP port"
fullWidth
error={!!errors.httpApiPort}
helperText={errors.httpApiPort?.message}
InputLabelProps={{ shrink: true }}
/>
</Grid>
</Grid>
</Grid>
<Divider sx={{ width: '100%' }} />
<Grid item container direction="row" alignItems="left" justifyContent="space-between" padding={3}>
<Grid item>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 1 }}>
Host
</Typography>
</Grid>
<Grid spacing={3} item container alignItems="center" xs={12} md={6}>
<Grid item width={1}>
<TextField
{...register('host')}
name="host"
label="Host"
fullWidth
error={!!errors.host}
helperText={errors.host?.message}
InputLabelProps={{ shrink: true }}
/>
</Grid>
</Grid>
</Grid>
<Divider sx={{ width: '100%' }} />
<Grid item container direction="row" alignItems="left" justifyContent="space-between" padding={3}>
<Grid item>
<Typography variant="body1" sx={{ fontWeight: 600, mb: 1 }}>
Version
</Typography>
</Grid>
<Grid spacing={3} item container alignItems="center" xs={12} md={6}>
<Grid item width={1}>
<TextField
{...register('version')}
name="version"
label="Version"
fullWidth
error={!!errors.version}
helperText={errors.version?.message}
InputLabelProps={{ shrink: true }}
/>
</Grid>
</Grid>
</Grid>
<Divider sx={{ width: '100%' }} />
<Grid item container direction="row" justifyContent="space-between" padding={3}>
<Grid item />
<Grid spacing={3} item container alignItems="center" xs={12} md={6}>
<Button
size="large"
variant="contained"
disabled={isSubmitting || !isDirty || !isValid}
onClick={handleSubmit((data) =>
getFee(bondedNode.proxy ? simulateVestingUpdateMixnodeConfig : simulateUpdateMixnodeConfig, {
host: data.host,
mix_port: data.mixPort,
verloc_port: data.verlocPort,
http_api_port: data.httpApiPort,
version: data.version,
}),
)}
sx={{ m: 3, mr: 0 }}
fullWidth
>
Submit changes to the blockchain
</Button>
</Grid>
</Grid>
</Grid>
<SimpleModal
open={openConfirmationModal}
header="Your changes are submitted to the blockchain"
subHeader="Remember to change the values
on your nodes config file too."
okLabel="close"
hideCloseIcon
displayInfoIcon
onOk={async () => {
await setOpenConfirmationModal(false);
}}
buttonFullWidth
sx={{
width: '450px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
headerStyles={{
width: '100%',
mb: 1,
textAlign: 'center',
color: theme.palette.nym.nymWallet.text.blue,
fontSize: 16,
}}
subHeaderStyles={{
width: '100%',
mb: 1,
textAlign: 'center',
color: 'main',
fontSize: 14,
}}
/>
</Grid>
);
};
@@ -1,10 +1,8 @@
import React, { useState } from 'react';
import { Box, Button, Divider, Grid } from '@mui/material';
import { isGateway, isMixnode, isNymNode } from 'src/types';
import { isMixnode, isNymNode } from 'src/types';
import { TBondedNode } from 'src/context/bonding';
import { GeneralMixnodeSettings } from './GeneralMixnodeSettings';
import { ParametersSettings } from './ParametersSettings';
import { GeneralGatewaySettings } from './GeneralGatewaySettings';
import { GeneralNymNodeSettings } from './GeneralNymNodeSettings';
const makeGeneralNav = (bondedNode: TBondedNode) => {
@@ -22,8 +20,6 @@ export const NodeGeneralSettings = ({ bondedNode }: { bondedNode: TBondedNode })
const getSettings = () => {
switch (navSelection) {
case 0: {
if (isMixnode(bondedNode)) return <GeneralMixnodeSettings bondedNode={bondedNode} />;
if (isGateway(bondedNode)) return <GeneralGatewaySettings bondedNode={bondedNode} />;
if (isNymNode(bondedNode)) return <GeneralNymNodeSettings bondedNode={bondedNode} />;
break;
}
+14 -3
View File
@@ -2,7 +2,7 @@ import React, { FC, useContext, useEffect, useState } from 'react';
import { Alert, AlertTitle, Box, Button, Paper, Stack, Typography } from '@mui/material';
import { Theme, useTheme } from '@mui/material/styles';
import { DecCoin, decimalToFloatApproximation, DelegationWithEverything, FeeDetails } from '@nymproject/types';
import { Link } from '@nymproject/react/link/Link';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
import { AppContext, urls } from 'src/context/main';
import { DelegationList } from 'src/components/Delegation/DelegationList';
import { TPoolOption } from 'src/components';
@@ -401,7 +401,16 @@ export const Delegation: FC<{ isStorybook?: boolean }> = ({ isStorybook }) => {
return (
<>
<Paper elevation={0} sx={{ p: 3, mt: 4 }}>
{/* Main container - make sure it constrains width properly */}
<Paper
elevation={0}
sx={{
p: 3,
mt: 4,
maxWidth: '100%',
overflowX: 'hidden',
}}
>
<Stack spacing={3}>
<Box display="flex" justifyContent="space-between">
{' '}
@@ -446,7 +455,9 @@ export const Delegation: FC<{ isStorybook?: boolean }> = ({ isStorybook }) => {
)}
</Box>
)}
{delegationsComponent(delegations)}
{/* Add a container to ensure delegations are constrained */}
<Box sx={{ width: '100%', overflowX: 'hidden' }}>{delegationsComponent(delegations)}</Box>
</Stack>
</Paper>
@@ -1,7 +1,7 @@
/* eslint-disable react/destructuring-assignment */
import React from 'react';
import { Button, Card, CardContent, TextField } from '@mui/material';
import { invoke } from '@tauri-apps/api';
import { invoke } from '@tauri-apps/api/core';
interface DocEntryProps {
function: FunctionDef;
@@ -1,6 +1,7 @@
import React, { useContext } from 'react';
import { Stack, Typography } from '@mui/material';
import { Link } from '@nymproject/react/link/Link';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
import { urls, AppContext } from '../../context/main';
export const NodeStats = ({ mixnodeId }: { mixnodeId?: string }) => {
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useSnackbar } from 'notistack';
import { decimalToPercentage, InclusionProbabilityResponse, MixnodeStatus } from '@nymproject/types';
import { getInclusionProbability, getMixnodeStakeSaturation, getMixnodeStatus } from '../../requests';
import { getMixnodeStakeSaturation, getMixnodeStatus } from '../../requests';
export const useSettingsState = () => {
const [status, setStatus] = useState<MixnodeStatus>('not_found');
@@ -27,14 +27,6 @@ export const useSettingsState = () => {
}
};
const getMixnodeInclusionProbability = async (mixId: number) => {
const probability = await getInclusionProbability(mixId);
if (probability) {
// eslint-disable-next-line @typescript-eslint/naming-convention
setInclusionProbability({ in_active: probability.in_active, in_reserve: probability.in_reserve });
}
};
const reset = () => {
setStatus('not_found');
setSaturation('-');
@@ -46,7 +38,6 @@ export const useSettingsState = () => {
try {
await getStatus(mixId);
await getStakeSaturation(mixId);
await getMixnodeInclusionProbability(mixId);
} catch (e) {
enqueueSnackbar(e as string, { variant: 'error', preventDuplicate: true });
reset();
@@ -59,7 +59,6 @@ async function getGatewayDetails() {
version: gateway.version,
};
} catch (error) {
console.error(error);
return null;
}
}
-10
View File
@@ -14,7 +14,6 @@ import {
getMixnodeUptime,
getMixnodeStakeSaturation,
getMixnodeRewardEstimation,
getInclusionProbability,
getMixnodeAvgUptime,
getMixNodeDescription as getNodeDescriptionRequest,
getPendingOperatorRewards,
@@ -80,14 +79,6 @@ async function getAdditionalMixnodeDetails(mixId: number, host: string, port: nu
},
};
const inclusionReq: TauriReq<typeof getInclusionProbability> = {
name: 'getInclusionProbability',
request: () => getInclusionProbability(mixId),
onFulfilled: (value) => {
details.setProbability = value;
},
};
const avgUptimeReq: TauriReq<typeof getMixnodeAvgUptime> = {
name: 'getMixnodeAvgUptime',
request: () => getMixnodeAvgUptime(),
@@ -117,7 +108,6 @@ async function getAdditionalMixnodeDetails(mixId: number, host: string, port: nu
uptimeReq,
stakeSaturationReq,
rewardReq,
inclusionReq,
avgUptimeReq,
nodeDescReq,
operatorRewardsReq,
-4
View File
@@ -1,6 +1,5 @@
import {
DecCoin,
InclusionProbabilityResponse,
MixnodeStatusResponse,
PendingIntervalEvent,
RewardEstimationResponse,
@@ -33,9 +32,6 @@ export const checkGatewayOwnership = async () => invokeWrapper<boolean>('owns_ga
export const checkNymNodeOwnership = async () => invokeWrapper<boolean>('owns_nym_node');
export const getInclusionProbability = async (mixId: number) =>
invokeWrapper<InclusionProbabilityResponse>('mixnode_inclusion_probability', { mixId });
export const getCurrentInterval = async () => invokeWrapper<Interval>('get_current_interval');
export const getNumberOfMixnodeDelegators = async (mixId: number) =>
+1 -1
View File
@@ -1,4 +1,4 @@
import { invoke } from '@tauri-apps/api';
import { invoke } from '@tauri-apps/api/core';
import { config } from '../config';
import { Console } from '../utils/console';
+36 -2
View File
@@ -1,17 +1,51 @@
import React from 'react';
import React, { useMemo, useEffect } from 'react';
import { CssBaseline, PaletteMode } from '@mui/material';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import { getDesignTokens } from './theme';
import '@assets/fonts/non-variable/fonts.css';
let fontsInitialized = false;
let interFontLink: HTMLLinkElement | null = null;
const FontLoader = () => {
useEffect(() => {
// Skip if already initialized
if (fontsInitialized === true) {
return;
}
fontsInitialized = true;
interFontLink = document.createElement('link');
interFontLink.rel = 'stylesheet';
interFontLink.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap';
document.head.appendChild(interFontLink);
// eslint-disable-next-line consistent-return
return () => {
// Only clean up if the component is truly being unmounted
if (interFontLink && document.head.contains(interFontLink)) {
document.head.removeChild(interFontLink);
interFontLink = null;
fontsInitialized = false;
}
};
}, []);
return null;
};
export const NymWalletThemeWithMode: FCWithChildren<{ mode: PaletteMode; children: React.ReactNode }> = ({
mode,
children,
}) => {
const theme = React.useMemo(() => createTheme(getDesignTokens(mode)), [mode]);
// Create theme with memoization for performance
const theme = useMemo(() => createTheme(getDesignTokens(mode)), [mode]);
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<FontLoader />
{children}
</ThemeProvider>
);
+4 -1
View File
@@ -8,13 +8,16 @@ import { NymWalletThemeWithMode } from './NymWalletTheme';
/**
* Provides the theme for the Network Explorer by reacting to the light/dark mode choice stored in the app context.
*/
export const NymWalletTheme: FCWithChildren = ({ children }) => {
const { mode } = useContext(AppContext);
return <NymWalletThemeWithMode mode={mode}>{children}</NymWalletThemeWithMode>;
};
/**
* Themed component specifically for authentication screens
*/
export const AuthTheme: FCWithChildren = ({ children }) => {
// Uses dark mode by default for auth screens
const theme = createTheme(getDesignTokens('dark'));
return (
<ThemeProvider theme={theme}>
+13
View File
@@ -52,6 +52,8 @@ declare module '@mui/material/styles' {
warn: string;
grey: string;
greyStroke: string;
elevated: string; // New property for more depth options
subtle: string; // New property for subtle backgrounds
};
text: {
main: string;
@@ -60,6 +62,7 @@ declare module '@mui/material/styles' {
contrast: string;
grey: string;
blue: string;
subdued: string; // New property for better text hierarchy
};
topNav: {
background: string;
@@ -76,6 +79,16 @@ declare module '@mui/material/styles' {
chart: {
grey: string;
};
// New modern properties
gradients: {
primary: string;
subtle: string;
};
shadows: {
light: string;
medium: string;
strong: string;
};
}
/**

Some files were not shown because too many files have changed in this diff Show More