Nym Wallet: deps updates, clipboard/updater/, icon, polishing...

This rolls together desktop wallet hardening, UX polish, and operational fixes we have been carrying in the branch. The goal is safer defaults, less noisy background behaviour.

Security
- Tighten the Tauri CSP for production and keep connect-src aligned with real needs.
- Add a safe URL opener path (allowlisted schemes / validation) so user-influenced links do not become an open redirect surface.
- Replace unwrap usage in mixnet account flows with proper errors and propagation.
- Add an internal threat-model note so future changes keep the same assumptions explicit.

Clipboard and desktop
- Add a window-level Tauri clipboard hook for normal inputs, with clear exclusions for
  currency fields, auth-sensitive paste, and opt-in replace-paste fields.
- Wire an Edit menu (cut, copy, paste, select all) where it helps, and keep behaviour
  consistent with the hook.
- Deduplicate clipboard field props and satisfy ESLint on optional paste handlers.
Updater and vesting operations
- Treat legacy static updater JSON (missing per-platform signatures) as a soft failure with a clear warning, instead of erroring the version check IPC
- Cut vesting polling spam when the chain has no vesting account for the address, and map vesting "no account" to a dedicated BackendError for stable handling on the client.
- Move high-frequency vesting query logs to debug and keep removed-query stubs at warn.

Icons and first-run chrome
- Regenerate macOS/Windows icon assets from a padded 1024 master so dock and switcher visual weight matches other apps; add a small script to regenerate from app-icon-source.png.
- Default the app to dark mode, paint the HTML shell and webview background in the same dark base colour

Housekeeping
- Mock app context defaults to dark for consistency with the new baseline.
Validation run locally where relevant: Rust check, TypeScript check, ESLint, and icon
regeneration script smoke run.

- Remove storybook and old webdriver tests too
This commit is contained in:
Tommy Verrall
2026-04-16 21:19:25 +02:00
parent ad56645fc5
commit 809559e6dc
178 changed files with 6221 additions and 7721 deletions
@@ -0,0 +1,34 @@
name: ci-nym-wallet-frontend
on:
pull_request:
paths:
- 'nym-wallet/**'
- '.github/workflows/ci-nym-wallet-frontend.yml'
jobs:
types-lint:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version-file: nym-wallet/.nvmrc
cache: yarn
cache-dependency-path: yarn.lock
- name: Install dependencies
run: yarn install --network-timeout 100000
- name: Build TypeScript packages (wallet depends on @nymproject/types, etc.)
run: yarn build:types
- name: Typecheck nym-wallet
run: yarn --cwd nym-wallet tsc
- name: Lint nym-wallet
run: yarn --cwd nym-wallet lint
- name: Unit tests (nym-wallet)
run: yarn --cwd nym-wallet test
@@ -1,53 +0,0 @@
name: ci-nym-wallet-storybook
on:
pull_request:
paths:
- 'nym-wallet/**'
- '.github/workflows/ci-nym-wallet-storybook.yml'
jobs:
build:
runs-on: arc-linux-latest-dind
steps:
- uses: actions/checkout@v6
- name: Install rsync
run: sudo apt-get install rsync
continue-on-error: true
- uses: rlespinasse/github-slug-action@v3.x
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup yarn
run: npm install -g yarn
- name: Install Rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ vars.REQUIRED_RUSTC_VERSION }}
- name: Install wasm-pack
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- name: Build dependencies
run: yarn && yarn build
- name: Build storybook
run: yarn storybook:build
working-directory: ./nym-wallet
- name: Deploy branch to CI www (storybook)
continue-on-error: true
uses: easingthemes/ssh-deploy@main
env:
SSH_PRIVATE_KEY: ${{ secrets.CI_WWW_SSH_PRIVATE_KEY }}
ARGS: "-rltgoDzvO --delete"
SOURCE: "nym-wallet/storybook-static/"
REMOTE_HOST: ${{ secrets.CI_WWW_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.CI_WWW_REMOTE_USER }}
TARGET: ${{ secrets.CI_WWW_REMOTE_TARGET }}/wallet-${{ env.GITHUB_REF_SLUG }}
EXCLUDE: "/dist/, /node_modules/"
@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@v6
- name: Install Dependencies (Linux)
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.0-dev build-essential curl wget libssl-dev libgtk-3-dev squashfs-tools
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget libssl-dev libgtk-3-dev squashfs-tools libsoup-3.0-dev libjavascriptcoregtk-4.1-dev
if: matrix.os == 'ubuntu-22.04'
- name: Install rust toolchain
@@ -26,7 +26,7 @@ jobs:
- name: Node
uses: actions/setup-node@v4
with:
node-version: 21
node-version: 22.13.0
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -30,7 +30,7 @@ jobs:
- name: Node
uses: actions/setup-node@v4
with:
node-version: 21
node-version: 22.13.0
cache: 'yarn'
- name: Install Rust toolchain
@@ -40,7 +40,7 @@ jobs:
- name: Node
uses: actions/setup-node@v4
with:
node-version: 21
node-version: 22.13.0
- name: Download EV CodeSignTool from ssl.com
working-directory: nym-wallet/src-tauri
+14
View File
@@ -4,5 +4,19 @@
],
"parserOptions": {
"project": "./tsconfig.eslint.json"
},
"overrides": [
{
"files": ["**/*.test.ts", "**/*.test.tsx"],
"env": { "jest": true }
}
],
"rules": {
"@typescript-eslint/lines-between-class-members": "off",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-implied-eval": "off",
"@typescript-eslint/no-throw-literal": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/quotes": "off"
}
}
+1 -1
View File
@@ -1 +1 @@
18
22.13.0
-60
View File
@@ -1,60 +0,0 @@
/* eslint-disable no-param-reassign */
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
module.exports = {
stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'],
framework: '@storybook/react',
core: {
builder: 'webpack5',
},
typescript: { reactDocgen: false },
// webpackFinal: async (config, { configType }) => {
// // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
// // You can change the configuration based on that.
// // 'PRODUCTION' is used when building the static version of storybook.
webpackFinal: async (config) => {
config.module.rules.forEach((rule) => {
// look for SVG import rule and replace
// NOTE: the rule before modification is /\.(svg|ico|jpg|jpeg|png|apng|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/
if (rule.test?.toString().includes('svg')) {
rule.test = /\.(ico|jpg|jpeg|png|apng|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/;
}
});
// handle asset loading with this
config.module.rules.unshift({
test: /\.svg(\?.*)?$/i,
issuer: /\.[jt]sx?$/,
use: ['@svgr/webpack'],
});
config.resolve.extensions = ['.tsx', '.ts', '.js'];
config.resolve.plugins = [new TsconfigPathsPlugin()];
config.plugins.push(
new ForkTsCheckerWebpackPlugin({
typescript: {
mode: 'write-references',
diagnosticOptions: {
semantic: true,
syntactic: true,
},
},
}),
);
if (!config.resolve.alias) {
config.resolve.alias = {};
}
config.resolve.alias['@tauri-apps/api'] = `${__dirname}/mocks/tauri`;
// Return the altered config
return config;
},
features: {
emotionAlias: false,
},
};
-8
View File
@@ -1,8 +0,0 @@
/**
* This is a mock for Tauri's API package (@tauri-apps/api/app), to prevent stories from being excluded, because they either use
* or import dependencies that use Tauri.
*/
module.exports = {
getVersion: () => undefined,
};
@@ -1,8 +0,0 @@
/**
* This is a mock for Tauri's API package (@tauri-apps/api/app), to prevent stories from being excluded, because they either use
* or import dependencies that use Tauri.
*/
module.exports = {
invoke: () => undefined,
}
@@ -1,4 +0,0 @@
/**
* This is a mock for Tauri's API package (@tauri-apps/api/app), to prevent stories from being excluded, because they either use
* or import dependencies that use Tauri.
*/
-113
View File
@@ -1,113 +0,0 @@
const delegations = [
{
mix_id: 1234,
node_identity: 'FiojKW7oY9WQmLCiYAsCA21tpowZHS6zcUoyYm319p6Z',
delegated_on_iso_datetime: new Date(2021, 1, 1).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
amount: { amount: '10', denom: 'nym' },
owner: '',
block_height: BigInt(100),
cost_params: {
profit_margin_percent: '0.04',
interval_operating_cost: {
amount: '20',
denom: 'nym',
},
},
stake_saturation: '0.2',
avg_uptime_percent: 0.5,
accumulated_by_delegates: { amount: '0', denom: 'nym' },
accumulated_by_operator: { amount: '0', denom: 'nym' },
uses_vesting_contract_tokens: false,
pending_events: [],
mixnode_is_unbonding: false,
errors: null,
},
{
mix_id: 5678,
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
unclaimed_rewards: { amount: '0.1', denom: 'nym' },
amount: { amount: '100', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.5',
avg_uptime_percent: 0.1,
cost_params: {
profit_margin_percent: '0.04',
interval_operating_cost: {
amount: '60',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '0', denom: 'nym' },
accumulated_by_operator: { amount: '0', denom: 'nym' },
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: false,
errors: null,
},
];
/**
* This is a mock for Tauri's API package (@tauri-apps/api), to prevent stories from being excluded, because they either use
* or import dependencies that use Tauri.
*/
module.exports = {
invoke: (operation, args) => {
switch (operation) {
case 'get_balance': {
return {
amount: {
amount: '100',
denom: 'nymt',
},
printable_balance: '100 NYMT',
};
}
case 'delegate_to_mixnode': {
return {
logs_json: '[]',
data_json: '{}',
transaction_hash: '12345',
};
}
case 'simulate_send': {
return {
amount: {
amount: '0.01',
denom: 'nym',
},
};
}
case 'get_delegation_summary': {
return {
delegations,
total_delegations: {
amount: '1000',
denom: 'nymt',
},
total_rewards: {
amount: '42',
denom: 'nymt',
},
};
}
case 'get_pending_delegation_events' : {
return [];
}
case 'migrate_vested_delegations': {
delegations[1].uses_vesting_contract_tokens = false;
return {};
}
}
console.error(
`Tauri cannot be used in Storybook. The operation requested was "${operation}". You can add mock responses to "nym_wallet/.storybook/mocks/tauri.js" if you need. The default response is "void".`,
);
return new Promise((resolve, reject) => {
reject(new Error(`Tauri operation ${operation} not available in storybook.`));
});
},
};
@@ -1,10 +0,0 @@
/**
* This is a mock for Tauri's API package (@tauri-apps/api/window), to prevent stories from being excluded, because they either use
* or import dependencies that use Tauri.
*/
module.exports = {
appWindow: {
maximize: () => undefined,
}
}
-55
View File
@@ -1,55 +0,0 @@
import { NymWalletThemeWithMode } from '../src/theme/NymWalletTheme';
import { Box } from '@mui/material';
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};
const withThemeProvider = (Story, context) => (
<div style={{ display: 'grid', height: '100%', gridTemplateColumns: '50% 50%' }}>
<div>
<NymWalletThemeWithMode mode="light">
<Box
p={4}
sx={{
display: 'grid',
gridTemplateRows: '80vh 2rem',
background: (theme) => theme.palette.background.default,
color: 'text.primary',
}}
>
<Box sx={{ overflowY: 'auto' }}>
<Story {...context} />
</Box>
<h4 style={{ textAlign: 'center' }}>Light mode</h4>
</Box>
</NymWalletThemeWithMode>
</div>
<div>
<NymWalletThemeWithMode mode="dark">
<Box
p={4}
sx={{
display: 'grid',
gridTemplateRows: '80vh 2rem',
background: (theme) => theme.palette.background.default,
color: 'text.primary',
}}
>
<Box sx={{ overflowY: 'auto' }}>
<Story {...context} />
</Box>
<h4 style={{ textAlign: 'center' }}>Dark mode</h4>
</Box>
</NymWalletThemeWithMode>
</div>
</div>
);
export const decorators = [withThemeProvider];
-21
View File
@@ -1,21 +0,0 @@
import { Theme } from '@mui/material/styles';
export const backDropStyles = (theme: Theme) => {
const { mode } = theme.palette;
return {
style: {
left: mode === 'light' ? '0' : '50%',
width: '50%',
},
};
};
export const modalStyles = (theme: Theme) => {
const { mode } = theme.palette;
return { left: mode === 'light' ? '25%' : '75%' };
};
export const dialogStyles = (theme: Theme) => {
const { mode } = theme.palette;
return { left: mode === 'light' ? '-50%' : '50%' };
};
+344 -338
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -12,8 +12,8 @@ The Nym desktop wallet enables you to use the Nym network and take advantage of
## Installation prerequisites - Linux / Mac
- `Yarn`
- `NodeJS >= v16.8.0`
- `Rust & cargo >= v1.56`
- `NodeJS >= v22.13.0`
- `Rust & cargo >= v1.85`
## Installation prerequisites - Windows
@@ -66,6 +66,17 @@ yarn build
```
The output will compile different types of binaries dependent on your hardware / OS system. Once the binaries are built, they can be located as follows:
## Linux AppImage notes
The wallet AppImage now ships with a Wayland-focused launch hook for modern Linux desktops. On Wayland sessions it:
- prefers the system `libwayland-client.so` when one is available
- defaults `GDK_BACKEND=wayland`
- defaults `GDK_SCALE=1`
- defaults `GDK_DPI_SCALE=0.8`
If you need to override this behavior for troubleshooting, set your own environment variables before launching the AppImage.
## Admin mode
The admin screens can be shown by setting the environment variable `ADMIN_ADDRESS`. You'll need to know the admin account address for the network you are using.
View File
+20
View File
@@ -0,0 +1,20 @@
/** @type {import('jest').Config} */
module.exports = {
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: {
esModuleInterop: true,
module: 'commonjs',
},
},
],
},
moduleNameMapper: {
'^src/(.*)$': '<rootDir>/src/$1',
},
};
+25 -28
View File
@@ -10,13 +10,11 @@
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"prebuild": "yarn --cwd .. build",
"prestorybook": "yarn --cwd .. build",
"prewebpack:dev": "yarn --cwd .. build",
"storybook": "start-storybook -p 6006",
"storybook:build": "build-storybook",
"tauri:build": "yarn tauri build",
"tauri:dev": "yarn tauri dev",
"tauri:buildx86": "yarn tauri build --target x86_64-apple-darwin",
"test": "jest --config jest.config.cjs",
"tsc": "tsc --noEmit true",
"tsc:watch": "tsc --noEmit true --watch",
"webpack:dev": "yarn webpack serve --config webpack.dev.js",
@@ -26,21 +24,21 @@
"@babel/helper-simple-access": "^7.25.9",
"@emotion/react": "^11.7.0",
"@emotion/styled": "^11.6.0",
"@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
"@hookform/resolvers": "^2.8.0",
"@mui/icons-material": "^5.2.0",
"@mui/material": "^5.2.2",
"@mui/styles": "^5.2.2",
"@mui/styles": "^5.18.0",
"@mui/utils": "^5.7.0",
"@nymproject/mui-theme": "^1.0.0",
"@nymproject/node-tester": "^1.3.1",
"@nymproject/react": "^1.0.0",
"@nymproject/types": "^1.0.0",
"@storybook/react": "^6.5.15",
"@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/api": "^2.10.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/tauri-forage": "^1.0.0-beta.2",
"big.js": "^6.2.1",
"bs58": "^4.0.1",
@@ -51,8 +49,8 @@
"notistack": "^2.0.3",
"npm-run-all": "^4.1.5",
"qrcode.react": "^1.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-error-boundary": "^3.1.3",
"react-hook-form": "^7.14.2",
"react-router-dom": "6",
@@ -72,31 +70,31 @@
"@babel/preset-typescript": "^7.15.0",
"@nymproject/eslint-config-react-typescript": "^1.0.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.4",
"@storybook/react": "^6.5.15",
"@svgr/webpack": "^6.1.1",
"@tauri-apps/cli": "^2.4.0",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.0.0",
"@tauri-apps/cli": "^2.10.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/big.js": "^6.1.6",
"@types/bs58": "^4.0.1",
"@types/jest": "^27.0.1",
"@types/jest": "^29.5.14",
"@types/minimatch": "5.1.2",
"@types/node": "^16.7.13",
"@types/node": "^22.15.29",
"@types/qrcode.react": "^1.0.2",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.3.8",
"@types/uuid": "^8.3.4",
"@types/zxcvbn": "^4.4.1",
"@typescript-eslint/eslint-plugin": "^5.13.0",
"@typescript-eslint/parser": "^5.13.0",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"babel-loader": "^8.3.0",
"babel-plugin-root-import": "^6.6.0",
"clean-webpack-plugin": "^4.0.0",
"css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^3.0.2",
"dotenv-webpack": "^7.0.3",
"eslint": "^9.26.0",
"eslint": "^8.57.1",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^16.1.0",
"eslint-config-prettier": "^8.5.0",
@@ -107,13 +105,12 @@
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.29.2",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-storybook": "^0.5.12",
"favicons": "^7.0.2",
"favicons-webpack-plugin": "^5.0.2",
"file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin": "^7.2.1",
"html-webpack-plugin": "^5.3.2",
"jest": "^27.1.0",
"jest": "^30.3.0",
"mini-css-extract-plugin": "^2.2.2",
"npm-run-all": "^4.1.5",
"prettier": "^2.8.7",
@@ -121,10 +118,10 @@
"react-refresh-typescript": "^2.0.2",
"style-loader": "^3.3.1",
"thread-loader": "^3.0.4",
"ts-jest": "^27.0.5",
"ts-jest": "^29.4.9",
"ts-loader": "^9.4.2",
"tsconfig-paths-webpack-plugin": "^3.5.2",
"typescript": "^4.6.2",
"typescript": "^5.9.3",
"url-loader": "^4.1.1",
"webpack": "^5.75.0",
"webpack-cli": "^4.8.0",
+19
View File
@@ -4,6 +4,25 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nym Wallet</title>
<style>
/* Match dark theme `background.default` (#242B2D) before React/MUI CssBaseline runs. */
html {
color-scheme: dark;
}
html,
body {
height: 100%;
margin: 0;
overflow: hidden;
background-color: #242b2d;
}
#root {
height: 100%;
overflow-x: hidden;
overflow-y: auto;
background-color: #242b2d;
}
</style>
</head>
<body>
<div id="root"></div>
+10
View File
@@ -4,6 +4,16 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nym Wallet Logs</title>
<style>
html {
color-scheme: dark;
}
html,
body {
margin: 0;
background-color: #242b2d;
}
</style>
</head>
<body>
<div id="root-log"></div>
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Regenerate macOS/Windows icon bundles from the 1024x1024 master in src-tauri/icons/.
# Master file: app-icon-source.png (padded per Apple-style safe zone). Edit that asset, then run:
# ./scripts/regenerate-tauri-icons.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$ROOT/src-tauri/icons/app-icon-source.png"
yarn --cwd "$ROOT" tauri icon "$SRC" -o "$ROOT/src-tauri/icons"
rm -rf "$ROOT/src-tauri/icons/android" "$ROOT/src-tauri/icons/ios"
rm -f "$ROOT/src-tauri/icons"/Square*.png "$ROOT/src-tauri/icons/StoreLogo.png"
python3 - <<PY
from PIL import Image
from pathlib import Path
icons = Path("$ROOT/src-tauri/icons")
src = Image.open(icons / "app-icon-source.png").convert("RGBA")
src.resize((128, 128), Image.Resampling.LANCZOS).save(icons / "tray_icon.png")
PY
-2
View File
@@ -1,2 +0,0 @@
[capabilities]
shell = { open = true }
+7 -8
View File
@@ -13,15 +13,15 @@ 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 = "2.1.1", features = [] }
tauri-build = { version = "2.5.6", 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"
tauri-plugin-updater = "2.10.1"
tauri-plugin-clipboard-manager = "2.3.2"
tauri-plugin-shell = "2.3.5"
tauri-plugin-process = "2.3.1"
tauri-plugin-opener = "2.5.3"
bip39 = { version = "2.0.0", features = ["zeroize", "rand"] }
cfg-if = "1.0.0"
colored = "2.0"
@@ -33,7 +33,6 @@ 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"] }
@@ -41,7 +40,7 @@ serde_json = "1.0"
serde_repr = "0.1"
strum = { version = "0.27.2", features = ["derive"] }
tap = "1"
tauri = { version = "2", features = [] }
tauri = { version = "2.10.3", features = [] }
#tendermint-rpc = "0.23.0"
time = { version = "0.3.30", features = ["local-offset"] }
thiserror = "1.0"
@@ -33,6 +33,7 @@
"updater:allow-download-and-install",
"updater:allow-install",
"core:event:allow-listen",
"shell:allow-open"
"shell:allow-open",
"process:default"
]
}
File diff suppressed because one or more lines are too long
@@ -1 +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","shell:allow-open"],"platforms":["linux","macOS","windows"]}}
{"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","shell:allow-open","process:default"],"platforms":["linux","macOS","windows"]}}
@@ -37,7 +37,7 @@
],
"definitions": {
"Capability": {
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
"type": "object",
"required": [
"identifier",
@@ -49,7 +49,7 @@
"type": "string"
},
"description": {
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.",
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.",
"default": "",
"type": "string"
},
@@ -639,10 +639,10 @@
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
},
{
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`",
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`",
"type": "string",
"const": "core:app:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`"
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`"
},
{
"description": "Enables the app_hide command without any pre-configured scope.",
@@ -656,6 +656,12 @@
"const": "core:app:allow-app-show",
"markdownDescription": "Enables the app_show command without any pre-configured scope."
},
{
"description": "Enables the bundle_type command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-bundle-type",
"markdownDescription": "Enables the bundle_type command without any pre-configured scope."
},
{
"description": "Enables the default_window_icon command without any pre-configured scope.",
"type": "string",
@@ -680,18 +686,36 @@
"const": "core:app:allow-name",
"markdownDescription": "Enables the name command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_data_store command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-remove-data-store",
"markdownDescription": "Enables the remove_data_store command without any pre-configured scope."
},
{
"description": "Enables the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-remove-listener",
"markdownDescription": "Enables the remove_listener command without any pre-configured scope."
},
{
"description": "Enables the set_app_theme command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-set-app-theme",
"markdownDescription": "Enables the set_app_theme command without any pre-configured scope."
},
{
"description": "Enables the set_dock_visibility command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-set-dock-visibility",
"markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope."
},
{
"description": "Enables the tauri_version command without any pre-configured scope.",
"type": "string",
@@ -716,6 +740,12 @@
"const": "core:app:deny-app-show",
"markdownDescription": "Denies the app_show command without any pre-configured scope."
},
{
"description": "Denies the bundle_type command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-bundle-type",
"markdownDescription": "Denies the bundle_type command without any pre-configured scope."
},
{
"description": "Denies the default_window_icon command without any pre-configured scope.",
"type": "string",
@@ -740,18 +770,36 @@
"const": "core:app:deny-name",
"markdownDescription": "Denies the name command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_data_store command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-remove-data-store",
"markdownDescription": "Denies the remove_data_store command without any pre-configured scope."
},
{
"description": "Denies the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-remove-listener",
"markdownDescription": "Denies the remove_listener command without any pre-configured scope."
},
{
"description": "Denies the set_app_theme command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-set-app-theme",
"markdownDescription": "Denies the set_app_theme command without any pre-configured scope."
},
{
"description": "Denies the set_dock_visibility command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-set-dock-visibility",
"markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope."
},
{
"description": "Denies the tauri_version command without any pre-configured scope.",
"type": "string",
@@ -1460,6 +1508,12 @@
"const": "core:webview:allow-reparent",
"markdownDescription": "Enables the reparent command without any pre-configured scope."
},
{
"description": "Enables the set_webview_auto_resize command without any pre-configured scope.",
"type": "string",
"const": "core:webview:allow-set-webview-auto-resize",
"markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope."
},
{
"description": "Enables the set_webview_background_color command without any pre-configured scope.",
"type": "string",
@@ -1562,6 +1616,12 @@
"const": "core:webview:deny-reparent",
"markdownDescription": "Denies the reparent command without any pre-configured scope."
},
{
"description": "Denies the set_webview_auto_resize command without any pre-configured scope.",
"type": "string",
"const": "core:webview:deny-set-webview-auto-resize",
"markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope."
},
{
"description": "Denies the set_webview_background_color command without any pre-configured scope.",
"type": "string",
@@ -1910,6 +1970,12 @@
"const": "core:window:allow-set-focus",
"markdownDescription": "Enables the set_focus command without any pre-configured scope."
},
{
"description": "Enables the set_focusable command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-set-focusable",
"markdownDescription": "Enables the set_focusable command without any pre-configured scope."
},
{
"description": "Enables the set_fullscreen command without any pre-configured scope.",
"type": "string",
@@ -1982,6 +2048,12 @@
"const": "core:window:allow-set-shadow",
"markdownDescription": "Enables the set_shadow command without any pre-configured scope."
},
{
"description": "Enables the set_simple_fullscreen command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-set-simple-fullscreen",
"markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope."
},
{
"description": "Enables the set_size command without any pre-configured scope.",
"type": "string",
@@ -2354,6 +2426,12 @@
"const": "core:window:deny-set-focus",
"markdownDescription": "Denies the set_focus command without any pre-configured scope."
},
{
"description": "Denies the set_focusable command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-set-focusable",
"markdownDescription": "Denies the set_focusable command without any pre-configured scope."
},
{
"description": "Denies the set_fullscreen command without any pre-configured scope.",
"type": "string",
@@ -2426,6 +2504,12 @@
"const": "core:window:deny-set-shadow",
"markdownDescription": "Denies the set_shadow command without any pre-configured scope."
},
{
"description": "Denies the set_simple_fullscreen command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-set-simple-fullscreen",
"markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope."
},
{
"description": "Denies the set_size command without any pre-configured scope.",
"type": "string",
@@ -37,7 +37,7 @@
],
"definitions": {
"Capability": {
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
"type": "object",
"required": [
"identifier",
@@ -49,7 +49,7 @@
"type": "string"
},
"description": {
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.",
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.",
"default": "",
"type": "string"
},
@@ -639,10 +639,10 @@
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
},
{
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`",
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`",
"type": "string",
"const": "core:app:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`"
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`"
},
{
"description": "Enables the app_hide command without any pre-configured scope.",
@@ -656,6 +656,12 @@
"const": "core:app:allow-app-show",
"markdownDescription": "Enables the app_show command without any pre-configured scope."
},
{
"description": "Enables the bundle_type command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-bundle-type",
"markdownDescription": "Enables the bundle_type command without any pre-configured scope."
},
{
"description": "Enables the default_window_icon command without any pre-configured scope.",
"type": "string",
@@ -680,18 +686,36 @@
"const": "core:app:allow-name",
"markdownDescription": "Enables the name command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_data_store command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-remove-data-store",
"markdownDescription": "Enables the remove_data_store command without any pre-configured scope."
},
{
"description": "Enables the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-remove-listener",
"markdownDescription": "Enables the remove_listener command without any pre-configured scope."
},
{
"description": "Enables the set_app_theme command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-set-app-theme",
"markdownDescription": "Enables the set_app_theme command without any pre-configured scope."
},
{
"description": "Enables the set_dock_visibility command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-set-dock-visibility",
"markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope."
},
{
"description": "Enables the tauri_version command without any pre-configured scope.",
"type": "string",
@@ -716,6 +740,12 @@
"const": "core:app:deny-app-show",
"markdownDescription": "Denies the app_show command without any pre-configured scope."
},
{
"description": "Denies the bundle_type command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-bundle-type",
"markdownDescription": "Denies the bundle_type command without any pre-configured scope."
},
{
"description": "Denies the default_window_icon command without any pre-configured scope.",
"type": "string",
@@ -740,18 +770,36 @@
"const": "core:app:deny-name",
"markdownDescription": "Denies the name command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_data_store command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-remove-data-store",
"markdownDescription": "Denies the remove_data_store command without any pre-configured scope."
},
{
"description": "Denies the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-remove-listener",
"markdownDescription": "Denies the remove_listener command without any pre-configured scope."
},
{
"description": "Denies the set_app_theme command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-set-app-theme",
"markdownDescription": "Denies the set_app_theme command without any pre-configured scope."
},
{
"description": "Denies the set_dock_visibility command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-set-dock-visibility",
"markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope."
},
{
"description": "Denies the tauri_version command without any pre-configured scope.",
"type": "string",
@@ -1460,6 +1508,12 @@
"const": "core:webview:allow-reparent",
"markdownDescription": "Enables the reparent command without any pre-configured scope."
},
{
"description": "Enables the set_webview_auto_resize command without any pre-configured scope.",
"type": "string",
"const": "core:webview:allow-set-webview-auto-resize",
"markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope."
},
{
"description": "Enables the set_webview_background_color command without any pre-configured scope.",
"type": "string",
@@ -1562,6 +1616,12 @@
"const": "core:webview:deny-reparent",
"markdownDescription": "Denies the reparent command without any pre-configured scope."
},
{
"description": "Denies the set_webview_auto_resize command without any pre-configured scope.",
"type": "string",
"const": "core:webview:deny-set-webview-auto-resize",
"markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope."
},
{
"description": "Denies the set_webview_background_color command without any pre-configured scope.",
"type": "string",
@@ -1910,6 +1970,12 @@
"const": "core:window:allow-set-focus",
"markdownDescription": "Enables the set_focus command without any pre-configured scope."
},
{
"description": "Enables the set_focusable command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-set-focusable",
"markdownDescription": "Enables the set_focusable command without any pre-configured scope."
},
{
"description": "Enables the set_fullscreen command without any pre-configured scope.",
"type": "string",
@@ -1982,6 +2048,12 @@
"const": "core:window:allow-set-shadow",
"markdownDescription": "Enables the set_shadow command without any pre-configured scope."
},
{
"description": "Enables the set_simple_fullscreen command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-set-simple-fullscreen",
"markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope."
},
{
"description": "Enables the set_size command without any pre-configured scope.",
"type": "string",
@@ -2354,6 +2426,12 @@
"const": "core:window:deny-set-focus",
"markdownDescription": "Denies the set_focus command without any pre-configured scope."
},
{
"description": "Denies the set_focusable command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-set-focusable",
"markdownDescription": "Denies the set_focusable command without any pre-configured scope."
},
{
"description": "Denies the set_fullscreen command without any pre-configured scope.",
"type": "string",
@@ -2426,6 +2504,12 @@
"const": "core:window:deny-set-shadow",
"markdownDescription": "Denies the set_shadow command without any pre-configured scope."
},
{
"description": "Denies the set_simple_fullscreen command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-set-simple-fullscreen",
"markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope."
},
{
"description": "Denies the set_size command without any pre-configured scope.",
"type": "string",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1008 B

After

Width:  |  Height:  |  Size: 994 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
if [ -z "${WAYLAND_DISPLAY:-}" ]; then
return 0 2>/dev/null || exit 0
fi
if [ -z "${LD_PRELOAD:-}" ]; then
for lib_path in \
/usr/lib/libwayland-client.so \
/usr/lib64/libwayland-client.so \
/usr/lib/x86_64-linux-gnu/libwayland-client.so
do
if [ -f "$lib_path" ]; then
export LD_PRELOAD="$lib_path"
break
fi
done
fi
export GDK_BACKEND="${GDK_BACKEND:-wayland}"
export GDK_SCALE="${GDK_SCALE:-1}"
export GDK_DPI_SCALE="${GDK_DPI_SCALE:-0.8}"
+48 -1
View File
@@ -14,7 +14,6 @@ use thiserror::Error;
pub enum BackendError {
#[error(transparent)]
TypesError {
#[from]
source: TypesError,
},
#[error(transparent)]
@@ -115,6 +114,8 @@ pub enum BackendError {
WalletUnexpectedMnemonicAccount,
#[error("Failed to derive address from mnemonic")]
FailedToDeriveAddress,
#[error("Built-in HD derivation path constant failed to parse (internal error)")]
InvalidInternalDerivationPath,
#[error(transparent)]
ValueParseError(#[from] ParseIntError),
#[error("The provided coin has an unknown denomination - {0}")]
@@ -156,6 +157,10 @@ pub enum BackendError {
#[error("there aren't any vesting delegations to migrate")]
NoVestingDelegations,
/// Vesting contract [`nym_vesting_contract_common::VestingContractError::NoAccountForAddress`].
#[error("Vesting contract has no account for this address")]
VestingContractAccountNotFound,
#[error("this command has been temporarily disabled")]
Disabled,
//
@@ -172,8 +177,50 @@ impl Serialize for BackendError {
}
}
/// Cosmwasm returns vesting [`nym_vesting_contract_common::VestingContractError::NoAccountForAddress`]
/// as `VESTING (...): Account does not exist - ...` in the ABCI log.
fn nyxd_error_is_vesting_contract_no_account(err: &NyxdError) -> bool {
fn text_matches_strict(text: &str) -> bool {
text.contains("VESTING") && text.contains("Account does not exist")
}
// Prefer strict match; fall back for ABCI text that omits the `VESTING` prefix. Exclude Nyxd
// `NonExistentAccountError` (`... does not exist on the chain`).
fn abci_query_vesting_no_account(text: &str) -> bool {
text_matches_strict(text)
|| (text.contains("Account does not exist")
&& !text.contains("does not exist on the chain"))
}
match err {
NyxdError::AbciError {
log,
pretty_log,
..
} => {
pretty_log
.as_ref()
.is_some_and(|s| abci_query_vesting_no_account(s))
|| abci_query_vesting_no_account(log)
}
_ => text_matches_strict(&err.to_string()),
}
}
impl From<TypesError> for BackendError {
fn from(e: TypesError) -> Self {
if let TypesError::NyxdError { ref source } = e {
if nyxd_error_is_vesting_contract_no_account(source) {
return Self::VestingContractAccountNotFound;
}
}
Self::TypesError { source: e }
}
}
impl From<NyxdError> for BackendError {
fn from(source: NyxdError) -> Self {
if nyxd_error_is_vesting_contract_no_account(&source) {
return Self::VestingContractAccountNotFound;
}
match source {
NyxdError::AbciError {
code: _,
+6 -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::Emitter;
use tauri::{Emitter, Manager};
use time::{format_description, OffsetDateTime};
fn formatted_time() -> String {
@@ -24,6 +24,7 @@ fn formatted_time() -> String {
}
pub fn setup_logging(app_handle: tauri::AppHandle) -> Result<(), log::SetLoggerError> {
let log_window_app = app_handle.clone();
let colors = ColoredLevelConfig::new()
.trace(Color::Magenta)
.debug(Color::Blue)
@@ -61,7 +62,10 @@ pub fn setup_logging(app_handle: tauri::AppHandle) -> Result<(), log::SetLoggerE
message: record.args().to_string(),
level: record.level().into(),
};
app_handle.emit("log://log", msg).unwrap();
// Tauri 2: target the log webview explicitly so the dedicated window receives events.
if let Some(log_win) = log_window_app.get_webview_window("log") {
let _ = log_win.emit("log://log", msg);
}
}));
base_config
+24 -20
View File
@@ -4,9 +4,9 @@
)]
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_process::init as init_process;
use tauri_plugin_shell::init as init_shell;
use tauri_plugin_updater::Builder as UpdaterBuilder;
@@ -23,6 +23,7 @@ use crate::state::WalletState;
mod config;
mod error;
mod log;
mod webview_theme;
mod menu;
mod network_config;
mod operations;
@@ -34,11 +35,13 @@ mod wallet_storage;
#[allow(clippy::too_many_lines)]
fn main() {
dotenvy::dotenv().ok();
configure_linux_wayland_defaults();
let context = tauri::generate_context!();
tauri::Builder::default()
.plugin(init_shell())
.plugin(init_opener())
.plugin(init_process())
.plugin(UpdaterBuilder::new().build())
.plugin(tauri_plugin_clipboard_manager::init())
.manage(WalletState::default())
@@ -217,25 +220,7 @@ fn main() {
app::react::set_react_state,
app::react::get_react_state,
])
.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()
}
})
.menu(|app| menu::build_app_menu(app))
.on_menu_event(|app, event| {
if event.id() == SHOW_LOG_WINDOW {
let _r = help::log::help_log_toggle_window(app.app_handle().clone());
@@ -245,3 +230,22 @@ fn main() {
.run(context)
.expect("error while running tauri application");
}
fn configure_linux_wayland_defaults() {
#[cfg(target_os = "linux")]
{
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
if std::env::var_os("GDK_BACKEND").is_none() {
unsafe { std::env::set_var("GDK_BACKEND", "wayland") };
}
if std::env::var_os("GDK_SCALE").is_none() {
unsafe { std::env::set_var("GDK_SCALE", "1") };
}
if std::env::var_os("GDK_DPI_SCALE").is_none() {
unsafe { std::env::set_var("GDK_DPI_SCALE", "0.8") };
}
}
}
}
+16 -27
View File
@@ -1,36 +1,25 @@
use tauri::menu::Menu;
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
use tauri::menu::{Menu, MenuBuilder, MenuItemBuilder, SubmenuBuilder};
use tauri::{AppHandle, Runtime};
pub const SHOW_LOG_WINDOW: &str = "show_log_window";
pub trait AddDefaultSubmenus {
#[allow(dead_code)]
fn add_default_app_submenus(self) -> Self;
}
pub fn build_app_menu<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
let edit_submenu = SubmenuBuilder::new(app, "Edit")
.cut()
.copy()
.paste()
.select_all()
.build()?;
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 app_handle = self.app_handle();
let mut menu_builder = MenuBuilder::new(app).item(&edit_submenu);
let help_text = MenuItemBuilder::with_id(SHOW_LOG_WINDOW, "Show logs")
.build(app_handle)
.expect("Failed to create menu item");
if std::env::var("NYM_WALLET_ENABLE_LOG").is_ok() {
let help_text = MenuItemBuilder::with_id(SHOW_LOG_WINDOW, "Show logs").build(app)?;
let submenu = SubmenuBuilder::new(app_handle, "Help")
.items(&[&help_text])
.build()
.expect("Failed to create help submenu");
let help_submenu = SubmenuBuilder::new(app, "Help").items(&[&help_text]).build()?;
let menu_builder = MenuBuilder::new(app_handle);
match menu_builder.item(&submenu).build() {
Ok(new_menu) => new_menu,
Err(_) => self,
}
} else {
self
}
menu_builder = menu_builder.item(&help_submenu);
}
menu_builder.build()
}
@@ -1,8 +1,15 @@
use tauri_plugin_opener::OpenerExt;
use url::Url;
#[tauri::command]
pub async fn open_url(url: String, app_handle: tauri::AppHandle) -> Result<(), String> {
println!("Opening URL: {url}");
let parsed = Url::parse(&url).map_err(|e| format!("Invalid URL: {e}"))?;
match parsed.scheme() {
"https" | "http" => {}
other => {
return Err(format!("URL scheme not allowed: {other}"));
}
}
match app_handle.opener().open_url(&url, None::<&str>) {
Ok(_) => Ok(()),
@@ -15,10 +15,29 @@ pub async fn check_version(handle: tauri::AppHandle) -> Result<AppVersion, Backe
})?;
// 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
})?;
let update_info = match updater.check().await {
Ok(info) => info,
Err(e) => {
let msg = e.to_string();
// Hosted static JSON must include per-platform `signature` (base64) alongside `url`.
// Legacy manifests with only `url` fail serde in tauri-plugin-updater 2.x.
if msg.contains("missing field") && msg.contains("signature") {
let current_version = handle.package_info().version.to_string();
log::warn!(
"Updater check skipped: manifest at configured endpoint is not Tauri 2-compatible \
(missing or invalid `signature` field). Users will not be notified of updates \
until the hosted updater.json is republished. Error: {msg}"
);
return Ok(AppVersion {
current_version: current_version.clone(),
latest_version: current_version,
is_update_available: false,
});
}
log::error!("An error occurred while checking for app update {e}");
return Err(BackendError::CheckAppVersionError);
}
};
// Process the result
if let Some(update) = update_info {
@@ -1,6 +1,7 @@
use tauri::Manager;
use crate::error::BackendError;
use crate::webview_theme::NYM_WALLET_WEBVIEW_BG;
#[tauri::command]
pub async fn create_main_window(app_handle: tauri::AppHandle) -> Result<(), BackendError> {
@@ -32,6 +33,7 @@ async fn create_window(
tauri::WebviewUrl::App(new_window_url.into()),
)
.title("Nym Wallet")
.background_color(NYM_WALLET_WEBVIEW_BG)
.build()
{
Ok(window) => {
@@ -1,4 +1,5 @@
use crate::error::BackendError;
use crate::webview_theme::NYM_WALLET_WEBVIEW_BG;
use tauri::Manager;
#[tauri::command]
@@ -18,6 +19,7 @@ pub fn help_log_toggle_window(app_handle: tauri::AppHandle) -> Result<(), Backen
tauri::WebviewUrl::App("log.html".into()),
)
.title("Nym Wallet Logs")
.background_color(NYM_WALLET_WEBVIEW_BG)
.build()
{
Ok(window) => {
@@ -18,6 +18,12 @@ use std::collections::HashMap;
use strum::IntoEnumIterator;
use url::Url;
fn cosmos_derivation_path() -> Result<DerivationPath, BackendError> {
COSMOS_DERIVATION_PATH
.parse()
.map_err(|_| BackendError::InvalidInternalDerivationPath)
}
#[tauri::command]
pub async fn connect_with_mnemonic(
mnemonic: Mnemonic,
@@ -48,8 +54,9 @@ pub async fn get_balance(state: tauri::State<'_, WalletState>) -> Result<Balance
}
#[tauri::command]
pub fn create_new_mnemonic() -> Mnemonic {
random_mnemonic()
pub fn create_new_mnemonic() -> Result<Mnemonic, BackendError> {
let mut rng = rand::thread_rng();
Mnemonic::generate_in_with(&mut rng, Language::English, 24).map_err(Into::into)
}
#[tauri::command]
@@ -82,11 +89,6 @@ pub async fn logout(state: tauri::State<'_, WalletState>) -> Result<(), BackendE
Ok(())
}
fn random_mnemonic() -> Mnemonic {
let mut rng = rand::thread_rng();
Mnemonic::generate_in_with(&mut rng, Language::English, 24).unwrap()
}
async fn _connect_with_mnemonic(
mnemonic: Mnemonic,
state: tauri::State<'_, WalletState>,
@@ -129,12 +131,26 @@ async fn _connect_with_mnemonic(
let default_nyxd_urls: HashMap<WalletNetwork, Url> = untested_nyxd_urls
.iter()
.map(|(network, urls)| (*network, urls.iter().next().unwrap().clone()))
.collect();
.map(|(network, urls)| {
let url = urls
.iter()
.next()
.cloned()
.ok_or(BackendError::WalletNoDefaultValidator)?;
Ok((*network, url))
})
.collect::<Result<HashMap<_, _>, BackendError>>()?;
let default_api_urls: HashMap<WalletNetwork, Url> = untested_api_urls
.iter()
.map(|(network, urls)| (*network, urls.iter().next().unwrap().clone()))
.collect();
.map(|(network, urls)| {
let url = urls
.iter()
.next()
.cloned()
.ok_or(BackendError::WalletNoDefaultValidator)?;
Ok((*network, url))
})
.collect::<Result<HashMap<_, _>, BackendError>>()?;
let nyxd_urls = pick_good_nyxd_urls(&default_nyxd_urls, &nyxd_urls).await?;
let api_urls = pick_good_api_urls(&default_api_urls, &api_urls).await?;
@@ -339,7 +355,7 @@ pub fn create_password(mnemonic: Mnemonic, password: UserPassword) -> Result<(),
}
log::info!("Creating password");
let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap();
let hd_path: DerivationPath = cosmos_derivation_path()?;
// Currently we only support a single, default, login id in the wallet
let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string());
wallet_storage::store_login_with_multiple_accounts(mnemonic, hd_path, login_id, &password)
@@ -451,7 +467,7 @@ pub async fn add_account_for_password(
state: tauri::State<'_, WalletState>,
) -> Result<AccountEntry, BackendError> {
log::info!("Adding account for the current password: {account_id}");
let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap();
let hd_path: DerivationPath = cosmos_derivation_path()?;
// Currently we only support a single, default, login id in the wallet
let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string());
let account_id = wallet_storage::AccountId::new(account_id.to_string());
@@ -501,27 +517,24 @@ async fn set_state_with_all_accounts(
let all_account_ids: Vec<WalletAccountIds> = all_accounts
.iter()
.map(|account| {
.map(|account| -> Result<WalletAccountIds, BackendError> {
let mnemonic = account.mnemonic();
let addresses: HashMap<WalletNetwork, cosmrs::AccountId> = WalletNetwork::iter()
.map(|network| {
let config_network: NymNetworkDetails = network.into();
(
network,
derive_address(
mnemonic.clone(),
&config_network.chain_details.bech32_account_prefix,
)
.unwrap(),
)
let addr = derive_address(
mnemonic.clone(),
&config_network.chain_details.bech32_account_prefix,
)?;
Ok((network, addr))
})
.collect();
WalletAccountIds {
.collect::<Result<HashMap<_, _>, BackendError>>()?;
Ok(WalletAccountIds {
id: account.id().clone(),
addresses,
}
})
})
.collect();
.collect::<Result<Vec<_>, BackendError>>()?;
let mut w_state = state.write().await;
w_state.set_all_accounts(all_account_ids);
@@ -16,7 +16,7 @@ pub(crate) async fn locked_coins(
block_time: Option<u64>,
state: tauri::State<'_, WalletState>,
) -> Result<DecCoin, BackendError> {
log::info!(">>> Query locked coins");
log::debug!(">>> Query locked coins");
let guard = state.read().await;
let client = guard.current_client()?;
@@ -28,7 +28,7 @@ pub(crate) async fn locked_coins(
)
.await?;
let display = guard.attempt_convert_to_display_dec_coin(res)?;
log::info!("<<< locked coins = {display}");
log::debug!("<<< locked coins = {display}");
Ok(display)
}
@@ -37,7 +37,7 @@ pub(crate) async fn spendable_coins(
block_time: Option<u64>,
state: tauri::State<'_, WalletState>,
) -> Result<DecCoin, BackendError> {
log::info!(">>> Query spendable coins");
log::debug!(">>> Query spendable coins");
let guard = state.read().await;
let client = guard.current_client()?;
@@ -50,7 +50,7 @@ pub(crate) async fn spendable_coins(
.await?;
let display = guard.attempt_convert_to_display_dec_coin(res)?;
log::info!("<<< spendable coins = {display}");
log::debug!("<<< spendable coins = {display}");
Ok(display)
}
@@ -58,7 +58,7 @@ pub(crate) async fn spendable_coins(
pub(crate) async fn spendable_vested_coins(
state: tauri::State<'_, WalletState>,
) -> Result<DecCoin, BackendError> {
log::info!(">>> Query spendable vested coins");
log::debug!(">>> Query spendable vested coins");
let guard = state.read().await;
let client = guard.current_client()?;
@@ -68,7 +68,7 @@ pub(crate) async fn spendable_vested_coins(
.await?;
let display = guard.attempt_convert_to_display_dec_coin(res)?;
log::info!("<<< spendable vested coins = {display}");
log::debug!("<<< spendable vested coins = {display}");
Ok(display)
}
@@ -76,7 +76,7 @@ pub(crate) async fn spendable_vested_coins(
pub(crate) async fn spendable_reward_coins(
state: tauri::State<'_, WalletState>,
) -> Result<DecCoin, BackendError> {
log::info!(">>> Query spendable reward coins");
log::debug!(">>> Query spendable reward coins");
let guard = state.read().await;
let client = guard.current_client()?;
@@ -86,7 +86,7 @@ pub(crate) async fn spendable_reward_coins(
.await?;
let display = guard.attempt_convert_to_display_dec_coin(res)?;
log::info!("<<< spendable reward coins = {display}");
log::debug!("<<< spendable reward coins = {display}");
Ok(display)
}
@@ -96,7 +96,7 @@ pub(crate) async fn vested_coins(
block_time: Option<u64>,
state: tauri::State<'_, WalletState>,
) -> Result<DecCoin, BackendError> {
log::info!(">>> Query vested coins");
log::debug!(">>> Query vested coins");
let guard = state.read().await;
let res = guard
@@ -109,7 +109,7 @@ pub(crate) async fn vested_coins(
.await?;
let display = guard.attempt_convert_to_display_dec_coin(res)?;
log::info!("<<< vested coins = {display}");
log::debug!("<<< vested coins = {display}");
Ok(display)
}
@@ -119,7 +119,7 @@ pub(crate) async fn vesting_coins(
block_time: Option<u64>,
state: tauri::State<'_, WalletState>,
) -> Result<DecCoin, BackendError> {
log::info!(">>> Query vesting coins");
log::debug!(">>> Query vesting coins");
let guard = state.read().await;
let res = guard
@@ -132,7 +132,7 @@ pub(crate) async fn vesting_coins(
.await?;
let display = guard.attempt_convert_to_display_dec_coin(res)?;
log::info!("<<< vesting coins = {display}");
log::debug!("<<< vesting coins = {display}");
Ok(display)
}
@@ -141,12 +141,12 @@ pub(crate) async fn vesting_start_time(
vesting_account_address: &str,
state: tauri::State<'_, WalletState>,
) -> Result<u64, BackendError> {
log::info!(">>> Query vesting start time");
log::debug!(">>> Query vesting start time");
let res = nyxd_client!(state)
.vesting_start_time(vesting_account_address)
.await?
.seconds();
log::info!("<<< vesting start time = {res}");
log::debug!("<<< vesting start time = {res}");
Ok(res)
}
@@ -155,12 +155,12 @@ pub(crate) async fn vesting_end_time(
vesting_account_address: &str,
state: tauri::State<'_, WalletState>,
) -> Result<u64, BackendError> {
log::info!(">>> Query vesting end time");
log::debug!(">>> Query vesting end time");
let res = nyxd_client!(state)
.vesting_end_time(vesting_account_address)
.await?
.seconds();
log::info!("<<< vesting end time = {res}");
log::debug!("<<< vesting end time = {res}");
Ok(res)
}
@@ -169,7 +169,7 @@ pub(crate) async fn original_vesting(
vesting_account_address: &str,
state: tauri::State<'_, WalletState>,
) -> Result<OriginalVestingResponse, BackendError> {
log::info!(">>> Query original vesting");
log::debug!(">>> Query original vesting");
let guard = state.read().await;
let reg = guard.registered_coins()?;
@@ -180,7 +180,7 @@ pub(crate) async fn original_vesting(
.await?;
let res = OriginalVestingResponse::from_vesting_contract(res, reg)?;
log::info!("<<< {res:?}");
log::debug!("<<< {res:?}");
Ok(res)
}
@@ -188,7 +188,7 @@ pub(crate) async fn original_vesting(
pub(crate) async fn get_historical_vesting_staking_reward(
state: tauri::State<'_, WalletState>,
) -> Result<DecCoin, BackendError> {
log::info!(">>> Query historical vesting staking reward coins");
log::debug!(">>> Query historical vesting staking reward coins");
let guard = state.read().await;
let client = guard.current_client()?;
@@ -197,7 +197,7 @@ pub(crate) async fn get_historical_vesting_staking_reward(
.get_historical_vesting_staking_reward(client.nyxd.address().as_ref())
.await?;
let display = guard.attempt_convert_to_display_dec_coin(res)?;
log::info!("<<< historical vesting staking reward coins = {display}");
log::debug!("<<< historical vesting staking reward coins = {display}");
Ok(display)
}
@@ -205,7 +205,7 @@ pub(crate) async fn get_historical_vesting_staking_reward(
pub(crate) async fn get_spendable_vested_coins(
state: tauri::State<'_, WalletState>,
) -> Result<DecCoin, BackendError> {
log::info!(">>> Query spendable vested coins");
log::debug!(">>> Query spendable vested coins");
let guard = state.read().await;
let client = guard.current_client()?;
@@ -214,7 +214,7 @@ pub(crate) async fn get_spendable_vested_coins(
.get_spendable_vested_coins(client.nyxd.address().as_ref())
.await?;
let display = guard.attempt_convert_to_display_dec_coin(res)?;
log::info!("<<< spendable vested coins = {display}");
log::debug!("<<< spendable vested coins = {display}");
Ok(display)
}
@@ -222,7 +222,7 @@ pub(crate) async fn get_spendable_vested_coins(
pub(crate) async fn get_spendable_reward_coins(
state: tauri::State<'_, WalletState>,
) -> Result<DecCoin, BackendError> {
log::info!(">>> Query spendable reward coins");
log::debug!(">>> Query spendable reward coins");
let guard = state.read().await;
let client = guard.current_client()?;
@@ -231,7 +231,7 @@ pub(crate) async fn get_spendable_reward_coins(
.get_spendable_reward_coins(client.nyxd.address().as_ref())
.await?;
let display = guard.attempt_convert_to_display_dec_coin(res)?;
log::info!("<<< spendable reward coins = {display}");
log::debug!("<<< spendable reward coins = {display}");
Ok(display)
}
@@ -239,7 +239,7 @@ pub(crate) async fn get_spendable_reward_coins(
pub(crate) async fn get_delegated_coins(
state: tauri::State<'_, WalletState>,
) -> Result<DecCoin, BackendError> {
log::info!(">>> Query delegated coins");
log::debug!(">>> Query delegated coins");
let guard = state.read().await;
let client = guard.current_client()?;
@@ -248,7 +248,7 @@ pub(crate) async fn get_delegated_coins(
.get_delegated_coins(client.nyxd.address().as_ref())
.await?;
let display = guard.attempt_convert_to_display_dec_coin(res)?;
log::info!("<<< delegated coins = {display}");
log::debug!("<<< delegated coins = {display}");
Ok(display)
}
@@ -256,7 +256,7 @@ pub(crate) async fn get_delegated_coins(
pub(crate) async fn get_pledged_coins(
state: tauri::State<'_, WalletState>,
) -> Result<DecCoin, BackendError> {
log::info!(">>> Query pledged coins");
log::debug!(">>> Query pledged coins");
let guard = state.read().await;
let client = guard.current_client()?;
@@ -265,7 +265,7 @@ pub(crate) async fn get_pledged_coins(
.get_pledged_coins(client.nyxd.address().as_ref())
.await?;
let display = guard.attempt_convert_to_display_dec_coin(res)?;
log::info!("<<< pledged coins = {display}");
log::debug!("<<< pledged coins = {display}");
Ok(display)
}
@@ -273,7 +273,7 @@ pub(crate) async fn get_pledged_coins(
pub(crate) async fn get_staked_coins(
state: tauri::State<'_, WalletState>,
) -> Result<DecCoin, BackendError> {
log::info!(">>> Query staked coins");
log::debug!(">>> Query staked coins");
let guard = state.read().await;
let client = guard.current_client()?;
@@ -282,7 +282,7 @@ pub(crate) async fn get_staked_coins(
.get_staked_coins(client.nyxd.address().as_ref())
.await?;
let display = guard.attempt_convert_to_display_dec_coin(res)?;
log::info!("<<< staked coins = {display}");
log::debug!("<<< staked coins = {display}");
Ok(display)
}
@@ -290,7 +290,7 @@ pub(crate) async fn get_staked_coins(
pub(crate) async fn get_withdrawn_coins(
state: tauri::State<'_, WalletState>,
) -> Result<DecCoin, BackendError> {
log::info!(">>> Query withdrawn coins");
log::debug!(">>> Query withdrawn coins");
let guard = state.read().await;
let client = guard.current_client()?;
@@ -299,7 +299,7 @@ pub(crate) async fn get_withdrawn_coins(
.get_withdrawn_coins(client.nyxd.address().as_ref())
.await?;
let display = guard.attempt_convert_to_display_dec_coin(res)?;
log::info!("<<< pledged coins = {display}");
log::debug!("<<< withdrawn coins = {display}");
Ok(display)
}
@@ -309,7 +309,7 @@ pub(crate) async fn delegated_free(
_block_time: Option<u64>,
_state: tauri::State<'_, WalletState>,
) -> Result<DecCoin, BackendError> {
log::info!(">>> Query delegated free -> THIS QUERY HAS BEEN REMOVED FROM THE CONTRACT");
log::warn!(">>> Query delegated free -> THIS QUERY HAS BEEN REMOVED FROM THE CONTRACT");
Err(BackendError::RemovedCommand {
name: "vesting::queries::delegated_free".to_string(),
alternative: "vesting::queries::get_delegated_coins".to_string(),
@@ -323,7 +323,7 @@ pub(crate) async fn delegated_vesting(
_vesting_account_address: &str,
_state: tauri::State<'_, WalletState>,
) -> Result<DecCoin, BackendError> {
log::info!(">>> Query delegated vesting -> THIS QUERY HAS BEEN REMOVED FROM THE CONTRACT");
log::warn!(">>> Query delegated vesting -> THIS QUERY HAS BEEN REMOVED FROM THE CONTRACT");
Err(BackendError::RemovedCommand {
name: "vesting::queries::delegated_vesting".to_string(),
alternative: "vesting::queries::get_delegated_coins".to_string(),
@@ -335,7 +335,7 @@ pub(crate) async fn vesting_get_mixnode_pledge(
address: &str,
state: tauri::State<'_, WalletState>,
) -> Result<Option<PledgeData>, BackendError> {
log::info!(">>> Query vesting get mixnode pledge");
log::debug!(">>> Query vesting get mixnode pledge");
let guard = state.read().await;
let reg = guard.registered_coins()?;
@@ -347,7 +347,7 @@ pub(crate) async fn vesting_get_mixnode_pledge(
.map(|pledge| PledgeData::from_vesting_contract(pledge, reg))
.transpose()?;
log::info!("<<< {res:?}");
log::debug!("<<< {res:?}");
Ok(res)
}
@@ -356,7 +356,7 @@ pub(crate) async fn vesting_get_gateway_pledge(
address: &str,
state: tauri::State<'_, WalletState>,
) -> Result<Option<PledgeData>, BackendError> {
log::info!(">>> Query vesting get gateway pledge");
log::debug!(">>> Query vesting get gateway pledge");
let guard = state.read().await;
let reg = guard.registered_coins()?;
@@ -368,7 +368,7 @@ pub(crate) async fn vesting_get_gateway_pledge(
.map(|pledge| PledgeData::from_vesting_contract(pledge, reg))
.transpose()?;
log::info!("<<< {res:?}");
log::debug!("<<< {res:?}");
Ok(res)
}
@@ -377,11 +377,11 @@ pub(crate) async fn get_current_vesting_period(
address: &str,
state: tauri::State<'_, WalletState>,
) -> Result<Period, BackendError> {
log::info!(">>> Query current vesting period");
log::debug!(">>> Query current vesting period");
let res = nyxd_client!(state)
.get_current_vesting_period(address)
.await?;
log::info!("<<< {res:?}");
log::debug!("<<< {res:?}");
Ok(res)
}
@@ -390,13 +390,13 @@ pub(crate) async fn get_account_info(
address: &str,
state: tauri::State<'_, WalletState>,
) -> Result<VestingAccountInfo, BackendError> {
log::info!(">>> Query account info");
log::debug!(">>> Query account info");
let guard = state.read().await;
let res = guard.registered_coins()?;
let vesting_account = guard.current_client()?.nyxd.get_account(address).await?;
let res = VestingAccountInfo::from_vesting_contract(vesting_account, res)?;
log::info!("<<< {res:?}");
log::debug!("<<< {res:?}");
Ok(res)
}
@@ -0,0 +1,5 @@
//! Shared webview chrome (matches wallet dark `background.default` #242B2D).
use tauri::window::Color;
pub const NYM_WALLET_WEBVIEW_BG: Color = Color(36, 43, 45, 255);
+22 -2
View File
@@ -26,6 +26,11 @@
"entitlements": null
},
"linux": {
"appimage": {
"files": {
"/apprun-hooks/99-nym-wayland.sh": "scripts/appimage-wayland-hook.sh"
}
},
"deb": {
"depends": []
}
@@ -55,15 +60,30 @@
"capabilities": [
"main-capability"
],
"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"
"csp": {
"default-src": "'self' customprotocol: asset:",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'",
"style-src": "'unsafe-inline' 'self'",
"img-src": "'self' asset: http://asset.localhost https://asset.localhost blob: data:",
"font-src": "'self' data:",
"connect-src": "ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* wss://127.0.0.1:* wss://localhost:* http: https: ws: wss:",
"media-src": "'self' blob: data:",
"worker-src": "'self' blob:",
"frame-src": "'none'",
"object-src": "'none'",
"base-uri": "'self'"
}
},
"windows": [
{
"title": "Nym Wallet",
"width": 1268,
"height": 768,
"minWidth": 1024,
"minHeight": 640,
"resizable": true,
"useHttpsScheme": true
"useHttpsScheme": true,
"backgroundColor": "#242b2d"
}
]
}
@@ -0,0 +1,51 @@
import { fetchNymPriceDeduped } from './networkOverview';
const sampleTokenomics = {
quotes: {
USD: {
price: 0.0331,
market_cap: 26_000_000,
volume_24h: 1_200_000,
},
},
};
describe('fetchNymPriceDeduped', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('coalesces concurrent requests for the same URL', async () => {
let callCount = 0;
global.fetch = jest.fn(() => {
callCount += 1;
return Promise.resolve({
ok: true,
json: () => Promise.resolve(sampleTokenomics),
} as Response);
});
const url = 'https://api.example.test/v1/nym-price';
const p1 = fetchNymPriceDeduped(url);
const p2 = fetchNymPriceDeduped(url);
const [a, b] = await Promise.all([p1, p2]);
expect(a).toStrictEqual(sampleTokenomics);
expect(b).toStrictEqual(sampleTokenomics);
expect(callCount).toBe(1);
});
it('does not coalesce different URLs', async () => {
let callCount = 0;
global.fetch = jest.fn(() => {
callCount += 1;
return Promise.resolve({
ok: true,
json: () => Promise.resolve(sampleTokenomics),
} as Response);
});
await Promise.all([fetchNymPriceDeduped('https://a.test/p'), fetchNymPriceDeduped('https://b.test/p')]);
expect(callCount).toBe(2);
});
});
+175
View File
@@ -0,0 +1,175 @@
import { addSeconds } from 'date-fns';
import type { Network } from 'src/types';
export type NetworkOverviewEndpoints = {
mixnodeStats: string;
epochCurrent: string;
epochRewards: string;
nymPrice: string;
observatoryNodesBase: string;
};
export const getNetworkOverviewEndpoints = (network?: Network): NetworkOverviewEndpoints => {
if (network === 'SANDBOX' || network === 'QA') {
return {
mixnodeStats: 'https://sandbox-node-status-api.nymte.ch/v2/mixnodes/stats',
epochCurrent: 'https://sandbox-nym-api1.nymtech.net/api/v1/epoch/current',
epochRewards: 'https://sandbox-nym-api1.nymtech.net/api/v1/epoch/reward_params',
nymPrice: 'https://api.nym.spectredao.net/api/v1/nym-price',
observatoryNodesBase: 'https://sandbox-node-status-api.nymte.ch/explorer/v3/nym-nodes',
};
}
return {
mixnodeStats: 'https://mainnet-node-status-api.nymtech.cc/v2/mixnodes/stats',
epochCurrent: 'https://validator.nymtech.net/api/v1/epoch/current',
epochRewards: 'https://validator.nymtech.net/api/v1/epoch/reward_params',
nymPrice: 'https://api.nym.spectredao.net/api/v1/nym-price',
observatoryNodesBase: 'https://mainnet-node-status-api.nymtech.cc/explorer/v3/nym-nodes',
};
};
export interface PacketsAndStakingPoint {
date_utc: string;
total_packets_received: number;
total_packets_sent: number;
total_packets_dropped: number;
total_stake: number;
}
export interface CurrentEpochApiData {
id: number;
current_epoch_id: number;
current_epoch_start: string;
epoch_length: { secs: number; nanos: number };
epochs_in_interval: number;
total_elapsed_epochs: number;
}
export type CurrentEpochWithEnd = CurrentEpochApiData & { current_epoch_end: string };
export interface EpochRewardsData {
interval: {
reward_pool: string;
staking_supply: string;
staking_supply_scale_factor: string;
epoch_reward_budget: string;
stake_saturation_point: string;
active_set_work_factor: string;
interval_pool_emission: string;
sybil_resistance: string;
};
rewarded_set: {
entry_gateways: number;
exit_gateways: number;
mixnodes: number;
standby: number;
};
}
export interface NymTokenomics {
quotes: {
USD: {
price: number;
market_cap: number;
volume_24h: number;
};
};
}
type ObservatoryPage = {
page: number;
size: number;
total: number;
items: Array<{ rewarding_details?: { unique_delegations?: number } }>;
};
const jsonHeaders = {
Accept: 'application/json',
'Content-Type': 'application/json; charset=utf-8',
};
export async function fetchPacketsAndStaking(url: string): Promise<PacketsAndStakingPoint[]> {
const response = await fetch(url, { headers: jsonHeaders });
if (!response.ok) {
throw new Error('Failed to fetch mixnode stats');
}
const data: PacketsAndStakingPoint[] = await response.json();
return data;
}
export async function fetchCurrentEpoch(url: string): Promise<CurrentEpochWithEnd> {
const response = await fetch(url, { headers: jsonHeaders, cache: 'no-store' });
if (!response.ok) {
throw new Error('Failed to fetch current epoch');
}
const data: CurrentEpochApiData = await response.json();
const current_epoch_end = addSeconds(new Date(data.current_epoch_start), data.epoch_length.secs).toISOString();
return { ...data, current_epoch_end };
}
export async function fetchEpochRewards(url: string): Promise<EpochRewardsData> {
const response = await fetch(url, { headers: jsonHeaders, cache: 'no-store' });
if (!response.ok) {
throw new Error('Failed to fetch epoch rewards');
}
return response.json();
}
export async function fetchNymPrice(url: string): Promise<NymTokenomics> {
const response = await fetch(url, { headers: jsonHeaders });
if (!response.ok) {
throw new Error('Failed to fetch NYM price');
}
return response.json();
}
const nymPriceInflight = new Map<string, Promise<NymTokenomics>>();
/** Coalesces concurrent requests for the same price URL (e.g. Balance card + Network overview). */
export function fetchNymPriceDeduped(url: string): Promise<NymTokenomics> {
const existing = nymPriceInflight.get(url);
if (existing) {
return existing;
}
const pending = fetchNymPrice(url).finally(() => {
nymPriceInflight.delete(url);
});
nymPriceInflight.set(url, pending);
return pending;
}
/** Paginates observatory nodes and sums unique_delegations (same idea as explorer StakersNumberCard). */
export async function fetchTotalDelegationsCount(baseUrl: string): Promise<number> {
const all: ObservatoryPage['items'] = [];
let page = 0;
const PAGE_SIZE = 200;
let hasMore = true;
while (hasMore) {
/* Paginated API: each request needs the previous page index. */
// eslint-disable-next-line no-await-in-loop -- sequential pagination
const response = await fetch(`${baseUrl}?page=${page}&size=${PAGE_SIZE}`, { headers: jsonHeaders });
if (!response.ok) {
throw new Error(`Failed to fetch observatory nodes (page ${page})`);
}
// eslint-disable-next-line no-await-in-loop -- follows fetch above
const data: ObservatoryPage = await response.json();
all.push(...data.items);
const totalPages = Math.ceil(data.total / PAGE_SIZE);
if (page >= totalPages - 1 || data.items.length < PAGE_SIZE) {
hasMore = false;
} else {
page += 1;
}
}
return all.reduce((sum, node) => sum + (node.rewarding_details?.unique_delegations ?? 0), 0);
}
export function formatCompactNumber(num: number): string {
return new Intl.NumberFormat('en-US', {
maximumFractionDigits: 2,
notation: 'compact',
compactDisplay: 'short',
}).format(num);
}
+9 -1
View File
@@ -7,6 +7,12 @@ import { ErrorFallback } from './components';
import { NymWalletTheme } from './theme';
import { maximizeWindow } from './utils';
import { config } from './config';
import { useTauriTextEditingClipboard } from './hooks/useTauriTextEditingClipboard';
const ClipboardBridge: FCWithChildren = ({ children }) => {
useTauriTextEditingClipboard();
return children;
};
export const AppCommon = ({ children }: { children: React.ReactNode }) => {
useEffect(() => {
@@ -26,7 +32,9 @@ export const AppCommon = ({ children }: { children: React.ReactNode }) => {
}}
>
<AppProvider>
<NymWalletTheme>{children}</NymWalletTheme>
<NymWalletTheme>
<ClipboardBridge>{children}</ClipboardBridge>
</NymWalletTheme>
</AppProvider>
</SnackbarProvider>
</Router>
@@ -2,11 +2,12 @@ import React from 'react';
import { Button } from '@mui/material';
import { AccountEntry } from '@nymproject/types';
import { AccountAvatar } from './AccountAvatar';
import { headerControlPillSx } from '../headerControlPillSx';
export const AccountOverview = ({ account, onClick }: { account: AccountEntry; onClick: () => void }) => (
<Button
startIcon={<AccountAvatar name={account.id} small />}
sx={{ color: 'text.primary', fontSize: 14 }}
sx={headerControlPillSx}
color="inherit"
onClick={onClick}
>
@@ -1,18 +0,0 @@
import React from 'react';
import { Box } from '@mui/material';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { MockAccountsProvider } from 'src/context/mocks/accounts';
import { Accounts } from '../Accounts';
export default {
title: 'Wallet / Multi Account',
component: Accounts,
} as ComponentMeta<typeof Accounts>;
export const Default: ComponentStory<typeof Accounts> = () => (
<Box display="flex" alignContent="center">
<MockAccountsProvider>
<Accounts />
</MockAccountsProvider>
</Box>
);
+1 -1
View File
@@ -8,7 +8,7 @@ export const ActionsMenu: FCWithChildren<{
onOpen: () => void;
onClose: () => void;
}> = ({ children, open, onOpen, onClose }) => {
const anchorEl: any = useRef<HTMLElement>();
const anchorEl = useRef<HTMLButtonElement | null>(null);
return (
<>
+43 -17
View File
@@ -1,7 +1,9 @@
import React, { useContext } from 'react';
import { AppBar as MuiAppBar, Grid, IconButton, Toolbar } from '@mui/material';
import { AppBar as MuiAppBar, Box, IconButton, Stack, Toolbar } from '@mui/material';
import { alpha } from '@mui/material/styles';
import { useNavigate } from 'react-router-dom';
import { Logout, SettingsOutlined as SettingsIcon } from '@mui/icons-material';
import { CONTENT_RAIL_MAX_WIDTH_WIDE } from '../layouts/contentRail';
import { AppContext } from '../context/main';
import { NetworkSelector } from './NetworkSelector';
import { MultiAccounts } from './Accounts';
@@ -11,24 +13,48 @@ export const AppBar = () => {
const navigate = useNavigate();
return (
<MuiAppBar position="sticky" sx={{ boxShadow: 'none', bgcolor: 'transparent', backgroundImage: 'none', mt: 3 }}>
<Toolbar disableGutters>
<Grid container justifyContent="space-between" alignItems="center" flexWrap="nowrap">
<Grid item container alignItems="center" spacing={1}>
<Grid item>
<MuiAppBar
position="sticky"
sx={{
boxShadow: 'none',
bgcolor: 'transparent',
backgroundImage: 'none',
pt: { xs: 2, md: 3 },
}}
>
<Toolbar
disableGutters
sx={{
display: 'flex',
justifyContent: 'center',
width: '100%',
minHeight: { xs: 48, sm: 56 },
}}
>
<Box
sx={{
width: '100%',
maxWidth: CONTENT_RAIL_MAX_WIDTH_WIDE,
mx: 'auto',
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
borderBottom: (theme) => `1px solid ${alpha(theme.palette.divider, 0.55)}`,
pb: 1.25,
}}
>
<Stack direction="row" alignItems="center" spacing={1.5} flexWrap="wrap" sx={{ py: 0.5, rowGap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<MultiAccounts />
</Grid>
<Grid item>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<NetworkSelector />
</Grid>
</Grid>
<Grid item container justifyContent="flex-end" md={12} lg={5} spacing={2}>
<Grid item>
</Box>
<Stack direction="row" spacing={0.5} alignItems="center">
<IconButton size="small" onClick={() => navigate('/settings')} sx={{ color: 'text.primary' }}>
<SettingsIcon fontSize="small" />
</IconButton>
</Grid>
<Grid item>
<IconButton
size="small"
onClick={async () => {
@@ -39,9 +65,9 @@ export const AppBar = () => {
>
<Logout fontSize="small" />
</IconButton>
</Grid>
</Grid>
</Grid>
</Stack>
</Stack>
</Box>
</Toolbar>
</MuiAppBar>
);
@@ -0,0 +1,68 @@
import React from 'react';
import { Box, CircularProgress, Fade, Stack, Typography } from '@mui/material';
import { alpha, useTheme } from '@mui/material/styles';
import { NymWordmark } from '@nymproject/react/logo/NymWordmark';
export type AppSessionLoadingOverlayProps = {
title: string;
subtitle?: string;
};
/**
* In-session full-viewport overlay (blur + card) for account switch and sign-out.
* Keeps the app theme unlike {@link LoadingPage} which uses the auth splash look.
*/
export const AppSessionLoadingOverlay = ({ title, subtitle }: AppSessionLoadingOverlayProps) => {
const theme = useTheme();
const fill = theme.palette.mode === 'dark' ? '#FFFFFF' : theme.palette.text.primary;
return (
<Fade in timeout={220}>
<Box
role="status"
aria-live="polite"
aria-busy="true"
sx={{
position: 'fixed',
inset: 0,
zIndex: 2000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
px: 2,
py: 4,
bgcolor: (t) => alpha(t.palette.background.default, t.palette.mode === 'dark' ? 0.78 : 0.86),
backdropFilter: 'blur(14px)',
}}
>
<Box
sx={{
width: '100%',
maxWidth: 400,
borderRadius: 3,
border: (t) => `1px solid ${t.palette.divider}`,
bgcolor: (t) => (t.palette.mode === 'dark' ? alpha(t.palette.background.paper, 0.94) : 'background.paper'),
boxShadow: (t) => t.palette.nym.nymWallet.shadows.strong,
px: { xs: 3, sm: 4 },
py: { xs: 3.5, sm: 4 },
}}
>
<Stack spacing={2.5} alignItems="center" textAlign="center">
<NymWordmark width={64} fill={fill} />
<Stack spacing={0.75} alignItems="center">
<Typography variant="h6" component="p" sx={{ fontWeight: 700, lineHeight: 1.25 }}>
{title}
</Typography>
{subtitle ? (
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.45, maxWidth: 320 }}>
{subtitle}
</Typography>
) : null}
</Stack>
<CircularProgress size={40} thickness={4} color="primary" aria-label="Loading" />
</Stack>
</Box>
</Box>
</Fade>
);
};
+52 -26
View File
@@ -11,40 +11,66 @@ export const Bond = ({
disabled: boolean;
}) => (
<NymCard title="Bonding" borderless>
<NymCard hideHeader borderless dataTestid="bond-run-node">
<Box
sx={{
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'space-between',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
gap: 3,
}}
>
<Typography variant="body2">
Bond a nym node. Learn how to set up and run a Nym node{' '}
<Link href="https://nym.com/docs/operators/nodes/nym-node" target="_blank">
here
</Link>
</Typography>
<Box
<Box sx={{ width: '100%' }}>
<Typography
variant="overline"
sx={{
display: 'block',
color: 'text.secondary',
letterSpacing: '0.12em',
fontWeight: 600,
mb: 0.75,
}}
>
Operator
</Typography>
<Typography component="h2" variant="h5" sx={{ fontWeight: 700, lineHeight: 1.25 }}>
Run a node
</Typography>
</Box>
<Typography
variant="body2"
sx={{
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'space-between',
gap: 2,
color: 'text.secondary',
maxWidth: 640,
mx: 'auto',
lineHeight: 1.6,
}}
>
<Button
size="large"
variant="contained"
color="primary"
type="button"
disableElevation
onClick={onBond}
disabled={disabled}
>
Bond
</Button>
</Box>
Bonding locks NYM as pledge so you can run a nym node on the network. You will need enough liquid NYM to cover
the minimum pledge and fees. Read the{' '}
<Link href="https://nym.com/docs/operators/nodes/nym-node" target="_blank">
node setup and bonding guide
</Link>{' '}
before you continue.
</Typography>
<Button
size="large"
variant="contained"
color="primary"
type="button"
disableElevation
onClick={onBond}
disabled={disabled}
sx={{
alignSelf: 'stretch',
maxWidth: 360,
width: '100%',
mx: 'auto',
}}
>
Bond
</Button>
</Box>
</NymCard>
);
@@ -95,6 +95,8 @@ export const NodeSettings = ({
return (
<SimpleModal
open
dense
accent="primary"
hideCloseIcon
sx={{ p: 0 }}
header={
@@ -129,7 +131,11 @@ export const NodeSettings = ({
<FormHelperText>Your new profit margin will be applied in the next interval</FormHelperText>
</Box>
<Box sx={{ mb: 3 }}>
<ModalListItem label="Est. fee for this operation will be caculated in the next page" value="" />
<ModalListItem
label="Next step"
value="Fee for this operation is calculated on the next page."
layout="stack"
/>
</Box>
<Button variant="contained" fullWidth size="large" onClick={handleValidate} disabled={error}>
Next
@@ -51,6 +51,8 @@ export const UnbondModal = ({ node, onConfirm, onClose, onError }: Props) => {
return (
<SimpleModal
open
dense
accent="primary"
header="Unbond"
subHeader="Unbond and remove your node from the mixnet"
okLabel="Unbond"
@@ -104,8 +104,13 @@ export const UpdateBondAmountModal = ({
onPrev={resetFeeState}
onConfirm={handleConfirm}
>
<ModalListItem label="New bond details" value={newBondToDisplay()} divider />
<ModalListItem label="Change bond details" value={`${currentBond.amount} ${currentBond.denom}`} divider />
<ModalListItem label="New bond details" value={newBondToDisplay()} divider layout="stack" />
<ModalListItem
label="Previous bond"
value={`${currentBond.amount} ${currentBond.denom}`}
divider
layout="stack"
/>
{userBalance.balance?.amount.amount && fee?.amount?.amount && (
<Box sx={{ my: 2 }}>
<BalanceWarning fee={fee?.amount?.amount} tx={newBond?.amount} />
@@ -117,6 +122,8 @@ export const UpdateBondAmountModal = ({
return (
<SimpleModal
open
dense
accent="primary"
header="Change bond amount"
subHeader="Add or reduce amount of tokens on your node"
okLabel="Next"
@@ -156,7 +163,12 @@ export const UpdateBondAmountModal = ({
) : (
<ModalListItem label="Node saturation" value={`${stakeSaturation}%`} divider />
)}
<ModalListItem label="Est. fee for this operation will be calculated in the next page" value="" divider />
<ModalListItem
label="Next step"
value="Fee for this operation is calculated on the next page."
divider
layout="stack"
/>
</Box>
</Stack>
</SimpleModal>
@@ -1,10 +0,0 @@
import React from 'react';
import { Tutorial } from './Tutorial';
export default {
title: 'Buy/Tutorial',
component: Tutorial,
};
export const TutorialPage = () => <Tutorial />;
+42 -8
View File
@@ -1,5 +1,8 @@
import React from 'react';
import { Box, Typography, Grid, Link, Card, CardContent, Stack } from '@mui/material';
import { Box, Typography, Grid, Card, CardContent, Stack, Button } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import { useSnackbar } from 'notistack';
import { safeOpenUrl } from 'src/utils/safeOpenUrl';
import BitfinexIcon from 'src/svg-icons/bitfinex.svg';
import KrakenIcon from 'src/svg-icons/kraken.svg';
import BybitIcon from 'src/svg-icons/bybit.svg';
@@ -12,11 +15,13 @@ const ExchangeCard = ({
tokenType,
url,
IconComponent,
onOpenExchange,
}: {
name: string;
tokenType: string;
url: string;
IconComponent: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
onOpenExchange: (name: string, url: string) => void;
}) => (
<Card
variant="outlined"
@@ -51,21 +56,26 @@ const ExchangeCard = ({
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{tokenType}
</Typography>
<Link
href={url}
target="_blank"
variant="body2"
<Button
variant="text"
data-testid="link-get-nym"
onClick={() => onOpenExchange(name, url)}
sx={{
alignSelf: 'flex-start',
p: 0,
minWidth: 0,
textTransform: 'none',
textDecoration: 'underline',
fontWeight: 500,
fontSize: '0.875rem',
'&:hover': {
textDecoration: 'none',
background: 'transparent',
},
}}
>
GET NYM
</Link>
</Button>
</Stack>
</Stack>
</CardContent>
@@ -73,6 +83,22 @@ const ExchangeCard = ({
);
export const Tutorial = () => {
const theme = useTheme();
const { enqueueSnackbar } = useSnackbar();
const openExchange = async (name: string, url: string) => {
try {
enqueueSnackbar(`Opening ${name} in your default browser - always verify the URL in the address bar.`, {
variant: 'info',
});
await safeOpenUrl(url);
} catch (e) {
enqueueSnackbar('Could not open the link. Copy the URL from the exchange website instead.', {
variant: 'error',
});
}
};
const exchanges = [
{
name: 'Bitfinex',
@@ -107,7 +133,15 @@ export const Tutorial = () => {
];
return (
<NymCard borderless title="Where you can get NYM tokens" sx={{ mt: 4 }}>
<NymCard
borderless
title="Where you can get NYM tokens"
sx={{
backgroundColor: 'background.paper',
border: `1px solid ${theme.palette.divider}`,
boxShadow: theme.palette.nym.nymWallet.shadows.light,
}}
>
<Typography mb={3} fontSize={14} sx={{ color: 'text.secondary' }}>
You can get NYM tokens from these exchanges
</Typography>
@@ -115,7 +149,7 @@ export const Tutorial = () => {
<Grid container spacing={3}>
{exchanges.map((exchange) => (
<Grid item xs={12} md={6} lg={4} key={exchange.name}>
<ExchangeCard {...exchange} />
<ExchangeCard {...exchange} onOpenExchange={openExchange} />
</Grid>
))}
</Grid>
@@ -71,6 +71,7 @@ export const ClipboardActions = ({
if (!fieldRef) return;
const keydownHandler = async (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
// Only handle if the associated field is focused
const { activeElement } = document;
if (fieldRef.current && activeElement === fieldRef.current) {
@@ -6,6 +6,11 @@ import { UseFormRegister, UseFormSetValue, FieldValues, Path, FieldErrors } from
import { writeText, readText } from '@tauri-apps/plugin-clipboard-manager';
import { PasteFromClipboard } from './ClipboardActions';
/**
* Keyboard clipboard helpers for fields that opt in via this module.
* App-wide Tauri paste/copy for normal inputs lives in `useTauriTextEditingClipboard` (see
* `data-nym-paste-replace`, `data-nym-currency-field`, `data-nym-auth-paste-field` exclusions).
*/
export const useCopyAllSupport = (
inputRef: React.MutableRefObject<HTMLInputElement | HTMLTextAreaElement | null>,
onPasteValue?: (value: string) => void,
@@ -15,6 +20,7 @@ export const useCopyAllSupport = (
const handleKeyDown = async (e: Event) => {
const keyEvent = e as KeyboardEvent;
if (keyEvent.defaultPrevented) return;
if (document.activeElement !== inputRef.current) return;
@@ -71,46 +77,47 @@ export const useCopyAllSupport = (
}, [inputRef.current, onPasteValue]);
};
export const TextFieldWithPaste = React.forwardRef<
HTMLDivElement,
TextFieldProps & {
onPasteValue?: (value: string) => void;
}
>(({ onPasteValue, ...props }, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
export type TextFieldWithPasteProps = TextFieldProps & {
// eslint-disable-next-line react/require-default-props -- optional on `forwardRef` + intersection props; see `onPasteValue = undefined` in render
onPasteValue?: (value: string) => void;
};
useCopyAllSupport(inputRef, onPasteValue);
export const TextFieldWithPaste = React.forwardRef<HTMLDivElement, TextFieldWithPasteProps>(
({ onPasteValue = undefined, inputProps: userInputProps, InputProps: userInputPropsMui, ...props }, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
const handlePaste = (pastedText: string) => {
onPasteValue?.(pastedText);
useCopyAllSupport(inputRef, onPasteValue);
if (inputRef.current) {
inputRef.current.focus();
}
};
const handlePaste = (pastedText: string) => {
onPasteValue?.(pastedText);
return (
<TextField
{...props}
ref={ref}
inputRef={inputRef}
InputProps={{
...props.InputProps,
if (inputRef.current) {
inputRef.current.focus();
}
};
const fieldProps = {
...props,
ref,
inputRef,
inputProps: {
...userInputProps,
'data-nym-paste-replace': 'true',
},
InputProps: {
...userInputPropsMui,
endAdornment: (
<InputAdornment position="end">
{onPasteValue && <PasteFromClipboard onPaste={handlePaste} fieldRef={inputRef} />}
{props.InputProps?.endAdornment}
{userInputPropsMui?.endAdornment}
</InputAdornment>
),
}}
/>
);
});
},
};
// Add defaultProps to fix the "require-default-props" warning
TextFieldWithPaste.defaultProps = {
onPasteValue: undefined,
};
return <TextField {...(fieldProps as TextFieldProps)} />;
},
);
export const CurrencyFormFieldWithPaste = ({
label,
@@ -167,6 +174,7 @@ export const CurrencyFormFieldWithPaste = ({
const handleKeyDown = async (e: Event) => {
const keyEvent = e as KeyboardEvent;
if (keyEvent.defaultPrevented) return;
if (document.activeElement !== inputRef.current) return;
// Handle Cmd+A (Select All)
@@ -236,7 +244,7 @@ export const CurrencyFormFieldWithPaste = ({
}, []);
return (
<Box position="relative" width="100%" ref={fieldRef}>
<Box position="relative" width="100%" ref={fieldRef} data-nym-currency-field>
<CurrencyFormField
label={label}
fullWidth={fullWidth}
@@ -274,6 +282,8 @@ export const HookFormTextFieldWithPaste = <TFieldValues extends FieldValues>({
register,
setValue,
errors,
inputProps: userInputProps,
InputProps: userInputPropsMui,
...props
}: {
name: Path<TFieldValues>;
@@ -295,25 +305,29 @@ export const HookFormTextFieldWithPaste = <TFieldValues extends FieldValues>({
// 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}
/>
);
const fieldProps = {
...register(name),
...props,
label,
error: Boolean(errors[name]),
helperText: errors[name]?.message?.toString(),
inputRef,
inputProps: {
...userInputProps,
'data-nym-paste-replace': 'true',
},
InputProps: {
...userInputPropsMui,
endAdornment: (
<InputAdornment position="end">
<PasteFromClipboard onPaste={handlePaste} fieldRef={inputRef} />
{userInputPropsMui?.endAdornment}
</InputAdornment>
),
},
};
return <TextField {...(fieldProps as TextFieldProps)} />;
};
export const HookFormCurrencyFieldWithPaste = <TFieldValues extends FieldValues>({
@@ -361,6 +375,7 @@ export const HookFormCurrencyFieldWithPaste = <TFieldValues extends FieldValues>
const handleKeyDown = async (e: Event) => {
const keyEvent = e as KeyboardEvent;
if (keyEvent.defaultPrevented) return;
if (document.activeElement !== inputRef.current) return;
// Handle Cmd+A (Select All)
@@ -435,7 +450,7 @@ export const HookFormCurrencyFieldWithPaste = <TFieldValues extends FieldValues>
};
return (
<Box position="relative" width="100%" ref={fieldRef}>
<Box position="relative" width="100%" ref={fieldRef} data-nym-currency-field>
<CurrencyFormField
label={label}
fullWidth
@@ -1,29 +0,0 @@
import * as React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { ConfirmTx } from './ConfirmTX';
import { ModalListItem } from './Modals/ModalListItem';
export default {
title: 'Wallet / Confirm Transaction',
component: ConfirmTx,
} as ComponentMeta<typeof ConfirmTx>;
const Template: ComponentStory<typeof ConfirmTx> = (args) => (
<ConfirmTx {...args}>
<ModalListItem label="Transaction type" value="Bond" divider />
<ModalListItem label="Current bond" value="100 NYM" divider />
<ModalListItem label="Additional bond" value="50 NYM" divider />
</ConfirmTx>
);
export const Default = Template.bind({});
Default.args = {
open: true,
header: 'Confirm transaction',
subheader: 'Confirm and proceed or cancel transaction',
fee: { amount: { amount: '0.001', denom: 'nym' }, fee: { Auto: null } },
onClose: () => {},
onConfirm: async () => {},
onPrev: () => {},
isStorybook: true,
};
+17 -32
View File
@@ -1,19 +1,9 @@
import React from 'react';
import { FeeDetails } from '@nymproject/types';
import { Box } from '@mui/material';
import { useTheme, Theme } from '@mui/material/styles';
import { SimpleModal } from './Modals/SimpleModal';
import { ModalFee } from './Modals/ModalFee';
import { ModalDivider } from './Modals/ModalDivider';
import { backDropStyles, modalStyles } from '../../.storybook/storiesStyles';
const storybookStyles = (theme: Theme, isStorybook?: boolean, backdropProps?: object) =>
isStorybook
? {
backdropProps: { ...backDropStyles(theme), ...backdropProps },
sx: modalStyles(theme),
}
: {};
export const ConfirmTx: FCWithChildren<{
open: boolean;
@@ -23,26 +13,21 @@ export const ConfirmTx: FCWithChildren<{
onConfirm: () => Promise<void>;
onClose?: () => void;
onPrev: () => void;
isStorybook?: boolean;
children?: React.ReactNode;
}> = ({ open, fee, onConfirm, onClose, header, subheader, onPrev, children, isStorybook }) => {
const theme = useTheme();
return (
<SimpleModal
open={open}
header={header}
subHeader={subheader}
okLabel="Confirm"
onOk={onConfirm}
onClose={onClose}
onBack={onPrev}
{...storybookStyles(theme, isStorybook)}
>
<Box sx={{ mt: 3 }}>
{children}
<ModalFee fee={fee} isLoading={false} />
<ModalDivider />
</Box>
</SimpleModal>
);
};
}> = ({ open, fee, onConfirm, onClose, header, subheader, onPrev, children }) => (
<SimpleModal
open={open}
header={header}
subHeader={subheader}
okLabel="Confirm"
onOk={onConfirm}
onClose={onClose}
onBack={onPrev}
>
<Box sx={{ mt: 3 }}>
{children}
<ModalFee fee={fee} isLoading={false} />
<ModalDivider />
</Box>
</SimpleModal>
);
@@ -95,6 +95,7 @@ export const CurrencyFormFieldWithPaste = ({
useEffect(() => {
const handleKeyDown = async (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
if (inputRef.current && document.activeElement === inputRef.current) {
if ((e.metaKey || e.ctrlKey) && e.key === 'v') {
e.preventDefault();
@@ -120,7 +121,7 @@ export const CurrencyFormFieldWithPaste = ({
}, [denom, onChanged]);
return (
<Box position="relative" width="100%" ref={fieldRef}>
<Box position="relative" width="100%" ref={fieldRef} data-nym-currency-field>
<CurrencyFormField
label={label}
fullWidth={fullWidth}
@@ -1,40 +0,0 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { useTheme } from '@mui/material/styles';
import { Button, Paper, Typography } from '@mui/material';
import { backDropStyles, modalStyles } from '../../../.storybook/storiesStyles';
import { OverSaturatedBlockerModal } from './DelegateBlocker';
export default {
title: 'Delegation/Components/Delegation Over Saturated Warning Modal',
component: OverSaturatedBlockerModal,
} as ComponentMeta<typeof OverSaturatedBlockerModal>;
export const Default = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<>
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
<h2>Lorem ipsum</h2>
<Button variant="contained" onClick={handleClick} sx={{ mb: 3 }}>
Show modal
</Button>
<Typography>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis
sunt velit elit do minim mollit non duis reprehenderit. Eiusmod dolore adipisicing ex nostrud consectetur
culpa exercitation do. Ad elit esse ipsum aliqua labore irure laborum qui culpa.
</Typography>
</Paper>
<OverSaturatedBlockerModal
open={open}
header="Node saturation: 114%"
onClose={() => setOpen(false)}
backdropProps={backDropStyles(theme)}
sx={modalStyles(theme)}
/>
</>
);
};
@@ -219,7 +219,7 @@ export const DelegateModal: FCWithChildren<{
<BalanceWarning fee={fee?.amount?.amount} tx={amount} />
</Box>
)}
<ModalListItem label="Node identity key" value={identityKey} divider />
<ModalListItem label="Node identity key" value={identityKey} divider layout="stack" />
<ModalListItem label="Amount" value={`${amount} ${denom.toUpperCase()}`} divider />
</ConfirmTx>
);
@@ -249,10 +249,12 @@ export const DelegateModal: FCWithChildren<{
header={header || 'Delegate'}
okLabel={buttonText || 'Delegate stake'}
okDisabled={!isValidated}
dense
accent="primary"
sx={sx}
backdropProps={backdropProps}
>
<Box sx={{ mt: 3 }}>
<Box sx={{ mt: 2.5 }}>
<TextFieldWithPaste
label="Node identity key"
fullWidth
@@ -264,7 +266,7 @@ export const DelegateModal: FCWithChildren<{
InputLabelProps={{ shrink: true }}
/>
</Box>
<Box display="flex" gap={2} alignItems="center" sx={{ mt: 3 }}>
<Box display="flex" gap={2} alignItems="center" sx={{ mt: 2.5 }}>
{hasVestingContract && <TokenPoolSelector disabled={false} onSelect={(pool) => setTokenPool(pool)} />}
<CurrencyFormFieldWithPaste
label="Amount"
@@ -275,7 +277,7 @@ export const DelegateModal: FCWithChildren<{
validationError={errorAmount}
/>
</Box>
<Box sx={{ mt: 3 }}>
<Box sx={{ mt: 2.5 }}>
<ModalListItem label="Account balance" value={accountBalance?.toUpperCase()} divider fontWeight={600} />
</Box>
@@ -299,7 +301,11 @@ export const DelegateModal: FCWithChildren<{
hidden
divider
/>
<ModalListItem label="Est. fee for this transaction will be calculated in the next page" />
<ModalListItem
label="Next step"
value="Fee for this transaction is calculated on the next page."
layout="stack"
/>
</SimpleModal>
);
};
@@ -1,19 +0,0 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { DelegationActions } from './DelegationActions';
export default {
title: 'Delegation/Components/Delegation List Item Actions',
component: DelegationActions,
} as ComponentMeta<typeof DelegationActions>;
export const Default = () => <DelegationActions />;
export const RedeemingDisabled = () => <DelegationActions disableRedeemingRewards />;
export const PendingDelegation = () => <DelegationActions isPending={{ actionType: 'delegate', blockHeight: 1000 }} />;
export const PendingUndelegation = () => (
<DelegationActions isPending={{ actionType: 'undelegate', blockHeight: 1000 }} />
);
@@ -1,135 +0,0 @@
import React from 'react';
import { Box, Chip, IconButton, TableCell, TableRow, Tooltip, Typography } from '@mui/material';
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';
import { toPercentIntegerString } from 'src/utils';
import { format } from 'date-fns';
import { Undelegate } from 'src/svg-icons';
import { DelegationListItemActions, DelegationsActionsMenu } from './DelegationActions';
const getStakeSaturation = (item: DelegationWithEverything) =>
!item.stake_saturation ? '-' : `${decimalToPercentage(item.stake_saturation)}%`;
const getRewardValue = (item: DelegationWithEverything) => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { unclaimed_rewards } = item;
return !unclaimed_rewards ? '-' : `${unclaimed_rewards.amount} ${unclaimed_rewards.denom}`;
};
export const DelegationItem = ({
item,
explorerUrl,
nodeIsUnbonded,
onItemActionClick,
}: {
item: DelegationWithEverything;
explorerUrl: string;
nodeIsUnbonded: boolean;
onItemActionClick?: (item: DelegationWithEverything, action: DelegationListItemActions) => void;
}) => {
const { setDelegationItemErrors } = useDelegationContext();
const operatingCost = isDelegation(item) && item.cost_params?.interval_operating_cost;
const tooltipText = () => {
if (nodeIsUnbonded) {
return 'This node has unbonded and it does not exist anymore. You need to undelegate from it to get your stake and outstanding rewards (if any) back.';
}
return '';
};
return (
<Tooltip arrow title={tooltipText()}>
<TableRow key={item.node_identity} sx={{ color: !item.node_identity ? 'error.main' : 'inherit' }}>
<TableCell sx={{ color: 'inherit', pr: 1 }} padding="normal">
{nodeIsUnbonded ? (
'-'
) : (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{item.errors && (
<Tooltip title="Open to view a list of errors that occurred">
<IconButton
sx={{ mr: 1 }}
size="small"
onClick={() => setDelegationItemErrors({ nodeId: item.node_identity, errors: item.errors! })}
>
<WarningAmberOutlined color="warning" fontSize="small" />
</IconButton>
</Tooltip>
)}
<Link
target="_blank"
href={`${explorerUrl}/nodes/${item.mix_id}`}
text={`${item.node_identity.slice(0, 6)}...${item.node_identity.slice(-6)}`}
color="text.primary"
noIcon
/>
</Box>
)}
</TableCell>
<TableCell sx={{ color: 'inherit' }}>
{isDelegation(item) && (!item.avg_uptime_percent ? '-' : `${item.avg_uptime_percent}%`)}
</TableCell>
<TableCell sx={{ color: 'inherit' }}>
{isDelegation(item) &&
(!item.cost_params?.profit_margin_percent
? '-'
: `${toPercentIntegerString(item.cost_params.profit_margin_percent)}%`)}
</TableCell>
<TableCell sx={{ color: 'inherit' }}>
<Typography style={{ textTransform: 'uppercase', fontSize: 'inherit' }}>
{operatingCost ? `${operatingCost.amount} ${operatingCost.denom}` : '-'}
</Typography>
</TableCell>
<TableCell sx={{ color: 'inherit' }}>{getStakeSaturation(item)}</TableCell>
<TableCell sx={{ color: 'inherit' }}>
{item.delegated_on_iso_datetime && format(new Date(item.delegated_on_iso_datetime), 'dd/MM/yyyy')}
</TableCell>
<TableCell sx={{ color: 'inherit' }}>
<Typography style={{ textTransform: 'uppercase', fontSize: 'inherit' }}>
{isDelegation(item) ? `${item.amount.amount} ${item.amount.denom}` : '-'}
</Typography>
</TableCell>
<TableCell sx={{ textTransform: 'uppercase', color: 'inherit' }}>{getRewardValue(item)}</TableCell>
<TableCell>
{item.uses_vesting_contract_tokens && (
<Tooltip title="Delegation uses locked tokens">
<LockOutlined sx={{ color: 'grey.800' }} fontSize="small" />
</Tooltip>
)}
</TableCell>
<TableCell align="center" sx={{ color: 'inherit' }}>
{!item.pending_events.length && !nodeIsUnbonded && (
<DelegationsActionsMenu
onActionClick={(action) => (onItemActionClick ? onItemActionClick(item, action) : undefined)}
disableRedeemingRewards={!item.unclaimed_rewards || item.unclaimed_rewards.amount === '0'}
disableDelegateMore={item.mixnode_is_unbonding}
/>
)}
{!item.pending_events.length && nodeIsUnbonded && (
<IconButton sx={{ color: (t) => t.palette.nym.nymWallet.text.main }}>
<Undelegate onClick={() => (onItemActionClick ? onItemActionClick(item, 'undelegate') : undefined)} />
</IconButton>
)}
{item.pending_events.length > 0 && (
<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" />
</Tooltip>
)}
</TableCell>
</TableRow>
</Tooltip>
);
};
@@ -1,311 +0,0 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { DelegationWithEverything } from '@nymproject/types';
import { DelegationList } from './DelegationList';
export default {
title: 'Delegation/Components/Delegation List',
component: DelegationList,
} as ComponentMeta<typeof DelegationList>;
const explorerUrl = 'https://sandbox-explorer.nymtech.net/network-components/mixnodes';
export const items: DelegationWithEverything[] = [
{
mix_id: 1,
node_identity: 'FiojKW7oY9WQmLCiYAsCA21tpowZHS6zcUoyYm319p6Z',
delegated_on_iso_datetime: new Date(2021, 1, 1).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
amount: { amount: '10', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(100),
stake_saturation: '0.25',
avg_uptime_percent: 0.5,
uses_vesting_contract_tokens: false,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 2,
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
amount: { amount: '1010', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '200', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.43',
avg_uptime_percent: 0.22,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 3,
node_identity: '',
amount: { amount: '300', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '50',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '300', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.5',
avg_uptime_percent: 0.1,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 4,
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
amount: { amount: '201', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '60',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '202', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.5',
avg_uptime_percent: 0.1,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 5,
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
amount: { amount: '100', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '80',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.5',
avg_uptime_percent: 0.1,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 6,
node_identity: '',
amount: { amount: '202', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.8',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.5',
avg_uptime_percent: 0.1,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 7,
node_identity: 'FiojKW7oY9WQmLCiYAsCA21tpowZHS6zcUoyYm319p6Z',
delegated_on_iso_datetime: new Date(2021, 1, 1).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
amount: { amount: '202', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.59',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(100),
stake_saturation: '0.5',
avg_uptime_percent: 0.5,
uses_vesting_contract_tokens: false,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 8,
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
amount: { amount: '100', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.9',
avg_uptime_percent: 0.1,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 9,
node_identity: '',
amount: { amount: '1000', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.4',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.9',
avg_uptime_percent: 0.1,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 10,
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
amount: { amount: '100', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.5',
avg_uptime_percent: 0.1,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 11,
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
amount: { amount: '100', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.56',
avg_uptime_percent: 0.9,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
{
mix_id: 12,
node_identity: '',
amount: { amount: '100', denom: 'nym' },
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
cost_params: {
profit_margin_percent: '0.1122323949234',
interval_operating_cost: {
amount: '40',
denom: 'nym',
},
},
accumulated_by_delegates: { amount: '50', denom: 'nym' },
accumulated_by_operator: { amount: '100', denom: 'nym' },
owner: '',
block_height: BigInt(4000),
stake_saturation: '0.5',
avg_uptime_percent: 0.1,
uses_vesting_contract_tokens: true,
pending_events: [],
mixnode_is_unbonding: true,
errors: null,
},
];
export const WithData = () => <DelegationList items={items} explorerUrl={explorerUrl} />;
export const Empty = () => <DelegationList items={[]} explorerUrl={explorerUrl} />;
export const OneItem = () => <DelegationList items={[items[0]]} explorerUrl={explorerUrl} />;
export const Loading = () => <DelegationList items={[]} isLoading explorerUrl={explorerUrl} />;
@@ -4,24 +4,36 @@ import {
AlertTitle,
Box,
Button,
Collapse,
FormControl,
IconButton,
InputLabel,
MenuItem,
Select,
Skeleton,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TableSortLabel,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import { visuallyHidden } from '@mui/utils';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import { DelegationWithEverything } from '@nymproject/types';
import { alpha } from '@mui/material/styles';
import { KeyboardArrowDown, KeyboardArrowUp, LockOutlined, WarningAmberOutlined } from '@mui/icons-material';
import { decimalToFloatApproximation, decimalToPercentage, 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';
import { LoadingModal } from '../Modals/LoadingModal';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
import { format } from 'date-fns';
import { Undelegate } from 'src/svg-icons';
import { toPercentIntegerString } from 'src/utils';
import { InfoTooltip } from '../InfoToolTip';
import { DelegationListItemActions, DelegationsActionsMenu } from './DelegationActions';
import { PendingDelegationCard } from './PendingDelegationCard';
import { isDelegation, isPendingDelegation, TDelegations, useDelegationContext } from '../../context/delegations';
import { ErrorModal } from '../Modals/ErrorModal';
@@ -29,126 +41,37 @@ 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;
orderBy: string;
}
interface HeadCell {
id: string;
label: string | React.ReactNode;
sortable: boolean;
disablePadding?: boolean;
align: 'left' | 'center' | 'right';
width?: string;
}
const headCells: HeadCell[] = [
{ 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', 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 SORT_FIELD_OPTIONS: { id: SortingKeys; label: string }[] = [
{ id: 'delegated_on_iso_datetime', label: 'Delegated on' },
{ id: 'node_identity', label: 'Node ID' },
{ id: 'avg_uptime_percent', label: 'Routing score' },
{ id: 'profit_margin_percent', label: 'Profit margin' },
{ id: 'operating_cost', label: 'Operating cost' },
{ id: 'stake_saturation', label: 'Stake saturation' },
{ id: 'amount', label: 'Delegation' },
{ id: 'unclaimed_rewards', label: 'Reward' },
];
const EnhancedTableHead: FCWithChildren<EnhancedTableProps> = ({ order, orderBy, onRequestSort }) => {
const createSortHandler = (property: string) => (event: React.MouseEvent<unknown>) => {
onRequestSort(event, property);
};
return (
<TableHead>
<TableRow>
{headCells.map((headCell) => (
<TableCell
key={headCell.id}
align={headCell.align}
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}
direction={orderBy === headCell.id ? order : 'asc'}
onClick={createSortHandler(headCell.id)}
IconComponent={ArrowDropDownIcon}
>
{headCell.label}
{orderBy === headCell.id ? (
<Box component="span" sx={visuallyHidden}>
{order === 'desc' ? 'sorted descending' : 'sorted ascending'}
</Box>
) : null}
</TableSortLabel>
</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>
);
};
const hasPruningError = (item: any): boolean => {
if (!isDelegation(item) || !item.errors) return false;
@@ -158,43 +81,69 @@ const hasPruningError = (item: any): boolean => {
);
};
const getStakeSaturation = (item: DelegationWithEverything) =>
!item.stake_saturation ? '-' : `${decimalToPercentage(item.stake_saturation)}%`;
const getRewardValue = (item: DelegationWithEverything) => {
const { unclaimed_rewards } = item;
return !unclaimed_rewards ? '-' : `${unclaimed_rewards.amount} ${unclaimed_rewards.denom}`;
};
const saturationNumeric = (item: DelegationWithEverything): number | undefined => {
if (!item.stake_saturation) return undefined;
return decimalToFloatApproximation(item.stake_saturation);
};
export const DelegationList: FCWithChildren<{
isLoading?: boolean;
items: TDelegations;
onItemActionClick?: (item: DelegationWithEverything, action: DelegationListItemActions) => void;
explorerUrl: string;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
}> = ({ isLoading, items, onItemActionClick, explorerUrl }) => {
nextEpoch?: string | Error;
}> = ({ items, onItemActionClick, explorerUrl, nextEpoch }) => {
const [order, setOrder] = React.useState<Order>('asc');
const [orderBy, setOrderBy] = React.useState<SortingKeys>('delegated_on_iso_datetime');
const [identityFilter, setIdentityFilter] = React.useState('');
const [expandedKey, setExpandedKey] = React.useState<string | null>(null);
const navigate = useNavigate();
const { delegationItemErrors, setDelegationItemErrors } = useDelegationContext();
const {
delegationItemErrors,
setDelegationItemErrors,
totalDelegations,
totalRewards,
totalDelegationsAndRewards,
isLoading: delegationsSummaryLoading,
} = useDelegationContext();
const handleRequestSort = (_: React.MouseEvent<unknown>, property: any) => {
const isAsc = orderBy === property && order === 'asc';
setOrder(isAsc ? 'desc' : 'asc');
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 activeDelegations = React.useMemo(
() => filteredItems.filter((item): item is DelegationWithEverything => isDelegation(item)),
[filteredItems],
);
const pendingItems = React.useMemo(() => filteredItems.filter((item) => isPendingDelegation(item)), [filteredItems]);
const searchNeedle = identityFilter.trim().toLowerCase();
const displayedDelegations = React.useMemo(() => {
if (!searchNeedle) return activeDelegations;
return activeDelegations.filter((d) => d.node_identity.toLowerCase().includes(searchNeedle));
}, [activeDelegations, searchNeedle]);
const activeCount = activeDelegations.length;
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.';
@@ -202,9 +151,22 @@ export const DelegationList: FCWithChildren<{
return message;
};
const pendingKey = (item: any, suffix: string) =>
`pending-${item.event?.mix_id}-${item.event?.address ?? ''}-${item.event?.kind ?? ''}-${
item.node_identity ?? ''
}-${suffix}`;
const nextEpochLine =
nextEpoch instanceof Error || !nextEpoch ? null : (
<Typography fontSize={14} color="text.secondary" sx={{ lineHeight: 1.5 }}>
Next epoch starts at <strong>{nextEpoch}</strong>
</Typography>
);
const emptyTableMessage = searchNeedle ? 'No delegations match your search.' : 'No delegations to show.';
return (
<>
{/* Display pruning error alert at the top if needed */}
{hasPruningErrors && (
<Alert
severity="warning"
@@ -223,78 +185,412 @@ export const DelegationList: FCWithChildren<{
</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()}`;
<Stack spacing={2} sx={{ width: '100%' }}>
<Stack spacing={2}>
<Box sx={{ maxWidth: 800 }}>
<Typography variant="h6" component="h2">
Delegations
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{activeCount} active
</Typography>
</Box>
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}
/>
);
}
<Stack spacing={1.25}>
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={2}
alignItems={{ xs: 'stretch', md: 'flex-end' }}
flexWrap="wrap"
>
<TextField
size="small"
label="Search identity"
value={identityFilter}
onChange={(e) => setIdentityFilter(e.target.value)}
sx={{
minWidth: { xs: '100%', md: 220 },
flex: { md: '1 1 200px' },
'& .MuiOutlinedInput-root': { borderRadius: 2 },
}}
/>
<FormControl size="small" sx={{ minWidth: 200, '& .MuiOutlinedInput-root': { borderRadius: 2 } }}>
<InputLabel id="delegation-sort-field-label">Sort by</InputLabel>
<Select
labelId="delegation-sort-field-label"
label="Sort by"
value={orderBy}
onChange={(e) => setOrderBy(e.target.value as SortingKeys)}
>
{SORT_FIELD_OPTIONS.map((opt) => (
<MenuItem key={opt.id} value={opt.id}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160, '& .MuiOutlinedInput-root': { borderRadius: 2 } }}>
<InputLabel id="delegation-sort-order-label">Order</InputLabel>
<Select
labelId="delegation-sort-order-label"
label="Order"
value={order}
onChange={(e) => setOrder(e.target.value as Order)}
>
<MenuItem value="asc">Ascending</MenuItem>
<MenuItem value="desc">Descending</MenuItem>
</Select>
</FormControl>
</Stack>
{nextEpochLine ? <Box sx={{ pt: 0.25 }}>{nextEpochLine}</Box> : null}
</Stack>
</Stack>
return <PendingDelegationItem key={pendingKey} item={item} explorerUrl={explorerUrl} />;
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="stretch">
<Box
sx={{
flex: 1,
minWidth: 0,
minHeight: 92,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
borderRadius: 2,
border: (t) => `1px solid ${t.palette.divider}`,
bgcolor: (t) =>
t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle',
p: 2.5,
}}
>
<Stack direction="row" alignItems="center" gap={0.5}>
<InfoTooltip title="The total amount you have delegated to node(s) in the network. The amount also includes the rewards you have accrued since last time you claimed your rewards" />
<Typography variant="body2" color="text.secondary">
Total delegations
</Typography>
</Stack>
<Typography fontWeight={600} fontSize={16} sx={{ mt: 0.5, textTransform: 'uppercase' }}>
{delegationsSummaryLoading ? <Skeleton width={140} height={22} /> : totalDelegationsAndRewards ?? '-'}
</Typography>
</Box>
<Box
sx={{
flex: 1,
minWidth: 0,
minHeight: 92,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
borderRadius: 2,
border: (t) => `1px solid ${t.palette.divider}`,
bgcolor: (t) =>
t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle',
p: 2.5,
}}
>
<Stack direction="row" alignItems="center" gap={0.5}>
<InfoTooltip title="The initial amount you delegated to the node(s)" />
<Typography variant="body2" color="text.secondary">
Original delegations
</Typography>
</Stack>
<Typography fontWeight={600} fontSize={16} sx={{ mt: 0.5, textTransform: 'uppercase' }}>
{delegationsSummaryLoading ? <Skeleton width={120} height={22} /> : totalDelegations ?? '-'}
</Typography>
</Box>
<Box
sx={{
flex: 1,
minWidth: 0,
minHeight: 92,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
borderRadius: 2,
border: (t) => `1px solid ${t.palette.divider}`,
bgcolor: (t) =>
t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle',
p: 2.5,
}}
>
<Stack direction="row" alignItems="center" gap={0.5}>
<InfoTooltip title="The rewards you have accrued since the last time you claimed your rewards. Rewards are automatically compounded. You can claim your rewards at any time." />
<Typography variant="body2" color="text.secondary">
Total rewards
</Typography>
</Stack>
<Typography fontWeight={600} fontSize={16} sx={{ mt: 0.5, textTransform: 'uppercase' }}>
{delegationsSummaryLoading ? <Skeleton width={120} height={22} /> : totalRewards ?? '-'}
</Typography>
</Box>
</Stack>
{pendingItems.length > 0 && (
<Stack spacing={1}>
<Typography variant="subtitle2" color="text.secondary">
Pending
</Typography>
<Stack spacing={2}>
{pendingItems.map((item: any, index: number) => {
if (
item.event &&
item.event.kind === 'Delegate' &&
(!item.node_identity || item.node_identity === '')
) {
return (
<PendingDelegationCard
key={pendingKey(item, `d-${index}`)}
item={{
...item,
node_identity: `Mix Identity Key ${item.event.mix_id}`,
}}
explorerUrl={explorerUrl}
/>
);
}
return (
<PendingDelegationCard key={pendingKey(item, `p-${index}`)} item={item} explorerUrl={explorerUrl} />
);
})}
</Stack>
</Stack>
)}
<TableContainer
sx={{
width: '100%',
overflowX: 'auto',
borderRadius: 3,
border: (t) => `1px solid ${t.palette.divider}`,
bgcolor: (t) =>
t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle',
}}
>
<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 stickyHeader size="small" sx={{ tableLayout: 'fixed' }}>
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 600, py: 1.25, width: '40%' }}>Node</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, py: 1.25, width: '16%' }}>
Amount
</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, py: 1.25, width: '14%' }}>
Saturation
</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, py: 1.25, width: '18%' }}>
Reward
</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, py: 1.25, width: 120, minWidth: 112 }}>
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{displayedDelegations.length === 0 ? (
<TableRow>
<TableCell colSpan={5} sx={{ py: 1.25 }}>
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
{emptyTableMessage}
</Typography>
</TableCell>
</TableRow>
) : (
displayedDelegations.map((item) => {
const rowKey = `${item.mix_id}-${item.node_identity}`;
const isOpen = expandedKey === rowKey;
const nodeIsUnbonded = Boolean(!item.node_identity);
const satNum = saturationNumeric(item);
let satColor: 'text.secondary' | 'error.main' | 'success.main' = 'text.secondary';
if (satNum !== undefined) {
satColor = satNum > 1 ? 'error.main' : 'success.main';
}
if (isDelegation(item)) {
if (!item.node_identity || item.node_identity === '-' || item.node_identity === '...') {
return null;
}
const operatingCost = item.cost_params?.interval_operating_cost;
const uptime = item.avg_uptime_percent;
const routingDisplay = uptime != null && String(uptime) !== '-' ? `${uptime}%` : '-';
const marginDisplay = item.cost_params?.profit_margin_percent
? `${toPercentIntegerString(item.cost_params.profit_margin_percent)}%`
: '-';
const costDisplay = operatingCost ? `${operatingCost.amount} ${operatingCost.denom}` : '-';
const delegatedDisplay = item.delegated_on_iso_datetime
? format(new Date(item.delegated_on_iso_datetime), 'dd/MM/yyyy')
: '-';
return (
<DelegationItem
key={`delegation-${item.mix_id}`}
item={item}
explorerUrl={explorerUrl}
nodeIsUnbonded={Boolean(!item.node_identity)}
onItemActionClick={onItemActionClick}
/>
);
}
const unbondedTooltip =
'This node has unbonded and it does not exist anymore. You need to undelegate from it to get your stake and outstanding rewards (if any) back.';
return null;
return (
<React.Fragment key={rowKey}>
<TableRow hover sx={{ '& > *': { borderBottom: 'unset' } }}>
<TableCell sx={{ py: 1.25, verticalAlign: 'middle' }}>
<Stack direction="row" alignItems="center" spacing={0.5}>
<IconButton
aria-label="expand row"
size="small"
onClick={() => setExpandedKey(isOpen ? null : rowKey)}
>
{isOpen ? <KeyboardArrowUp /> : <KeyboardArrowDown />}
</IconButton>
<Stack direction="row" alignItems="center" gap={0.5} flexWrap="wrap" sx={{ minWidth: 0 }}>
{item.errors && (
<Tooltip title="Open to view a list of errors that occurred">
<IconButton
size="small"
onClick={() =>
setDelegationItemErrors({ nodeId: item.node_identity, errors: item.errors! })
}
>
<WarningAmberOutlined color="warning" fontSize="small" />
</IconButton>
</Tooltip>
)}
{item.uses_vesting_contract_tokens && (
<Tooltip title="Delegation uses locked tokens">
<LockOutlined sx={{ color: 'text.secondary', fontSize: 18 }} />
</Tooltip>
)}
{nodeIsUnbonded ? (
<Tooltip title={unbondedTooltip} arrow>
<Typography color="text.secondary">-</Typography>
</Tooltip>
) : (
<Link
target="_blank"
href={`${explorerUrl}/nodes/${item.mix_id}`}
text={`${item.node_identity.slice(0, 6)}...${item.node_identity.slice(-6)}`}
color="text.primary"
noIcon
/>
)}
</Stack>
</Stack>
</TableCell>
<TableCell align="right" sx={{ py: 1.25, whiteSpace: 'nowrap', verticalAlign: 'middle' }}>
{item.amount.amount} {item.amount.denom}
</TableCell>
<TableCell
align="right"
sx={{ py: 1.25, color: satColor, whiteSpace: 'nowrap', verticalAlign: 'middle' }}
>
{getStakeSaturation(item)}
</TableCell>
<TableCell align="right" sx={{ py: 1.25, whiteSpace: 'nowrap', verticalAlign: 'middle' }}>
{getRewardValue(item)}
</TableCell>
<TableCell align="right" sx={{ py: 1.25, whiteSpace: 'nowrap', verticalAlign: 'middle' }}>
{!item.pending_events.length && !nodeIsUnbonded && (
<DelegationsActionsMenu
onActionClick={(action) =>
onItemActionClick ? onItemActionClick(item, action) : undefined
}
disableRedeemingRewards={!item.unclaimed_rewards || item.unclaimed_rewards.amount === '0'}
disableDelegateMore={item.mixnode_is_unbonding}
/>
)}
{!item.pending_events.length && nodeIsUnbonded && (
<IconButton sx={{ color: (t) => t.palette.nym.nymWallet.text.main }} size="small">
<Undelegate
onClick={() => (onItemActionClick ? onItemActionClick(item, 'undelegate') : undefined)}
/>
</IconButton>
)}
{item.pending_events.length > 0 && (
<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' },
},
}}
>
<Typography variant="caption" color="text.secondary">
Pending events
</Typography>
</Tooltip>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={5}>
<Collapse in={isOpen} timeout="auto" unmountOnExit>
<Box
sx={{
py: 2,
px: 2,
borderRadius: 3,
overflow: 'hidden',
border: (t) =>
`1px solid ${alpha(t.palette.divider, t.palette.mode === 'dark' ? 0.35 : 0.5)}`,
bgcolor: (t) =>
t.palette.mode === 'dark'
? alpha(t.palette.common.white, 0.04)
: alpha(t.palette.common.black, 0.03),
}}
>
<Table size="small" sx={{ tableLayout: 'fixed' }}>
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 600, color: 'text.secondary', py: 1, width: '25%' }}>
Routing
</TableCell>
<TableCell sx={{ fontWeight: 600, color: 'text.secondary', py: 1, width: '25%' }}>
Margin
</TableCell>
<TableCell sx={{ fontWeight: 600, color: 'text.secondary', py: 1, width: '25%' }}>
NYM cost
</TableCell>
<TableCell sx={{ fontWeight: 600, color: 'text.secondary', py: 1, width: '25%' }}>
Delegated on
</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell sx={{ py: 1.25, verticalAlign: 'top', borderBottom: 'none' }}>
<Typography variant="body2" color="text.primary">
{routingDisplay}
</Typography>
</TableCell>
<TableCell sx={{ py: 1.25, verticalAlign: 'top', borderBottom: 'none' }}>
<Typography variant="body2" color="text.primary">
{marginDisplay}
</Typography>
</TableCell>
<TableCell sx={{ py: 1.25, verticalAlign: 'top', borderBottom: 'none' }}>
<Typography variant="body2" color="text.primary">
{costDisplay}
</Typography>
</TableCell>
<TableCell sx={{ py: 1.25, verticalAlign: 'top', borderBottom: 'none' }}>
<Typography variant="body2" color="text.primary">
{delegatedDisplay}
</Typography>
</TableCell>
</TableRow>
</TableBody>
</Table>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
})
: null}
</TableBody>
</Table>
</TableContainer>
)}
</TableBody>
</Table>
</TableContainer>
</Stack>
</>
);
};
@@ -1,190 +0,0 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { Paper, Button } from '@mui/material';
import { useTheme, Theme } from '@mui/material/styles';
import { Delegations } from './Delegations';
import { items } from './DelegationList.stories';
import { DelegationModal } from './DelegationModal';
import { backDropStyles, modalStyles } from '../../../.storybook/storiesStyles';
const explorerUrl = 'https://sandbox-explorer.nymtech.net';
const storybookStyles = (theme: Theme) => ({
backdropProps: backDropStyles(theme),
sx: modalStyles(theme),
});
export default {
title: 'Delegation/Components/Delegation Modals',
component: Delegations,
} as ComponentMeta<typeof Delegations>;
const transaction = {
url: 'https://sandbox-blocks.nymtech.net/transactions/11ED7B9E21534A9421834F52FED5103DC6E982949C06335F5E12EFC71DAF0CFB',
hash: '11ED7B9E21534A9421834F52FED5103DC6E982949C06335F5E12EFC71DAF0CFB',
};
// Another transaction for Dark Theme to avoid duplicate key errors in rendering
const transactionForDarkTheme = {
url: 'https://sandbox-blocks.nymtech.net/transactions/11ED7B9E21534A9421834F52FED5103DC6E982949C06335F5E12EFC71DAF0CFO',
hash: '11ED7B9E21534A9421834F52FED5103DC6E982949C06335F5E12EFC71DAF0CF0',
};
const Content: FCWithChildren<{ children: React.ReactElement<any, any>; handleClick: () => void }> = ({
children,
handleClick,
}) => (
<>
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
<h2>Your Delegations</h2>
<Button variant="contained" onClick={handleClick} sx={{ mb: 3 }}>
Show modal
</Button>
<Delegations items={items} explorerUrl={explorerUrl} />
</Paper>
{children}
</>
);
export const Loading = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<Content handleClick={handleClick}>
<DelegationModal
open={open}
onClose={() => setOpen(false)}
status="loading"
action="delegate"
{...storybookStyles(theme)}
/>
</Content>
);
};
export const DelegateSuccess = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<Content handleClick={handleClick}>
<DelegationModal
open={open}
onClose={() => setOpen(false)}
status="success"
action="delegate"
message="You delegated 5 NYM"
transactions={theme.palette.mode === 'light' ? [transaction] : [transactionForDarkTheme]}
{...storybookStyles(theme)}
/>
</Content>
);
};
export const UndelegateSuccess = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<Content handleClick={handleClick}>
<DelegationModal
open={open}
onClose={() => setOpen(false)}
status="success"
action="undelegate"
message="You undelegated 5 NYM"
transactions={theme.palette.mode === 'light' ? [transaction] : [transactionForDarkTheme]}
{...storybookStyles(theme)}
/>
</Content>
);
};
export const RedeemSuccess = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<Content handleClick={handleClick}>
<DelegationModal
open={open}
onClose={() => setOpen(false)}
status="success"
action="redeem"
message="42 NYM"
transactions={
theme.palette.mode === 'light'
? [transaction, transaction]
: [transactionForDarkTheme, transactionForDarkTheme]
}
{...storybookStyles(theme)}
/>
</Content>
);
};
export const RedeemWithVestedSuccess = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<Content handleClick={handleClick}>
<DelegationModal
open={open}
onClose={() => setOpen(false)}
status="success"
action="redeem"
message="42 NYM"
transactions={
theme.palette.mode === 'light'
? [transaction, transaction]
: [transactionForDarkTheme, transactionForDarkTheme]
}
{...storybookStyles(theme)}
/>
</Content>
);
};
export const RedeemAllSuccess = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<Content handleClick={handleClick}>
<DelegationModal
open={open}
onClose={() => setOpen(false)}
status="success"
action="redeem-all"
message="42 NYM"
transactions={
theme.palette.mode === 'light'
? [transaction, transaction]
: [transactionForDarkTheme, transactionForDarkTheme]
}
{...storybookStyles(theme)}
/>
</Content>
);
};
export const Error = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<Content handleClick={handleClick}>
<DelegationModal
open={open}
onClose={() => setOpen(false)}
status="error"
action="redeem-all"
message="Minim esse veniam Lorem id velit Lorem eu eu est. Excepteur labore sunt do proident proident sint aliquip consequat Lorem sint non nulla ad excepteur."
transactions={theme.palette.mode === 'light' ? [transaction] : [transactionForDarkTheme]}
{...storybookStyles(theme)}
/>
</Content>
);
};
@@ -1,27 +0,0 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { Paper } from '@mui/material';
import { Delegations } from './Delegations';
import { items } from './DelegationList.stories';
const explorerUrl = 'https://sandbox-explorer.nymtech.net';
export default {
title: 'Delegation/Components/Delegations',
component: Delegations,
} as ComponentMeta<typeof Delegations>;
export const Default = () => (
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
<h2>Your Delegations</h2>
<Delegations items={items} explorerUrl={explorerUrl} />
</Paper>
);
export const Empty = () => (
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
<h2>Your Delegations</h2>
<Delegations items={[]} explorerUrl={explorerUrl} />
</Paper>
);
@@ -6,18 +6,12 @@ import { DelegationList } from './DelegationList';
import { DelegationListItemActions } from './DelegationActions';
export const Delegations: FCWithChildren<{
isLoading?: boolean;
items: DelegationWithEverything[];
explorerUrl: string;
onDelegationItemActionClick?: (item: DelegationWithEverything, action: DelegationListItemActions) => void;
}> = ({ isLoading, items, explorerUrl, onDelegationItemActionClick }) => (
}> = ({ items, explorerUrl, onDelegationItemActionClick }) => (
<>
<DelegationList
isLoading={isLoading}
items={items}
explorerUrl={explorerUrl}
onItemActionClick={onDelegationItemActionClick}
/>
<DelegationList items={items} explorerUrl={explorerUrl} onItemActionClick={onDelegationItemActionClick} />
<Box sx={{ mt: 3 }}>
<Link href={`${explorerUrl}/network-components/mixnodes/`} target="_blank" rel="noreferrer">
Check the{' '}
@@ -1,144 +0,0 @@
import React from 'react';
import { Button, Paper, Typography } from '@mui/material';
import { useTheme, Theme } from '@mui/material/styles';
import { DelegateModal } from './DelegateModal';
import { UndelegateModal } from './UndelegateModal';
import { backDropStyles, modalStyles } from '../../../.storybook/storiesStyles';
const storybookStyles = (theme: Theme) => ({
backdropProps: backDropStyles(theme),
sx: modalStyles(theme),
});
export default {
title: 'Delegation/Components/Action Modals',
};
const Background: FCWithChildren<{ onOpen: () => void }> = ({ onOpen }) => {
const theme = useTheme();
return (
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
<h2>Lorem ipsum</h2>
<Button variant="contained" onClick={onOpen}>
Show modal
</Button>
<Typography sx={{ color: theme.palette.text.primary }}>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis sunt
velit elit do minim mollit non duis reprehenderit. Eiusmod dolore adipisicing ex nostrud consectetur culpa
exercitation do. Ad elit esse ipsum aliqua labore irure laborum qui culpa.
</Typography>
<Typography sx={{ color: theme.palette.text.primary }}>
Occaecat commodo excepteur anim ut officia dolor laboris dolore id occaecat enim qui eiusmod occaecat aliquip ad
tempor. Labore amet laborum magna amet consequat dolor cupidatat in consequat sunt aliquip magna laboris tempor
culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna elit ut
mollit.
</Typography>
<Typography sx={{ color: theme.palette.text.primary }}>
Labore voluptate elit amet ipsum qui officia duis in et occaecat culpa ex do non labore mollit. Cillum cupidatat
duis ea dolore laboris laboris sunt duis anim consectetur cupidatat nulla ad minim sunt ea. Aliqua amet commodo
est irure sint magna sunt. Pariatur dolore commodo labore quis incididunt proident duis voluptate exercitation
in duis. Occaecat aliqua laboris reprehenderit nostrud est aute pariatur fugiat anim. Dolore sunt cillum ea
aliquip consectetur laborum ipsum qui veniam Lorem consectetur adipisicing velit magna aute. Amet tempor quis
excepteur minim culpa velit Lorem enim ad.
</Typography>
<Typography sx={{ color: theme.palette.text.primary }}>
Mollit laborum exercitation excepteur laboris adipisicing ipsum veniam cillum mollit voluptate do. Amet et anim
Lorem mollit minim duis cupidatat non. Consectetur sit deserunt nisi nisi non excepteur dolor eiusmod aute aute
irure anim dolore ipsum et veniam.
</Typography>
</Paper>
);
};
export const Delegate = () => {
const [open, setOpen] = React.useState<boolean>(false);
const theme = useTheme();
return (
<>
<Background onOpen={() => setOpen(true)} />
<DelegateModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
denom="nym"
estimatedReward={50.423}
accountBalance="425.2345053"
nodeUptimePercentage={99.28394}
profitMarginPercentage="11.12334234"
rewardInterval="monthlyish"
hasVestingContract={false}
{...storybookStyles(theme)}
/>
</>
);
};
export const DelegateBelowMinimum = () => {
const [open, setOpen] = React.useState<boolean>(false);
const theme = useTheme();
return (
<>
<Background onOpen={() => setOpen(true)} />
<DelegateModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
denom="nym"
estimatedReward={425.2345053}
nodeUptimePercentage={99.28394}
profitMarginPercentage="11.12334234"
rewardInterval="monthlyish"
initialAmount="0.1"
hasVestingContract={false}
{...storybookStyles(theme)}
/>
</>
);
};
export const DelegateMore = () => {
const [open, setOpen] = React.useState<boolean>(false);
const theme = useTheme();
return (
<>
<Background onOpen={() => setOpen(true)} />
<DelegateModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
header="Delegate more"
buttonText="Delegate more"
denom="nym"
estimatedReward={50.423}
accountBalance="425.2345053"
nodeUptimePercentage={99.28394}
profitMarginPercentage="11.12334234"
rewardInterval="monthlyish"
hasVestingContract={false}
{...storybookStyles(theme)}
/>
</>
);
};
export const Undelegate = () => {
const [open, setOpen] = React.useState<boolean>(false);
const theme = useTheme();
return (
<>
<Background onOpen={() => setOpen(true)} />
<UndelegateModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
currency="nym"
amount={150}
mixId={1234}
identityKey="AA6RfeY8DttMD3CQKoayV6mss5a5FC3RoH75Kmcujyxx"
usesVestingContractTokens={false}
{...storybookStyles(theme)}
/>
</>
);
};
@@ -0,0 +1,46 @@
import React from 'react';
import { Box, Chip, Paper, Stack, Tooltip, Typography } from '@mui/material';
import { WrappedDelegationEvent } from '@nymproject/types';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
export const PendingDelegationCard = ({ item, explorerUrl }: { item: WrappedDelegationEvent; explorerUrl: string }) => (
<Paper
variant="outlined"
sx={{
p: 2,
borderRadius: 3,
bgcolor: (t) => (t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle'),
borderColor: 'divider',
}}
>
<Stack spacing={1.5} direction={{ xs: 'column', sm: 'row' }} alignItems={{ sm: 'center' }} flexWrap="wrap">
<Link
target="_blank"
href={`${explorerUrl}/nodes/${item.event.mix_id}`}
text={`${item.node_identity.slice(0, 6)}...${item.node_identity.slice(-6)}`}
color="text.primary"
noIcon
/>
<Typography variant="body2" color="text.secondary">
{item.event.amount?.amount} {item.event.amount?.denom?.toUpperCase() ?? 'NYM'}
</Typography>
<Box sx={{ flex: 1 }} />
<Tooltip
title={
<Box sx={{ textAlign: 'left' }}>
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.
</Box>
}
arrow
PopperProps={{
sx: {
'& .MuiTooltip-tooltip': { textAlign: 'left' },
},
}}
>
<Chip label="Pending" size="small" color="primary" variant="outlined" />
</Tooltip>
</Stack>
</Paper>
);
@@ -1,49 +0,0 @@
import React from 'react';
import { Box, Chip, TableCell, TableRow, Tooltip } from '@mui/material';
import { WrappedDelegationEvent } from '@nymproject/types';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
export const PendingDelegationItem = ({ item, explorerUrl }: { item: WrappedDelegationEvent; explorerUrl: string }) => (
<TableRow key={item.node_identity}>
<TableCell>
<Link
target="_blank"
href={`${explorerUrl}/nodes/${item.event.mix_id}`}
text={`${item.node_identity.slice(0, 6)}...${item.node_identity.slice(-6)}`}
color="text.primary"
noIcon
/>
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>
<Box sx={{ textAlign: 'left' }}>{item.event.amount?.amount} NYM</Box>
</TableCell>
<TableCell>-</TableCell>
<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>
);
@@ -1,20 +0,0 @@
import * as React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { Box } from '@mui/material';
import { BalanceWarning } from './FeeWarning';
export default {
title: 'Wallet / Balance warning',
component: BalanceWarning,
} as ComponentMeta<typeof BalanceWarning>;
const Template: ComponentStory<typeof BalanceWarning> = (args) => (
<Box mt={2} height={800}>
<BalanceWarning {...args} />
</Box>
);
export const WithWarning = Template.bind({});
WithWarning.args = {
fee: '200',
};
+26 -15
View File
@@ -1,6 +1,6 @@
import React, { FC, useEffect, useRef, useState } from 'react';
import type { UnlistenFn } from '@tauri-apps/api/event';
import { listen } from '@tauri-apps/api/event';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import {
Box,
Paper,
@@ -90,32 +90,43 @@ interface RecordPayload {
export const LogViewer: FC = () => {
const theme = useTheme();
const unlisten = useRef<UnlistenFn>();
const unlisten = useRef<UnlistenFn | null>(null);
const [messages, setMessages] = useState<RecordPayload[]>([]);
const [messageCount, setMessageCount] = useState(0);
const tableRef = useRef<HTMLDivElement>(null);
useEffect(() => {
listen('log://log', (event) => {
const payload = event.payload as RecordPayload;
const payloadWithTimestamp = {
...payload,
timestamp: Date.now(),
};
let cancelled = false;
setMessages((prev) => [payloadWithTimestamp, ...prev]);
setMessageCount((prev) => prev + 1);
const setupListener = async () => {
const unlistenFn = await getCurrentWebview().listen<RecordPayload>('log://log', (event) => {
const { payload } = event;
const payloadWithTimestamp = {
...payload,
timestamp: Date.now(),
};
if (tableRef.current) {
tableRef.current.scrollTop = 0;
setMessages((prev) => [payloadWithTimestamp, ...prev]);
setMessageCount((prev) => prev + 1);
if (tableRef.current) {
tableRef.current.scrollTop = 0;
}
});
if (cancelled) {
unlistenFn();
} else {
unlisten.current = unlistenFn;
}
}).then((fn) => {
unlisten.current = fn;
});
};
setupListener();
return () => {
cancelled = true;
if (unlisten.current) {
unlisten.current();
unlisten.current = null;
}
};
}, []);
@@ -43,7 +43,12 @@ export const EnhancedPasswordInput: React.FC<EnhancedPasswordInputProps> = ({
const input = inputRef.current.querySelector('input');
if (!input) return undefined;
input.setAttribute('autocomplete', 'new-password');
input.setAttribute('data-lpignore', 'true');
input.setAttribute('data-1p-ignore', 'true');
const handleKeyDown = async (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
if (document.activeElement !== input) return;
if ((e.metaKey || e.ctrlKey) && e.key === 'a') {
@@ -101,7 +106,7 @@ export const EnhancedPasswordInput: React.FC<EnhancedPasswordInputProps> = ({
};
return (
<Box position="relative" ref={inputRef}>
<Box position="relative" ref={inputRef} data-nym-auth-paste-field>
<PasswordInput password={password} onUpdatePassword={onUpdatePassword} {...otherProps} />
<Box
sx={{
@@ -46,6 +46,7 @@ export const EnhancedMnemonicInput: React.FC<EnhancedMnemonicInputProps> = ({
// Fix the event type issue by casting Event to KeyboardEvent
const handleKeyDown = async (e: Event) => {
const keyEvent = e as KeyboardEvent;
if (keyEvent.defaultPrevented) return;
if (document.activeElement !== input) return;
if ((keyEvent.metaKey || keyEvent.ctrlKey) && keyEvent.key === 'a') {
@@ -108,7 +109,7 @@ export const EnhancedMnemonicInput: React.FC<EnhancedMnemonicInputProps> = ({
};
return (
<Box position="relative" ref={inputRef}>
<Box position="relative" ref={inputRef} data-nym-auth-paste-field>
<OriginalMnemonicInput mnemonic={mnemonic} onUpdateMnemonic={onUpdateMnemonic} {...otherProps} />
<Box
sx={{
+13 -3
View File
@@ -29,19 +29,29 @@ export const Mnemonic = ({
</Box>
<TextField
label="Mnemonic"
type="input"
value={mnemonic}
multiline
autoFocus={false}
fullWidth
inputProps={{
readOnly: true,
'aria-readonly': true,
style: {
height: '160px',
minHeight: '160px',
alignSelf: 'flex-start',
},
}}
InputLabelProps={{ shrink: true }}
helperText="Read-only - copy or write it down. It cannot be edited here."
FormHelperTextProps={{ sx: { textAlign: 'center' } }}
sx={{
'input::-webkit-textfield-decoration-container': {
'& .MuiInputBase-input': {
cursor: 'text',
caretColor: 'transparent',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
lineHeight: 1.5,
},
'textarea::-webkit-textfield-decoration-container': {
alignItems: 'start',
},
}}
@@ -1,70 +0,0 @@
import React, { useState } from 'react';
import { ErrorOutline } from '@mui/icons-material';
import { useTheme, Theme } from '@mui/material/styles';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { Button } from '@mui/material';
import { ConfirmationModal } from './ConfirmationModal';
import { backDropStyles, dialogStyles } from '../../../.storybook/storiesStyles';
const storybookStyles = (theme: Theme) => ({
backdropProps: backDropStyles(theme),
sx: dialogStyles(theme),
});
export default {
title: 'Modals/ConfirmationModal',
component: ConfirmationModal,
} as ComponentMeta<typeof ConfirmationModal>;
const Template: ComponentStory<typeof ConfirmationModal> = (args) => {
const [open, setOpen] = useState(true);
const theme = useTheme();
return (
<>
<Button variant="outlined" onClick={() => setOpen(true)}>
Open confirmation dialog
</Button>
<ConfirmationModal
{...args}
open={open}
onClose={() => setOpen(false)}
onConfirm={() => setOpen(false)}
{...storybookStyles(theme)}
>
Dialog content.
</ConfirmationModal>
</>
);
};
export const withError: ComponentStory<typeof ConfirmationModal> = () => {
const [open, setOpen] = useState(true);
const theme = useTheme();
return (
<>
<Button variant="outlined" onClick={() => setOpen(true)}>
Open confirmation dialog
</Button>
<ConfirmationModal
title="An error occured"
confirmButton="Done"
open={open}
onClose={() => setOpen(false)}
onConfirm={() => setOpen(false)}
{...storybookStyles(theme)}
>
<ErrorOutline color="error" />
</ConfirmationModal>
</>
);
};
export const Default = Template.bind({});
Default.args = {
title: 'Confirmation Modal',
subTitle: '',
fullWidth: true,
confirmButton: 'Confirm',
maxWidth: 'xs',
disabled: false,
};
@@ -11,22 +11,45 @@ export const ModalListItem: FCWithChildren<{
light?: boolean;
value?: React.ReactNode;
sxValue?: SxProps;
}> = ({ label, value, hidden, fontWeight, fontSize, divider, sxValue }) => (
/** row: label and value on one line; stack: label above value (better for long strings) */
layout?: 'row' | 'stack';
}> = ({ label, value, hidden, fontWeight, fontSize, divider, sxValue, layout = 'row' }) => (
<Box sx={{ display: hidden ? 'none' : 'block' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography fontSize="smaller" fontWeight={fontWeight} sx={{ color: 'text.primary', fontSize: 14 }}>
{label}
</Typography>
{value && (
{layout === 'stack' ? (
<Stack spacing={0.5} alignItems="flex-start">
<Typography
fontSize="smaller"
fontWeight={fontWeight}
sx={{ color: 'text.primary', fontSize: fontSize || 14, ...sxValue }}
fontWeight={fontWeight ?? 600}
sx={{ color: 'text.secondary', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.6 }}
>
{value}
{label}
</Typography>
)}
</Stack>
{value ? (
<Typography
fontSize="smaller"
fontWeight={fontWeight}
sx={{ color: 'text.primary', fontSize: fontSize || 14, width: '100%', wordBreak: 'break-word', ...sxValue }}
>
{value}
</Typography>
) : null}
</Stack>
) : (
<Stack direction="row" justifyContent="space-between" alignItems="center" gap={1}>
<Typography fontSize="smaller" fontWeight={fontWeight} sx={{ color: 'text.primary', fontSize: 14 }}>
{label}
</Typography>
{value ? (
<Typography
fontSize="smaller"
fontWeight={fontWeight}
sx={{ color: 'text.primary', fontSize: fontSize || 14, textAlign: 'right', ...sxValue }}
>
{value}
</Typography>
) : null}
</Stack>
)}
{divider && <ModalDivider />}
</Box>
);
@@ -1,260 +0,0 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { Button, Paper, Typography } from '@mui/material';
import { useTheme, Theme } from '@mui/material/styles';
import { SimpleModal } from './SimpleModal';
import { ModalDivider } from './ModalDivider';
import { backDropStyles, modalStyles } from '../../../.storybook/storiesStyles';
const storybookStyles = (theme: Theme) => ({
backdropProps: backDropStyles(theme),
sx: modalStyles(theme),
});
export default {
title: 'Modals/Simple Modal',
component: SimpleModal,
} as ComponentMeta<typeof SimpleModal>;
const BasePage: FCWithChildren<{ children: React.ReactElement<any, any>; handleClick: () => void }> = ({
children,
handleClick,
}) => (
<>
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
<h2>Lorem ipsum</h2>
<Button variant="contained" onClick={handleClick} sx={{ mb: 3 }}>
Show modal
</Button>
<Typography>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis sunt
velit elit do minim mollit non duis reprehenderit. Eiusmod dolore adipisicing ex nostrud consectetur culpa
exercitation do. Ad elit esse ipsum aliqua labore irure laborum qui culpa.
</Typography>
<Typography>
Occaecat commodo excepteur anim ut officia dolor laboris dolore id occaecat enim qui eiusmod occaecat aliquip ad
tempor. Labore amet laborum magna amet consequat dolor cupidatat in consequat sunt aliquip magna laboris tempor
culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna elit ut
mollit.
</Typography>
<Typography>
Labore voluptate elit amet ipsum qui officia duis in et occaecat culpa ex do non labore mollit. Cillum cupidatat
duis ea dolore laboris laboris sunt duis anim consectetur cupidatat nulla ad minim sunt ea. Aliqua amet commodo
est irure sint magna sunt. Pariatur dolore commodo labore quis incididunt proident duis voluptate exercitation
in duis. Occaecat aliqua laboris reprehenderit nostrud est aute pariatur fugiat anim. Dolore sunt cillum ea
aliquip consectetur laborum ipsum qui veniam Lorem consectetur adipisicing velit magna aute. Amet tempor quis
excepteur minim culpa velit Lorem enim ad.
</Typography>
<Typography>
Mollit laborum exercitation excepteur laboris adipisicing ipsum veniam cillum mollit voluptate do. Amet et anim
Lorem mollit minim duis cupidatat non. Consectetur sit deserunt nisi nisi non excepteur dolor eiusmod aute aute
irure anim dolore ipsum et veniam.
</Typography>
</Paper>
{children}
</>
);
export const Default = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<BasePage handleClick={handleClick}>
<SimpleModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
header="This is a modal"
subHeader="This is a sub header"
okLabel="Click to continue"
{...storybookStyles(theme)}
>
<Typography sx={{ color: theme.palette.text.primary }}>
Lorem mollit minim duis cupidatat non. Consectetur sit deserunt
</Typography>
<Typography sx={{ color: theme.palette.text.primary }}>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis.
</Typography>
<ModalDivider />
<Typography sx={{ color: theme.palette.text.primary }}>
Occaecat commodo excepteur anim ut officia dolor laboris dolore id occaecat enim qui eius
</Typography>
<Typography sx={{ color: theme.palette.text.primary }}>
Tempor culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna
elit ut mollit.
</Typography>
</SimpleModal>
</BasePage>
);
};
export const NoSubheader = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<BasePage handleClick={handleClick}>
<SimpleModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
header="This is a modal"
okLabel="Kaplow!"
{...storybookStyles(theme)}
>
<Typography sx={{ color: theme.palette.text.primary }}>
Tempor culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna
elit ut mollit.
</Typography>
<ModalDivider />
<Typography sx={{ color: theme.palette.text.primary }}>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis.
</Typography>
</SimpleModal>
</BasePage>
);
};
export const hideCloseIcon = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<BasePage handleClick={handleClick}>
<SimpleModal
open={open}
hideCloseIcon
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
header="This is a modal"
okLabel="Kaplow!"
{...storybookStyles(theme)}
>
<Typography sx={{ color: theme.palette.text.primary }}>
Tempor culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna
elit ut mollit.
</Typography>
<ModalDivider />
<Typography sx={{ color: theme.palette.text.primary }}>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis.
</Typography>
</SimpleModal>
</BasePage>
);
};
export const hideCloseIconAndDisplayErrorIcon = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<BasePage handleClick={handleClick}>
<SimpleModal
open={open}
hideCloseIcon
displayErrorIcon
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
header="This modal announces an error !"
okLabel="Kaplow!"
backdropProps={backDropStyles(theme)}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
...modalStyles(theme),
}}
headerStyles={{
width: '100%',
mb: 3,
textAlign: 'center',
color: 'error.main',
}}
subHeaderStyles={{ textAlign: 'center', color: 'text.primary', fontSize: 14, fontWeight: 400 }}
>
<Typography sx={{ color: theme.palette.text.primary }}>
Tempor culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna
elit ut mollit.
</Typography>
<ModalDivider />
<Typography sx={{ color: theme.palette.text.primary }}>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis.
</Typography>
</SimpleModal>
</BasePage>
);
};
export const withBackButton = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<BasePage handleClick={handleClick}>
<SimpleModal
open={open}
hideCloseIcon
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
header="This is a modal"
okLabel="Primary action"
onBack={() => setOpen(false)}
{...storybookStyles(theme)}
>
<Typography sx={{ color: theme.palette.text.primary }}>
Tempor culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna
elit ut mollit.
</Typography>
<ModalDivider />
<Typography sx={{ color: theme.palette.text.primary }}>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis.
</Typography>
</SimpleModal>
</BasePage>
);
};
export const withBackButtonAndCustomLabel = () => {
const [open, setOpen] = React.useState<boolean>(false);
const handleClick = () => setOpen(true);
const theme = useTheme();
return (
<BasePage handleClick={handleClick}>
<SimpleModal
open={open}
hideCloseIcon
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
header="This is a modal"
okLabel="Primary action"
onBack={() => setOpen(false)}
backLabel="Cancel"
backButtonFullWidth
{...storybookStyles(theme)}
>
<Typography sx={{ color: theme.palette.text.primary }}>
Tempor culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna
elit ut mollit.
</Typography>
<ModalDivider />
<Typography sx={{ color: theme.palette.text.primary }}>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis.
</Typography>
</SimpleModal>
</BasePage>
);
};
@@ -1,5 +1,6 @@
import React from 'react';
import { Box, Button, Modal, Stack, SxProps, Typography } from '@mui/material';
import { alpha } from '@mui/material/styles';
import CloseIcon from '@mui/icons-material/Close';
import ErrorOutline from '@mui/icons-material/ErrorOutline';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
@@ -11,9 +12,15 @@ export const SimpleModal: FCWithChildren<{
hideCloseIcon?: boolean;
displayErrorIcon?: boolean;
displayInfoIcon?: boolean;
/** Center the header title; close control stays top-right. */
headerCentered?: boolean;
headerStyles?: SxProps;
subHeaderStyles?: SxProps;
buttonFullWidth?: boolean;
/** Tighter padding and typography */
dense?: boolean;
/** Primary left accent bar */
accent?: 'none' | 'primary';
onClose?: () => void;
onOk?: () => Promise<void>;
onBack?: () => void;
@@ -31,9 +38,12 @@ export const SimpleModal: FCWithChildren<{
hideCloseIcon,
displayErrorIcon,
displayInfoIcon,
headerCentered,
headerStyles,
subHeaderStyles,
buttonFullWidth,
dense,
accent = 'none',
onClose,
okDisabled,
onOk,
@@ -48,37 +58,103 @@ export const SimpleModal: FCWithChildren<{
backdropProps,
}) => (
<Modal open={open} onClose={onClose} BackdropProps={backdropProps}>
<Box sx={{ border: (t) => `1px solid ${t.palette.nym.nymWallet.modal.border}`, ...modalStyle, ...sx }}>
{displayErrorIcon && <ErrorOutline color="error" sx={{ mb: 3 }} />}
<Box
sx={{
border: (t) => `1px solid ${t.palette.nym.nymWallet.modal.border}`,
...modalStyle,
...(dense ? { p: 3, borderRadius: '12px' } : {}),
...(accent === 'primary'
? {
borderLeft: (t) => `4px solid ${t.palette.primary.main}`,
}
: {}),
...sx,
}}
>
{displayErrorIcon && <ErrorOutline color="error" sx={{ mb: dense ? 2 : 3 }} />}
{displayInfoIcon && <InfoOutlinedIcon sx={{ mb: 2, color: (theme) => theme.palette.nym.nymWallet.text.blue }} />}
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Stack
direction="row"
justifyContent={headerCentered ? 'center' : 'space-between'}
alignItems="center"
sx={headerCentered ? { position: 'relative', width: '100%' } : undefined}
>
{typeof header === 'string' ? (
<Typography fontSize={20} fontWeight={600} sx={{ color: 'text.primary', ...headerStyles }}>
<Typography
fontSize={dense ? 18 : 20}
fontWeight={600}
sx={{
color: 'text.primary',
...(headerCentered ? { flex: 1, textAlign: 'center', pr: 4 } : {}),
...headerStyles,
}}
>
{header}
</Typography>
) : (
header
)}
{!hideCloseIcon && <CloseIcon onClick={onClose} cursor="pointer" />}
{!hideCloseIcon && (
<CloseIcon
onClick={onClose}
cursor="pointer"
sx={
headerCentered
? {
position: 'absolute',
right: 0,
top: '50%',
transform: 'translateY(-50%)',
}
: undefined
}
/>
)}
</Stack>
<Typography
mt={subHeader ? 0.5 : 0}
mb={3}
fontSize={12}
color={(theme) => theme.palette.text.secondary}
sx={{ color: (theme) => theme.palette.nym.nymWallet.text.muted, ...subHeaderStyles }}
>
{subHeader}
</Typography>
{subHeader ? (
<Typography
mt={0.5}
mb={dense ? 2 : 3}
fontSize={14}
sx={{
color: 'text.secondary',
lineHeight: 1.45,
...(headerCentered ? { textAlign: 'center' } : {}),
...subHeaderStyles,
}}
>
{subHeader}
</Typography>
) : null}
{children}
{(onOk || onBack) && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 2, width: buttonFullWidth ? '100%' : null }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
mt: dense ? 1.5 : 2,
width: buttonFullWidth ? '100%' : null,
}}
>
{onBack && <StyledBackButton onBack={onBack} label={backLabel} fullWidth={backButtonFullWidth} />}
{onOk && (
<Button variant="contained" fullWidth size="large" onClick={onOk} disabled={okDisabled}>
<Button
variant="contained"
fullWidth
size="large"
onClick={onOk}
disabled={okDisabled}
sx={(theme) => ({
color: theme.palette.primary.contrastText,
'&.Mui-disabled': {
color: alpha(theme.palette.primary.contrastText, 0.55),
},
})}
>
{okLabel}
</Button>
)}
+199 -108
View File
@@ -1,88 +1,89 @@
import React, { useState, useContext } from 'react';
import React, { useContext, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { List, ListItem, ListItemIcon, ListItemText } from '@mui/material';
import {
AccountBalanceWalletOutlined,
ArrowBack,
ArrowForward,
Description,
Settings,
Toll,
} from '@mui/icons-material';
import { Divider, List, ListItemButton, ListItemIcon, ListItemText, Stack, Typography } from '@mui/material';
import type { Theme } from '@mui/material/styles';
import { alpha } from '@mui/material/styles';
import { AccountBalanceWalletOutlined, Description, Settings, VpnKeyOutlined } from '@mui/icons-material';
import { safeOpenUrl } from 'src/utils/safeOpenUrl';
import { AppContext } from '../context/main';
import { Delegate, Bonding } from '../svg-icons';
const activeNavPrimaryColor = (theme: Theme) =>
theme.palette.mode === 'dark' ? theme.palette.common.white : theme.palette.grey[900];
export const Nav = () => {
const location = useLocation();
const navigate = useNavigate();
const { isAdminAddress, handleShowSendModal, handleShowReceiveModal } = useContext(AppContext);
const { isAdminAddress } = useContext(AppContext);
const [routesSchema] = useState([
{
label: 'Balance',
route: '/balance',
Icon: AccountBalanceWalletOutlined,
onClick: () => navigate('/balance'),
},
{
label: 'Send',
Icon: ArrowForward,
onClick: handleShowSendModal,
},
{
label: 'Receive',
Icon: ArrowBack,
onClick: handleShowReceiveModal,
},
{
label: 'Delegation',
route: '/delegation',
Icon: Delegate,
onClick: () => navigate('/delegation'),
},
{
label: 'Bonding',
route: '/bonding',
Icon: Bonding,
onClick: () => navigate('/bonding'),
},
{
label: 'Docs',
route: '/admin',
Icon: Description,
mode: 'dev',
onClick: () => navigate('/docs'),
},
{
label: 'Admin',
route: '/admin',
Icon: Settings,
mode: 'admin',
onClick: () => navigate('/admin'),
},
{
label: 'Buy',
route: '/buy',
Icon: Toll,
onClick: () => navigate('/buy'),
},
]);
const routesSchema = useMemo(
() => [
{
label: 'Balance',
description: 'Portfolio',
route: '/balance',
Icon: AccountBalanceWalletOutlined,
onClick: () => navigate('/balance'),
},
{
label: 'Delegation',
description: 'Stake and manage rewards',
route: '/delegation',
Icon: Delegate,
onClick: () => navigate('/delegation'),
},
{
label: 'Bonding',
description: 'Run operator workflows',
route: '/bonding',
Icon: Bonding,
onClick: () => navigate('/bonding'),
},
{
label: 'Docs',
description: 'Internal wallet notes',
route: '/docs',
Icon: Description,
mode: 'dev',
onClick: () => navigate('/docs'),
},
{
label: 'Admin',
description: 'Network management',
route: '/admin',
Icon: Settings,
mode: 'admin',
onClick: () => navigate('/admin'),
},
],
[navigate],
);
const ecosystemLinks = useMemo(
() => [
{
label: 'NymVPN',
description: 'Plans and subscribe',
href: 'https://nym.com/pricing',
Icon: VpnKeyOutlined,
},
],
[],
);
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
marginLeft: 12,
marginRight: 12,
}}
>
<Stack spacing={1.5}>
<Typography variant="caption" sx={{ color: 'nym.text.muted', textTransform: 'uppercase', letterSpacing: 1 }}>
Navigation
</Typography>
<List
disablePadding
sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
{routesSchema
@@ -99,47 +100,137 @@ export const Nav = () => {
return false;
}
})
.map(({ Icon, onClick, label, route }) => (
<ListItem
disableGutters
key={label}
onClick={onClick}
sx={{
cursor: 'pointer',
py: 2,
paddingLeft: 3.5,
borderRadius: 1,
'&:hover': { backgroundColor: (theme) => theme.palette.nym.nymWallet.hover.background },
}}
>
<ListItemIcon
.map(({ Icon, onClick, label, route, description }) => {
const isActive = route ? location.pathname.startsWith(route) : false;
return (
<ListItemButton
key={label}
onClick={onClick}
sx={{
height: '20px',
minWidth: 30,
color: location.pathname === route ? 'primary.main' : 'text.primary',
}}
>
<Icon
sx={{
fontSize: 20,
}}
/>
</ListItemIcon>
<ListItemText
sx={{
height: '20px',
margin: 0,
color: location.pathname === route ? 'primary.main' : 'text.primary',
'& .MuiListItemText-primary': {
fontSize: 14,
fontWeight: (theme) => (theme.palette.mode === 'light' ? 600 : 500),
px: 2,
py: 2,
borderRadius: 3,
alignItems: 'flex-start',
border: (theme) => {
if (isActive) {
return `1px solid ${theme.palette.primary.main}`;
}
if (theme.palette.mode === 'dark') {
return '1px solid rgba(255,255,255,0.06)';
}
return `1px solid ${alpha(theme.palette.common.black, 0.08)}`;
},
backgroundColor: (theme) => {
if (isActive) {
return `${theme.palette.primary.main}12`;
}
return theme.palette.mode === 'dark'
? 'rgba(255,255,255,0.02)'
: alpha(theme.palette.common.black, 0.02);
},
'&:hover': {
backgroundColor: (theme) =>
isActive ? `${theme.palette.primary.main}18` : theme.palette.nym.nymWallet.hover.background,
},
}}
primary={label}
/>
</ListItem>
))}
>
<ListItemIcon
sx={{
minWidth: 40,
mt: 0.5,
color: isActive ? 'primary.main' : 'text.primary',
}}
>
<Icon
sx={{
fontSize: 20,
color: 'inherit',
}}
/>
</ListItemIcon>
<ListItemText
sx={{
margin: 0,
'& .MuiListItemText-primary': {
fontSize: 14,
fontWeight: 600,
lineHeight: 1.2,
color: (theme) => (isActive ? activeNavPrimaryColor(theme) : theme.palette.text.primary),
},
'& .MuiListItemText-secondary': {
mt: 0.5,
fontSize: 12,
lineHeight: 1.35,
color: 'text.secondary',
},
}}
primary={label}
secondary={description}
/>
</ListItemButton>
);
})}
<Divider sx={{ my: 0.5, borderColor: 'divider' }} />
<Typography variant="caption" sx={{ color: 'nym.text.muted', textTransform: 'uppercase', letterSpacing: 1 }}>
Ecosystem
</Typography>
{ecosystemLinks.map(({ Icon, label, description, href }) => (
<ListItemButton
key={label}
onClick={() => {
safeOpenUrl(href).catch(() => {
/* opener unavailable or user cancelled */
});
}}
sx={{
px: 2,
py: 2,
borderRadius: 3,
alignItems: 'flex-start',
border: (theme) =>
theme.palette.mode === 'dark'
? '1px solid rgba(255,255,255,0.06)'
: `1px solid ${alpha(theme.palette.common.black, 0.08)}`,
backgroundColor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.02)' : alpha(theme.palette.common.black, 0.02),
'&:hover': {
backgroundColor: (theme) => theme.palette.nym.nymWallet.hover.background,
},
}}
>
<ListItemIcon
sx={{
minWidth: 40,
mt: 0.5,
color: 'primary.main',
}}
>
<Icon sx={{ fontSize: 20, color: 'inherit' }} />
</ListItemIcon>
<ListItemText
sx={{
margin: 0,
'& .MuiListItemText-primary': {
fontSize: 14,
fontWeight: 600,
lineHeight: 1.2,
color: 'text.primary',
},
'& .MuiListItemText-secondary': {
mt: 0.5,
fontSize: 12,
lineHeight: 1.35,
color: 'text.secondary',
},
}}
primary={label}
secondary={description}
/>
</ListItemButton>
))}
</List>
</div>
</Stack>
);
};
@@ -1,20 +0,0 @@
import * as React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { Box } from '@mui/material';
import { MockMainContextProvider } from '../context/mocks/main';
import { NetworkSelector } from './NetworkSelector';
export default {
title: 'Wallet / Network Selector',
component: NetworkSelector,
} as ComponentMeta<typeof NetworkSelector>;
const Template: ComponentStory<typeof NetworkSelector> = () => (
<Box mt={2} height={800}>
<MockMainContextProvider>
<NetworkSelector />
</MockMainContextProvider>
</Box>
);
export const Default = Template.bind({});
@@ -4,6 +4,7 @@ import { ArrowDropDown, Check } from '@mui/icons-material';
import { Network } from 'src/types';
import { AppContext } from '../context/main';
import { config } from '../config';
import { headerControlPillSx } from './headerControlPillSx';
const networks: { networkName: Network; name: string }[] = [
{ networkName: 'MAINNET', name: 'Nym Mainnet' },
@@ -59,10 +60,10 @@ export const NetworkSelector = () => {
<Button
variant="text"
color="inherit"
sx={{ color: 'text.primary', fontSize: 14 }}
sx={headerControlPillSx}
onClick={handleClick}
disableElevation
endIcon={<ArrowDropDown sx={{ color: (theme) => `1px solid ${theme.palette.text.primary}` }} />}
endIcon={<ArrowDropDown sx={{ color: 'text.primary' }} />}
>
{networks.find((n) => n.networkName === network)?.name}
</Button>
+36 -17
View File
@@ -11,34 +11,53 @@ const CardContentNoPadding = styled(CardContent)(() => ({
}));
export const NymCard: FCWithChildren<{
title: string | React.ReactElement;
subheader?: string | React.ReactChild;
title?: string | React.ReactElement;
subheader?: React.ReactNode;
Action?: React.ReactNode;
Icon?: React.ReactNode;
noPadding?: boolean;
borderless?: boolean;
/** Omit the card header row (use for fully custom headers inside children). */
hideHeader?: boolean;
dataTestid?: string;
sx?: SxProps;
sxTitle?: SxProps;
children?: React.ReactNode;
}> = ({ title, subheader, Action, Icon, noPadding, borderless, children, dataTestid, sx, sxTitle }) => (
<Card variant="outlined" sx={{ overflow: 'auto', ...(borderless && { border: 'none', dropShadow: 'none' }), ...sx }}>
<CardHeader
sx={{
p: 3,
color: (theme: Theme) => theme.palette.text.primary,
'& .MuiCardHeader-title h5': { fontSize: '1.25rem' },
}}
title={<Title title={title} Icon={Icon} sx={sxTitle} />}
subheader={subheader}
data-testid={dataTestid || title}
subheaderTypographyProps={{ variant: 'subtitle1' }}
action={Action}
/>
}> = ({ title, subheader, Action, Icon, noPadding, borderless, hideHeader, children, dataTestid, sx, sxTitle }) => (
<Card
variant="outlined"
data-testid={hideHeader ? dataTestid : undefined}
sx={{
overflow: 'hidden',
borderRadius: 4,
borderColor: 'divider',
backgroundImage: 'none',
...(borderless && { border: 'none', boxShadow: 'none' }),
...sx,
}}
>
{!hideHeader && title !== undefined && (
<CardHeader
sx={{
p: 3,
color: (theme: Theme) => theme.palette.text.primary,
'& .MuiCardHeader-title h5': { fontSize: '1.25rem' },
'& .MuiCardHeader-action': {
alignSelf: 'center',
m: 0,
},
}}
title={<Title title={title} Icon={Icon} sx={sxTitle} />}
subheader={subheader}
data-testid={dataTestid || (typeof title === 'string' ? title : 'nym-card')}
subheaderTypographyProps={{ variant: 'subtitle1' }}
action={Action}
/>
)}
{noPadding ? (
<CardContentNoPadding>{children}</CardContentNoPadding>
) : (
<CardContent sx={{ p: 3, paddingTop: 0 }}>{children}</CardContent>
<CardContent sx={{ p: 3, paddingTop: hideHeader ? 3 : 0 }}>{children}</CardContent>
)}
</Card>
);
@@ -1,54 +1,48 @@
import React, { useContext } from 'react';
import { AppContext } from 'src/context';
import { Box, Stack, SxProps, Typography, alpha, useTheme } from '@mui/material';
import QRCode from 'qrcode.react';
import { ClientAddress } from '@nymproject/react/client-address/ClientAddress';
import { Box, Stack, Typography, alpha, useTheme } from '@mui/material';
import QrCode2Icon from '@mui/icons-material/QrCode2';
import { CopyToClipboard } from '@nymproject/react/clipboard/CopyToClipboard';
import { QrCodeReact } from 'src/utils/qrCodeReact';
import { SimpleModal } from '../Modals/SimpleModal';
export const ReceiveModal = ({
onClose,
sx,
backdropProps,
}: {
onClose: () => void;
sx?: SxProps;
backdropProps?: object;
}) => {
export const ReceiveModal = ({ onClose }: { onClose: () => void }) => {
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;
const address = clientDetails?.client_address?.trim() ?? '';
return (
<SimpleModal
header="Receive"
header="Receive NYM"
subHeader="Share your address or scan the QR code to receive NYM from another wallet."
headerCentered
open
onClose={onClose}
okLabel=""
sx={{
...sx,
'& .MuiPaper-root': {
overflow: 'hidden',
borderRadius: '20px',
boxShadow: '0 12px 48px rgba(0, 0, 0, 0.15)',
boxShadow: theme.palette.nym.nymWallet.shadows.strong,
maxWidth: '480px',
},
}}
backdropProps={backdropProps}
subHeaderStyles={{ mb: 0, px: 3, pt: 2 }}
subHeaderStyles={{ mb: 0, px: 3, pt: 0.5 }}
>
<Stack
gap={4}
gap={3}
sx={{
position: 'relative',
px: 3,
pb: 3,
pt: 1,
pt: 0,
}}
>
<Box>
<Box sx={{ width: '100%' }}>
<Typography
variant="caption"
sx={{
@@ -56,6 +50,7 @@ export const ReceiveModal = ({
color: 'text.secondary',
fontWeight: 600,
display: 'block',
textAlign: 'center',
}}
>
Your address
@@ -64,23 +59,43 @@ export const ReceiveModal = ({
<Box
sx={{
p: 2,
bgcolor: alpha(theme.palette.primary.main, 0.04),
bgcolor: alpha(theme.palette.primary.main, 0.06),
borderRadius: '12px',
border: `1px solid ${alpha(theme.palette.primary.main, 0.1)}`,
border: `1px solid ${alpha(theme.palette.primary.main, 0.18)}`,
position: 'relative',
}}
>
{clientDetails?.client_address && (
{address ? (
<Box
sx={{
fontSize: '0.9rem',
fontFamily: 'monospace',
letterSpacing: '0.5px',
wordBreak: 'break-all',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 1.5,
width: '100%',
}}
>
<ClientAddress address={clientDetails?.client_address} withCopy showEntireAddress />
<Typography
component="span"
sx={{
flex: 1,
minWidth: 0,
fontSize: '0.9rem',
fontFamily: 'monospace',
letterSpacing: '0.5px',
wordBreak: 'break-all',
color: 'text.primary',
textAlign: 'left',
}}
>
{address}
</Typography>
<CopyToClipboard value={address} sx={{ flexShrink: 0, mt: 0.25 }} />
</Box>
) : (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
No client address available. Sign in again if this persists.
</Typography>
)}
</Box>
</Box>
@@ -92,12 +107,22 @@ export const ReceiveModal = ({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
bgcolor: isLightMode ? alpha(highlightColor, 0.06) : alpha(darkBgColor, 0.7),
bgcolor: isLightMode ? alpha(highlightColor, 0.06) : alpha(theme.palette.background.paper, 0.5),
borderRadius: '16px',
py: 4,
py: 3,
px: 2,
border: `1px solid ${alpha(highlightColor, isLightMode ? 0.2 : 0.12)}`,
}}
>
<Stack direction="row" alignItems="center" gap={1} sx={{ mb: 1.5 }}>
<QrCode2Icon sx={{ color: 'primary.main', fontSize: 22 }} />
<Typography variant="subtitle2" fontWeight={600} color="text.primary">
QR code
</Typography>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ mb: 2, textAlign: 'center', maxWidth: 320 }}>
Share this address only with people you trust.
</Typography>
<Box
sx={{
position: 'relative',
@@ -114,7 +139,7 @@ export const ReceiveModal = ({
width: '100%',
height: '100%',
borderRadius: '16px',
background: `radial-gradient(circle, ${alpha(highlightColor, 0.15)} 0%, transparent 70%)`,
background: `radial-gradient(circle, ${alpha(highlightColor, 0.18)} 0%, transparent 70%)`,
}}
/>
@@ -124,9 +149,9 @@ export const ReceiveModal = ({
justifyContent: 'center',
alignItems: 'center',
p: 3,
bgcolor: isLightMode ? 'white' : alpha(darkBgColor, 0.7),
bgcolor: isLightMode ? 'white' : alpha(darkBgColor, 0.85),
borderRadius: '16px',
border: `2px solid ${isLightMode ? highlightColor : theme.palette.nym.nymWallet.modal.border}`,
border: `2px solid ${isLightMode ? highlightColor : alpha(highlightColor, 0.35)}`,
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': {
@@ -135,22 +160,21 @@ export const ReceiveModal = ({
},
}}
>
{clientDetails && (
<QRCode
{address ? (
<QrCodeReact
renderAs="svg"
data-testid="qr-code"
value={clientDetails?.client_address}
value={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,
}}
/>
) : (
<Typography variant="caption" color="text.secondary">
QR unavailable without an address
</Typography>
)}
</Box>
</Box>
@@ -158,10 +182,11 @@ export const ReceiveModal = ({
<Typography
variant="body2"
sx={{
mt: 3,
mt: 2.5,
color: 'text.secondary',
textAlign: 'center',
maxWidth: '80%',
maxWidth: '90%',
lineHeight: 1.5,
}}
>
Scan this QR code with a compatible wallet to receive NYM tokens
+3 -3
View File
@@ -2,10 +2,10 @@ import React, { useContext } from 'react';
import { AppContext } from 'src/context';
import { ReceiveModal } from './ReceiveModal';
export const Receive = ({ hasStorybookStyles }: { hasStorybookStyles?: {} }) => {
const { showReceiveModal, handleShowReceiveModal } = useContext(AppContext);
export const Receive = () => {
const { showReceiveModal, handleCloseReceiveModal } = useContext(AppContext);
if (showReceiveModal) return <ReceiveModal onClose={handleShowReceiveModal} {...hasStorybookStyles} />;
if (showReceiveModal) return <ReceiveModal onClose={handleCloseReceiveModal} />;
return null;
};
@@ -1,139 +0,0 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { Button, Paper } from '@mui/material';
import { useTheme, Theme } from '@mui/material/styles';
import { RedeemModal } from './RedeemModal';
import { backDropStyles, modalStyles } from '../../../.storybook/storiesStyles';
const storybookStyles = (theme: Theme) => ({
backdropProps: backDropStyles(theme),
sx: modalStyles(theme),
});
export default {
title: 'Rewards/Components/Redeem Modals',
component: RedeemModal,
} as ComponentMeta<typeof RedeemModal>;
const Content: FCWithChildren<{
setOpen: (value: boolean) => void;
}> = ({ setOpen }) => (
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
<h2>Lorem ipsum</h2>
<Button variant="contained" onClick={() => setOpen(true)}>
Show modal
</Button>
<p>
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis sunt
velit elit do minim mollit non duis reprehenderit. Eiusmod dolore adipisicing ex nostrud consectetur culpa
exercitation do. Ad elit esse ipsum aliqua labore irure laborum qui culpa.
</p>
<p>
Occaecat commodo excepteur anim ut officia dolor laboris dolore id occaecat enim qui eiusmod occaecat aliquip ad
tempor. Labore amet laborum magna amet consequat dolor cupidatat in consequat sunt aliquip magna laboris tempor
culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna elit ut
mollit.
</p>
<p>
Labore voluptate elit amet ipsum qui officia duis in et occaecat culpa ex do non labore mollit. Cillum cupidatat
duis ea dolore laboris laboris sunt duis anim consectetur cupidatat nulla ad minim sunt ea. Aliqua amet commodo
est irure sint magna sunt. Pariatur dolore commodo labore quis incididunt proident duis voluptate exercitation in
duis. Occaecat aliqua laboris reprehenderit nostrud est aute pariatur fugiat anim. Dolore sunt cillum ea aliquip
consectetur laborum ipsum qui veniam Lorem consectetur adipisicing velit magna aute. Amet tempor quis excepteur
minim culpa velit Lorem enim ad.
</p>
<p>
Mollit laborum exercitation excepteur laboris adipisicing ipsum veniam cillum mollit voluptate do. Amet et anim
Lorem mollit minim duis cupidatat non. Consectetur sit deserunt nisi nisi non excepteur dolor eiusmod aute aute
irure anim dolore ipsum et veniam.
</p>
</Paper>
);
export const RedeemAllRewards = () => {
const [open, setOpen] = React.useState<boolean>(false);
const theme = useTheme();
return (
<>
<Content setOpen={setOpen} />
<RedeemModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
message="Redeem all rewards"
denom="nym"
mixId={1234}
identityKey="D88RfeY8DttMD3CQKoayV6mss5a5FC3RoH75Kmcujaaa"
amount={425.65843}
{...storybookStyles(theme)}
usesVestingTokens={false}
/>
</>
);
};
export const RedeemRewardForMixnode = () => {
const [open, setOpen] = React.useState<boolean>(false);
const theme = useTheme();
return (
<>
<Content setOpen={setOpen} />
<RedeemModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
message="Claim rewards"
denom="nym"
mixId={1234}
identityKey="D88RfeY8DttMD3CQKoayV6mss5a5FC3RoH75Kmcujaaa"
amount={425.65843}
{...storybookStyles(theme)}
usesVestingTokens={false}
/>
</>
);
};
export const FeeIsMoreThanAllRewards = () => {
const [open, setOpen] = React.useState<boolean>(false);
const theme = useTheme();
return (
<>
<Content setOpen={setOpen} />
<RedeemModal
open={open}
onClose={() => setOpen(false)}
onOk={() => setOpen(false)}
message="Redeem all rewards"
denom="nym"
mixId={1234}
identityKey="D88RfeY8DttMD3CQKoayV6mss5a5FC3RoH75Kmcujaaa"
amount={0.001}
{...storybookStyles(theme)}
usesVestingTokens={false}
/>
</>
);
};
export const FeeIsMoreThanMixnodeReward = () => {
const [open, setOpen] = React.useState<boolean>(false);
const theme = useTheme();
return (
<>
<Content setOpen={setOpen} />
<RedeemModal
open={open}
onClose={() => setOpen(false)}
onOk={async () => setOpen(false)}
mixId={1234}
identityKey="D88RfeY8DttMD3CQKoayV6mss5a5FC3RoH75Kmcujaaa"
message="Claim rewards"
denom="nym"
amount={0.001}
{...storybookStyles(theme)}
usesVestingTokens={false}
/>
</>
);
};

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