initialize Jam V2

Co-authored-by: theborakompanioni <theborakompanioni+github@gmail.com>
This commit is contained in:
Nischal Shetty 2025-06-20 01:19:54 +05:30 committed by GitHub
parent 92bf282aa7
commit ee5ea32379
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
311 changed files with 11146 additions and 52111 deletions

2
.env Normal file
View file

@ -0,0 +1,2 @@
VITE_JM_API_BASE_URL=/api/v1
VITE_JM_API_AUTH_TOKEN_EXPIRY_SECONDS=1800

1
.env.development Normal file
View file

@ -0,0 +1 @@
VITE_JM_API_AUTH_TOKEN_EXPIRY_SECONDS=15

View file

@ -4,7 +4,6 @@ about: Create a report to help us improve
title: ''
labels: 'bug'
assignees: ''
---
Hey there! Thank you for reporting an issue your feedback is invaluable to open-source software development, and all contributions are greatly appreciated.
@ -12,14 +11,14 @@ Hey there! Thank you for reporting an issue your feedback is invaluable to o
🚨 Important warning: Be cautious of scammers in the comments. 🚨
Red flags to watch out for:
- Requests to email "official support" or links to external websites
- Responses from recently created accounts, often lacking profile pictures
- Inquiries for personal information that is not relevant to resolving your issue
As long as you avoid sharing sensitive information (such as your seed phrase), you should be safe.
As long as you avoid sharing sensitive information (such as your seed phrase), you should be safe.
However, it's best to avoid engaging with any suspicious comments.
**Expected behavior**
A clear and concise description of what you expected to happen.
@ -29,17 +28,19 @@ A clear and concise description of what you expected to happen.
A clear and concise description of what the bug is and what actually happens.
**Steps to reproduce the problem**
1.
2.
3.
1.
2.
3.
**Specifications**
- Version:
- Platform:
- Browser:
- Version:
- Platform:
- Browser:
**Additional context**
Add any other context about the problem here to help explain your problem, e.g. error logs or screenshots if applicable.
Add any other context about the problem here to help explain your problem, e.g. error logs or screenshots if applicable.
⚠️ Make sure to remove any sensitive information before sharing screenshots or logs. ⚠️

View file

@ -4,7 +4,6 @@ about: Suggest an idea
title: ''
labels: 'enhancement'
assignees: ''
---
Hey there! Thank you for proposing a new feature your feedback is invaluable to open-source software development, and all contributions are greatly appreciated.
@ -12,14 +11,14 @@ Hey there! Thank you for proposing a new feature your feedback is invaluable
🚨 Important warning: Be cautious of scammers in the comments. 🚨
Red flags to watch out for:
- Requests to email "official support" or links to external websites
- Responses from recently created accounts, often lacking profile pictures
- Inquiries for personal information that is not relevant to resolving your issue
As long as you avoid sharing sensitive information (such as your seed phrase), you should be safe.
As long as you avoid sharing sensitive information (such as your seed phrase), you should be safe.
However, it's best to avoid engaging with any suspicious comments.
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Example: I'm always frustrated when [...]

View file

@ -28,9 +28,22 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
# Install
- name: Install
run: npm ci
- name: Get installed Playwright version
id: playwright-version
run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package-lock.json').dependencies['@playwright/test'].version)")" >> $GITHUB_ENV
- name: Cache Playwright
uses: actions/cache@v3
id: playwright-cache
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
- run: npx playwright install --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
- run: npx playwright install-deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
# Checks
- name: Lint
run: npm run lint

50
.gitignore vendored
View file

@ -1,23 +1,31 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.idea/
node_modules
dist
dist-ssr
*.local
# Environment variables
.env.local
.env.*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*storybook.log
storybook-static

1
.husky/.gitignore vendored
View file

@ -1 +0,0 @@
_

View file

@ -1,2 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged
npx lint-staged && npm test

2
.npmrc
View file

@ -1,2 +0,0 @@
git-tag-version = false
engine-strict = true

1
.nvmrc
View file

@ -1 +0,0 @@
v22.11.0

11
.storybook/main.ts Normal file
View file

@ -0,0 +1,11 @@
import type { StorybookConfig } from '@storybook/react-vite'
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: ['@chromatic-com/storybook', '@storybook/addon-docs', '@storybook/addon-a11y', '@storybook/addon-vitest'],
framework: {
name: '@storybook/react-vite',
options: {},
},
}
export default config

22
.storybook/preview.ts Normal file
View file

@ -0,0 +1,22 @@
import '../src/index.css'
import type { Preview } from '@storybook/react-vite'
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo',
},
},
}
export default preview

View file

@ -0,0 +1,7 @@
import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'
import { setProjectAnnotations } from '@storybook/react-vite'
import * as projectAnnotations from './preview'
// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations])

View file

@ -1,9 +0,0 @@
git:
filters:
- filter_type: file
file_format: KEYVALUEJSON
source_file: src/i18n/locales/en/translation.json
source_language: en
translation_files_expression: src/i18n/locales/<lang>/translation.json
settings:
pr_branch_name: tx_translations_<br_unique_id>

View file

@ -9,363 +9,364 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
#### Added
* ability to select jar when unlocking fidelity bond ([#574](https://github.com/joinmarket-webui/jam/issues/574)) ([#885](https://github.com/joinmarket-webui/jam/issues/885)) ([1f2acd2](https://github.com/joinmarket-webui/jam/commit/1f2acd2322f099b6a21eb30c14764b87e0f1ee45))
* add color-coded checkboxes for UTXO states in Send view ([#878](https://github.com/joinmarket-webui/jam/issues/878)) ([6baf92e](https://github.com/joinmarket-webui/jam/commit/6baf92ecf459e1c4bd6398a28abf3e01bd46c721))
* fast theme toggle in navbar ([#904](https://github.com/joinmarket-webui/jam/issues/904)) ([69f65fa](https://github.com/joinmarket-webui/jam/commit/69f65fa3bd376e10245c3bd9034f43cd2b20fddd))
* orderbook hard refresh ([#906](https://github.com/joinmarket-webui/jam/issues/906)) ([83ef6d2](https://github.com/joinmarket-webui/jam/commit/83ef6d2505b2c35ebe2e364cb92424c74a9fa7d6))
* show progress during rescan ([#894](https://github.com/joinmarket-webui/jam/issues/894)) ([c608c58](https://github.com/joinmarket-webui/jam/commit/c608c583aa0ba53bd470743a09a0005bc12935bf))
- ability to select jar when unlocking fidelity bond ([#574](https://github.com/joinmarket-webui/jam/issues/574)) ([#885](https://github.com/joinmarket-webui/jam/issues/885)) ([1f2acd2](https://github.com/joinmarket-webui/jam/commit/1f2acd2322f099b6a21eb30c14764b87e0f1ee45))
- add color-coded checkboxes for UTXO states in Send view ([#878](https://github.com/joinmarket-webui/jam/issues/878)) ([6baf92e](https://github.com/joinmarket-webui/jam/commit/6baf92ecf459e1c4bd6398a28abf3e01bd46c721))
- fast theme toggle in navbar ([#904](https://github.com/joinmarket-webui/jam/issues/904)) ([69f65fa](https://github.com/joinmarket-webui/jam/commit/69f65fa3bd376e10245c3bd9034f43cd2b20fddd))
- orderbook hard refresh ([#906](https://github.com/joinmarket-webui/jam/issues/906)) ([83ef6d2](https://github.com/joinmarket-webui/jam/commit/83ef6d2505b2c35ebe2e364cb92424c74a9fa7d6))
- show progress during rescan ([#894](https://github.com/joinmarket-webui/jam/issues/894)) ([c608c58](https://github.com/joinmarket-webui/jam/commit/c608c583aa0ba53bd470743a09a0005bc12935bf))
#### Fixed
* relative fee greater than zero ([#862](https://github.com/joinmarket-webui/jam/issues/862)) ([5c3306b](https://github.com/joinmarket-webui/jam/commit/5c3306bd573696989f2e506be64187582fac7ed8))
* **ui:** fidelity bond utxo row color in dark mode ([#898](https://github.com/joinmarket-webui/jam/issues/898)) ([3019285](https://github.com/joinmarket-webui/jam/commit/30192857f81910db1142e2a02f08f3b23dd24136))
* **ui:** fix button alignment in jar selection modal ([#897](https://github.com/joinmarket-webui/jam/issues/897)) ([10ad2a6](https://github.com/joinmarket-webui/jam/commit/10ad2a61d4c18dc4a6d3a6f6fc31d2a88b852e9f))
- relative fee greater than zero ([#862](https://github.com/joinmarket-webui/jam/issues/862)) ([5c3306b](https://github.com/joinmarket-webui/jam/commit/5c3306bd573696989f2e506be64187582fac7ed8))
- **ui:** fidelity bond utxo row color in dark mode ([#898](https://github.com/joinmarket-webui/jam/issues/898)) ([3019285](https://github.com/joinmarket-webui/jam/commit/30192857f81910db1142e2a02f08f3b23dd24136))
- **ui:** fix button alignment in jar selection modal ([#897](https://github.com/joinmarket-webui/jam/issues/897)) ([10ad2a6](https://github.com/joinmarket-webui/jam/commit/10ad2a61d4c18dc4a6d3a6f6fc31d2a88b852e9f))
## [0.3.0](https://github.com/joinmarket-webui/jam/compare/v0.2.0...v0.3.0) (2024-10-01)
#### Added
* **config:** ability to customize "max sweep fee change" setting ([#793](https://github.com/joinmarket-webui/jam/issues/793)) ([b4f8a56](https://github.com/joinmarket-webui/jam/commit/b4f8a56baa5e1f4bbad1cd437a3f08172d9db026))
* display warning on fidelity bond with same expiry date ([#741](https://github.com/joinmarket-webui/jam/issues/741)) ([c04007d](https://github.com/joinmarket-webui/jam/commit/c04007d2968dd587f6fdc0d85798412c2d892f97))
* **earn:** add simple stats to earn report ([#731](https://github.com/joinmarket-webui/jam/issues/731)) ([24a9026](https://github.com/joinmarket-webui/jam/commit/24a9026a24579ba8ba5e99117b58dc6c31c3c3bc))
* **orderbook:** show fidelity bond value and locktime ([#766](https://github.com/joinmarket-webui/jam/issues/766)) ([3bcbdee](https://github.com/joinmarket-webui/jam/commit/3bcbdeed191486047ae58913d9017f58b88245a4))
* **rescan:** ability to rescan timechain ([#838](https://github.com/joinmarket-webui/jam/issues/838)) ([3ec72b1](https://github.com/joinmarket-webui/jam/commit/3ec72b187d78def597399ccb49573bfeae855a9a))
* **ui**: unit toggle on main wallet view ([#806](https://github.com/joinmarket-webui/jam/issues/806)) ([09aa113](https://github.com/joinmarket-webui/jam/commit/09aa1133a6851356cfa10733f46166a743cfeb9e))
* **import**: validate mnemonic phrase against BIP39 wordlist ([#739](https://github.com/joinmarket-webui/jam/issues/739)) ([69e8fa7](https://github.com/joinmarket-webui/jam/commit/69e8fa7f1218fffb83d1c372d39c2264de909b63))
* **send**: quick freeze/unfreeze UTXOs on send page ([#771](https://github.com/joinmarket-webui/jam/issues/771)) ([5c8f81e](https://github.com/joinmarket-webui/jam/commit/5c8f81e92d59b38b83c21739fba0ef7c16505e82))
* **send**: show "selected" UTXOs before performing transaction ([#773](https://github.com/joinmarket-webui/jam/issues/773)) ([96844d9](https://github.com/joinmarket-webui/jam/commit/96844d982f895406dbc23ed329491fe499e9a625))
- **config:** ability to customize "max sweep fee change" setting ([#793](https://github.com/joinmarket-webui/jam/issues/793)) ([b4f8a56](https://github.com/joinmarket-webui/jam/commit/b4f8a56baa5e1f4bbad1cd437a3f08172d9db026))
- display warning on fidelity bond with same expiry date ([#741](https://github.com/joinmarket-webui/jam/issues/741)) ([c04007d](https://github.com/joinmarket-webui/jam/commit/c04007d2968dd587f6fdc0d85798412c2d892f97))
- **earn:** add simple stats to earn report ([#731](https://github.com/joinmarket-webui/jam/issues/731)) ([24a9026](https://github.com/joinmarket-webui/jam/commit/24a9026a24579ba8ba5e99117b58dc6c31c3c3bc))
- **orderbook:** show fidelity bond value and locktime ([#766](https://github.com/joinmarket-webui/jam/issues/766)) ([3bcbdee](https://github.com/joinmarket-webui/jam/commit/3bcbdeed191486047ae58913d9017f58b88245a4))
- **rescan:** ability to rescan timechain ([#838](https://github.com/joinmarket-webui/jam/issues/838)) ([3ec72b1](https://github.com/joinmarket-webui/jam/commit/3ec72b187d78def597399ccb49573bfeae855a9a))
- **ui**: unit toggle on main wallet view ([#806](https://github.com/joinmarket-webui/jam/issues/806)) ([09aa113](https://github.com/joinmarket-webui/jam/commit/09aa1133a6851356cfa10733f46166a743cfeb9e))
- **import**: validate mnemonic phrase against BIP39 wordlist ([#739](https://github.com/joinmarket-webui/jam/issues/739)) ([69e8fa7](https://github.com/joinmarket-webui/jam/commit/69e8fa7f1218fffb83d1c372d39c2264de909b63))
- **send**: quick freeze/unfreeze UTXOs on send page ([#771](https://github.com/joinmarket-webui/jam/issues/771)) ([5c8f81e](https://github.com/joinmarket-webui/jam/commit/5c8f81e92d59b38b83c21739fba0ef7c16505e82))
- **send**: show "selected" UTXOs before performing transaction ([#773](https://github.com/joinmarket-webui/jam/issues/773)) ([96844d9](https://github.com/joinmarket-webui/jam/commit/96844d982f895406dbc23ed329491fe499e9a625))
#### Fixed
* allow absolute maker fee of zero ([#727](https://github.com/joinmarket-webui/jam/issues/727)) ([c04a830](https://github.com/joinmarket-webui/jam/commit/c04a8304dbbce6a2dbfdfbada22317a97815dc3e))
* amount input field to properly interpret BTC ([#800](https://github.com/joinmarket-webui/jam/issues/800)) ([072a419](https://github.com/joinmarket-webui/jam/commit/072a41950d7efab144bab079be3641df8095c985))
* **earn:** validate offer minsize ([#745](https://github.com/joinmarket-webui/jam/issues/745)) ([7aef192](https://github.com/joinmarket-webui/jam/commit/7aef192e72de95c8cc3b20e0a76f369e98208318))
* **fb:** months order across timezones ([#853](https://github.com/joinmarket-webui/jam/issues/853)) ([d2116f5](https://github.com/joinmarket-webui/jam/commit/d2116f5ce703f8e7790e55d6179899cfa925fe14))
* **fb:** display error alert in modal ([#777](https://github.com/joinmarket-webui/jam/pull/776)) ([fef6260](https://github.com/joinmarket-webui/jam/commit/fef6260be03d1fe0bf65e1b6fafb8f10dbf8d2af))
* **i18n:** description values for Chinese translations ([#829](https://github.com/joinmarket-webui/jam/issues/829)) ([ba84d59](https://github.com/joinmarket-webui/jam/commit/ba84d59e84ce6f8b785954eda25636587278fef5))
* **main:** properly fade out jar tooltips ([#848](https://github.com/joinmarket-webui/jam/issues/848)) ([1f6eddf](https://github.com/joinmarket-webui/jam/commit/1f6eddfe1740d267950499c4b9967cd5869b432f))
- allow absolute maker fee of zero ([#727](https://github.com/joinmarket-webui/jam/issues/727)) ([c04a830](https://github.com/joinmarket-webui/jam/commit/c04a8304dbbce6a2dbfdfbada22317a97815dc3e))
- amount input field to properly interpret BTC ([#800](https://github.com/joinmarket-webui/jam/issues/800)) ([072a419](https://github.com/joinmarket-webui/jam/commit/072a41950d7efab144bab079be3641df8095c985))
- **earn:** validate offer minsize ([#745](https://github.com/joinmarket-webui/jam/issues/745)) ([7aef192](https://github.com/joinmarket-webui/jam/commit/7aef192e72de95c8cc3b20e0a76f369e98208318))
- **fb:** months order across timezones ([#853](https://github.com/joinmarket-webui/jam/issues/853)) ([d2116f5](https://github.com/joinmarket-webui/jam/commit/d2116f5ce703f8e7790e55d6179899cfa925fe14))
- **fb:** display error alert in modal ([#777](https://github.com/joinmarket-webui/jam/pull/776)) ([fef6260](https://github.com/joinmarket-webui/jam/commit/fef6260be03d1fe0bf65e1b6fafb8f10dbf8d2af))
- **i18n:** description values for Chinese translations ([#829](https://github.com/joinmarket-webui/jam/issues/829)) ([ba84d59](https://github.com/joinmarket-webui/jam/commit/ba84d59e84ce6f8b785954eda25636587278fef5))
- **main:** properly fade out jar tooltips ([#848](https://github.com/joinmarket-webui/jam/issues/848)) ([1f6eddf](https://github.com/joinmarket-webui/jam/commit/1f6eddfe1740d267950499c4b9967cd5869b432f))
## [0.2.0](https://github.com/joinmarket-webui/jam/compare/v0.1.6...v0.2.0) (2024-02-24)
#### Fixed
* **fee-randomization:** fix fee range in PaymentConfirmModal ([#655](https://github.com/joinmarket-webui/jam/issues/655)) ([31f54c8](https://github.com/joinmarket-webui/jam/commit/31f54c8cb8bf1474406328be7c5a26b5b5263a44))
* selectable ui elements ([#714](https://github.com/joinmarket-webui/jam/issues/714)) ([378daf5](https://github.com/joinmarket-webui/jam/commit/378daf5c936b56a401deeefb9d847bf2ed14f7c7))
* show warning on missing fee config values ([#674](https://github.com/joinmarket-webui/jam/issues/674)) ([5900a8c](https://github.com/joinmarket-webui/jam/commit/5900a8c6209aac9d1113186616f72efe023fa3c4))
* **ui:** adapt payment confirm size ([#712](https://github.com/joinmarket-webui/jam/issues/712)) ([11e2c2a](https://github.com/joinmarket-webui/jam/commit/11e2c2a88c8dc95bd47e64b0f315213ddd355ca6))
* use orderbook.json instead of parsing html table ([#687](https://github.com/joinmarket-webui/jam/issues/687)) ([df81804](https://github.com/joinmarket-webui/jam/commit/df8180432be414840b71ea970eef42316c085941))
- **fee-randomization:** fix fee range in PaymentConfirmModal ([#655](https://github.com/joinmarket-webui/jam/issues/655)) ([31f54c8](https://github.com/joinmarket-webui/jam/commit/31f54c8cb8bf1474406328be7c5a26b5b5263a44))
- selectable ui elements ([#714](https://github.com/joinmarket-webui/jam/issues/714)) ([378daf5](https://github.com/joinmarket-webui/jam/commit/378daf5c936b56a401deeefb9d847bf2ed14f7c7))
- show warning on missing fee config values ([#674](https://github.com/joinmarket-webui/jam/issues/674)) ([5900a8c](https://github.com/joinmarket-webui/jam/commit/5900a8c6209aac9d1113186616f72efe023fa3c4))
- **ui:** adapt payment confirm size ([#712](https://github.com/joinmarket-webui/jam/issues/712)) ([11e2c2a](https://github.com/joinmarket-webui/jam/commit/11e2c2a88c8dc95bd47e64b0f315213ddd355ca6))
- use orderbook.json instead of parsing html table ([#687](https://github.com/joinmarket-webui/jam/issues/687)) ([df81804](https://github.com/joinmarket-webui/jam/commit/df8180432be414840b71ea970eef42316c085941))
#### Added
* align amount input fields ([#711](https://github.com/joinmarket-webui/jam/issues/711)) ([7006e57](https://github.com/joinmarket-webui/jam/commit/7006e575f48c62d761dafe08e0fc2317e2e6c1b4))
* check for existing wallet ([#720](https://github.com/joinmarket-webui/jam/issues/720)) ([ac383dc](https://github.com/joinmarket-webui/jam/commit/ac383dcdd8f3bee4e63e3236537c5e945c2edd69))
* custom tx fee on direct and collaborative send ([#706](https://github.com/joinmarket-webui/jam/issues/706)) ([dc95e64](https://github.com/joinmarket-webui/jam/commit/dc95e646e7095d0ae8e0a312830455fc81e1a6bc))
* display ui/backend version ([#668](https://github.com/joinmarket-webui/jam/issues/668)) ([69e61e0](https://github.com/joinmarket-webui/jam/commit/69e61e06c135549c1812f20456c9cbbd46853e2b))
* renew fidelity bond ([#678](https://github.com/joinmarket-webui/jam/issues/678)) ([b4948ef](https://github.com/joinmarket-webui/jam/commit/b4948ef9f013c6e127251d85def0096e9d4f21f2))
* **ui:** autofocus next input when confirming seed phrase backup ([#718](https://github.com/joinmarket-webui/jam/issues/718)) ([27e687c](https://github.com/joinmarket-webui/jam/commit/27e687c9cf34fdd1f92b8c675c6579b4759f9389))
- align amount input fields ([#711](https://github.com/joinmarket-webui/jam/issues/711)) ([7006e57](https://github.com/joinmarket-webui/jam/commit/7006e575f48c62d761dafe08e0fc2317e2e6c1b4))
- check for existing wallet ([#720](https://github.com/joinmarket-webui/jam/issues/720)) ([ac383dc](https://github.com/joinmarket-webui/jam/commit/ac383dcdd8f3bee4e63e3236537c5e945c2edd69))
- custom tx fee on direct and collaborative send ([#706](https://github.com/joinmarket-webui/jam/issues/706)) ([dc95e64](https://github.com/joinmarket-webui/jam/commit/dc95e646e7095d0ae8e0a312830455fc81e1a6bc))
- display ui/backend version ([#668](https://github.com/joinmarket-webui/jam/issues/668)) ([69e61e0](https://github.com/joinmarket-webui/jam/commit/69e61e06c135549c1812f20456c9cbbd46853e2b))
- renew fidelity bond ([#678](https://github.com/joinmarket-webui/jam/issues/678)) ([b4948ef](https://github.com/joinmarket-webui/jam/commit/b4948ef9f013c6e127251d85def0096e9d4f21f2))
- **ui:** autofocus next input when confirming seed phrase backup ([#718](https://github.com/joinmarket-webui/jam/issues/718)) ([27e687c](https://github.com/joinmarket-webui/jam/commit/27e687c9cf34fdd1f92b8c675c6579b4759f9389))
### [0.1.6](https://github.com/joinmarket-webui/jam/compare/v0.1.5...v0.1.6) (2023-09-22)
#### Fixed
* typo on orderbook page ([#661](https://github.com/joinmarket-webui/jam/issues/661)) ([a0fa434](https://github.com/joinmarket-webui/jam/commit/a0fa434eabea269e87b1e1786e9144a07d893269))
* **ui:** consistent button styles ([#656](https://github.com/joinmarket-webui/jam/issues/656)) ([a559e1e](https://github.com/joinmarket-webui/jam/commit/a559e1e92c9e245a48f1e698c109b8f039e9b443))
- typo on orderbook page ([#661](https://github.com/joinmarket-webui/jam/issues/661)) ([a0fa434](https://github.com/joinmarket-webui/jam/commit/a0fa434eabea269e87b1e1786e9144a07d893269))
- **ui:** consistent button styles ([#656](https://github.com/joinmarket-webui/jam/issues/656)) ([a559e1e](https://github.com/joinmarket-webui/jam/commit/a559e1e92c9e245a48f1e698c109b8f039e9b443))
#### Added
* backend version based feature toggles ([#647](https://github.com/joinmarket-webui/jam/issues/647)) ([6b45718](https://github.com/joinmarket-webui/jam/commit/6b457187593a37d34faf23e6d4898cb2a7fb59b7))
* **balance:** display frozen balance on jars ([#635](https://github.com/joinmarket-webui/jam/issues/635)) ([b029c92](https://github.com/joinmarket-webui/jam/commit/b029c923823e6f9f441289f61b39c2d83b5bb983))
* **i18n:** add Chinese translation (zh-Hans and zh-Hant) ([#628](https://github.com/joinmarket-webui/jam/issues/628)) ([550a435](https://github.com/joinmarket-webui/jam/commit/550a43588b1b003a52a4ed3ba00b4623b9e7dd1d))
* **i18n:** add Italian translation (it) ([#627](https://github.com/joinmarket-webui/jam/issues/627)) ([068042f](https://github.com/joinmarket-webui/jam/commit/068042fceb5ff192b99bbf479bd1a225d52d1b61))
* **i18n:** adding translation in Brazilian Portuguese (pt-BR) ([#615](https://github.com/joinmarket-webui/jam/issues/615)) ([4d9171e](https://github.com/joinmarket-webui/jam/commit/4d9171e15fe45100dd26db621576739af9b7e012))
* **i18n:** update german translation (de) ([#659](https://github.com/joinmarket-webui/jam/issues/659)) ([8b54f28](https://github.com/joinmarket-webui/jam/commit/8b54f28644fc04c58716102d3bcc6583e32b104a))
* **i18n:** update to Chinese translations ([#660](https://github.com/joinmarket-webui/jam/issues/660)) ([c40938a](https://github.com/joinmarket-webui/jam/commit/c40938a2fab147695a03ef58ff0692bdd9fc1354))
* import wallet ([#621](https://github.com/joinmarket-webui/jam/issues/621)) ([028f321](https://github.com/joinmarket-webui/jam/commit/028f32166a21778796dd10e2d1d7fbce5e07ed8f))
* **Send:** Fee breakdown table ([#606](https://github.com/joinmarket-webui/jam/issues/606)) ([7a6b920](https://github.com/joinmarket-webui/jam/commit/7a6b920fd2f145b9e47f29ef0f27a2786bb87525))
- backend version based feature toggles ([#647](https://github.com/joinmarket-webui/jam/issues/647)) ([6b45718](https://github.com/joinmarket-webui/jam/commit/6b457187593a37d34faf23e6d4898cb2a7fb59b7))
- **balance:** display frozen balance on jars ([#635](https://github.com/joinmarket-webui/jam/issues/635)) ([b029c92](https://github.com/joinmarket-webui/jam/commit/b029c923823e6f9f441289f61b39c2d83b5bb983))
- **i18n:** add Chinese translation (zh-Hans and zh-Hant) ([#628](https://github.com/joinmarket-webui/jam/issues/628)) ([550a435](https://github.com/joinmarket-webui/jam/commit/550a43588b1b003a52a4ed3ba00b4623b9e7dd1d))
- **i18n:** add Italian translation (it) ([#627](https://github.com/joinmarket-webui/jam/issues/627)) ([068042f](https://github.com/joinmarket-webui/jam/commit/068042fceb5ff192b99bbf479bd1a225d52d1b61))
- **i18n:** adding translation in Brazilian Portuguese (pt-BR) ([#615](https://github.com/joinmarket-webui/jam/issues/615)) ([4d9171e](https://github.com/joinmarket-webui/jam/commit/4d9171e15fe45100dd26db621576739af9b7e012))
- **i18n:** update german translation (de) ([#659](https://github.com/joinmarket-webui/jam/issues/659)) ([8b54f28](https://github.com/joinmarket-webui/jam/commit/8b54f28644fc04c58716102d3bcc6583e32b104a))
- **i18n:** update to Chinese translations ([#660](https://github.com/joinmarket-webui/jam/issues/660)) ([c40938a](https://github.com/joinmarket-webui/jam/commit/c40938a2fab147695a03ef58ff0692bdd9fc1354))
- import wallet ([#621](https://github.com/joinmarket-webui/jam/issues/621)) ([028f321](https://github.com/joinmarket-webui/jam/commit/028f32166a21778796dd10e2d1d7fbce5e07ed8f))
- **Send:** Fee breakdown table ([#606](https://github.com/joinmarket-webui/jam/issues/606)) ([7a6b920](https://github.com/joinmarket-webui/jam/commit/7a6b920fd2f145b9e47f29ef0f27a2786bb87525))
### [0.1.5](https://github.com/joinmarket-webui/jam/compare/v0.1.4...v0.1.5) (2023-02-08)
#### Fixed
* construct dates with timestamp to please safari ([#584](https://github.com/joinmarket-webui/jam/issues/584)) ([9ebe168](https://github.com/joinmarket-webui/jam/commit/9ebe168b5dced4553bae942a8d8836e9b103c815))
* **fb:** correctly display success screen after unlocking fb ([#601](https://github.com/joinmarket-webui/jam/issues/601)) ([0eb6649](https://github.com/joinmarket-webui/jam/commit/0eb6649b7a21ab03824e562edec92d3080cf8e16))
* **fee:** allow input for relative fee limit of 0.0001% ([#603](https://github.com/joinmarket-webui/jam/issues/603)) ([1c87a6d](https://github.com/joinmarket-webui/jam/commit/1c87a6de06db3974a8f0e307329fc3f13dd69954))
* **fees:** allow min tx fee of 1sat/vbyte ([#604](https://github.com/joinmarket-webui/jam/issues/604)) ([4121c9b](https://github.com/joinmarket-webui/jam/commit/4121c9bd4510cef9af5345fd921d040933b915e9))
* install python3-venv in regtest environment ([#585](https://github.com/joinmarket-webui/jam/issues/585)) ([d9b0415](https://github.com/joinmarket-webui/jam/commit/d9b0415dffb397c9d602b42408916ce8daeef63c))
- construct dates with timestamp to please safari ([#584](https://github.com/joinmarket-webui/jam/issues/584)) ([9ebe168](https://github.com/joinmarket-webui/jam/commit/9ebe168b5dced4553bae942a8d8836e9b103c815))
- **fb:** correctly display success screen after unlocking fb ([#601](https://github.com/joinmarket-webui/jam/issues/601)) ([0eb6649](https://github.com/joinmarket-webui/jam/commit/0eb6649b7a21ab03824e562edec92d3080cf8e16))
- **fee:** allow input for relative fee limit of 0.0001% ([#603](https://github.com/joinmarket-webui/jam/issues/603)) ([1c87a6d](https://github.com/joinmarket-webui/jam/commit/1c87a6de06db3974a8f0e307329fc3f13dd69954))
- **fees:** allow min tx fee of 1sat/vbyte ([#604](https://github.com/joinmarket-webui/jam/issues/604)) ([4121c9b](https://github.com/joinmarket-webui/jam/commit/4121c9bd4510cef9af5345fd921d040933b915e9))
- install python3-venv in regtest environment ([#585](https://github.com/joinmarket-webui/jam/issues/585)) ([d9b0415](https://github.com/joinmarket-webui/jam/commit/d9b0415dffb397c9d602b42408916ce8daeef63c))
#### Added
* add dedicated error page ([#586](https://github.com/joinmarket-webui/jam/issues/586)) ([42c9f5d](https://github.com/joinmarket-webui/jam/commit/42c9f5d773a74a41e4f32c145c3512d7d9405d6d))
* **Jam:** add success message ([#599](https://github.com/joinmarket-webui/jam/issues/599)) ([531adb6](https://github.com/joinmarket-webui/jam/commit/531adb6ffa82183534c7e8f08c7a87d05adad59c))
* quick freeze/unfreeze utxos ([#591](https://github.com/joinmarket-webui/jam/issues/591)) ([f3f6e84](https://github.com/joinmarket-webui/jam/commit/f3f6e847abfe43db6ae6d63656d15b81122b66e2))
* Show jar total amount in detail view ([#551](https://github.com/joinmarket-webui/jam/issues/551)) ([90f22e5](https://github.com/joinmarket-webui/jam/commit/90f22e59eb317ae7280357f0c00455ae9c46bf68))
- add dedicated error page ([#586](https://github.com/joinmarket-webui/jam/issues/586)) ([42c9f5d](https://github.com/joinmarket-webui/jam/commit/42c9f5d773a74a41e4f32c145c3512d7d9405d6d))
- **Jam:** add success message ([#599](https://github.com/joinmarket-webui/jam/issues/599)) ([531adb6](https://github.com/joinmarket-webui/jam/commit/531adb6ffa82183534c7e8f08c7a87d05adad59c))
- quick freeze/unfreeze utxos ([#591](https://github.com/joinmarket-webui/jam/issues/591)) ([f3f6e84](https://github.com/joinmarket-webui/jam/commit/f3f6e847abfe43db6ae6d63656d15b81122b66e2))
- Show jar total amount in detail view ([#551](https://github.com/joinmarket-webui/jam/issues/551)) ([90f22e5](https://github.com/joinmarket-webui/jam/commit/90f22e59eb317ae7280357f0c00455ae9c46bf68))
### [0.1.4](https://github.com/joinmarket-webui/jam/compare/v0.1.3...v0.1.4) (2022-12-13)
#### Fixed
* **send:** parse number of collaborators as integer ([#572](https://github.com/joinmarket-webui/jam/issues/572)) ([4fca8f1](https://github.com/joinmarket-webui/jam/commit/4fca8f1fd1ba5d9e9f7f1289c9db35c1f03aaf34))
* **performance:** speed up initial page load ([#566](https://github.com/joinmarket-webui/jam/pull/566)) ([e2bad18](https://github.com/joinmarket-webui/jam/commit/e2bad188f116b63bf68b308106bba33ac8fc7164))
- **send:** parse number of collaborators as integer ([#572](https://github.com/joinmarket-webui/jam/issues/572)) ([4fca8f1](https://github.com/joinmarket-webui/jam/commit/4fca8f1fd1ba5d9e9f7f1289c9db35c1f03aaf34))
- **performance:** speed up initial page load ([#566](https://github.com/joinmarket-webui/jam/pull/566)) ([e2bad18](https://github.com/joinmarket-webui/jam/commit/e2bad188f116b63bf68b308106bba33ac8fc7164))
#### Added
* Settings subsections ([#573](https://github.com/joinmarket-webui/jam/issues/573)) ([495dc2b](https://github.com/joinmarket-webui/jam/commit/495dc2b169ddccedb0adff3f6b9f5caf03873cbf)), closes [#524](https://github.com/joinmarket-webui/jam/issues/524)
* spend fidelity bond ([#556](https://github.com/joinmarket-webui/jam/issues/556)) ([9f42dac](https://github.com/joinmarket-webui/jam/commit/9f42dac19ad1897c51be535636ff64b8d2bbb125))
- Settings subsections ([#573](https://github.com/joinmarket-webui/jam/issues/573)) ([495dc2b](https://github.com/joinmarket-webui/jam/commit/495dc2b169ddccedb0adff3f6b9f5caf03873cbf)), closes [#524](https://github.com/joinmarket-webui/jam/issues/524)
- spend fidelity bond ([#556](https://github.com/joinmarket-webui/jam/issues/556)) ([9f42dac](https://github.com/joinmarket-webui/jam/commit/9f42dac19ad1897c51be535636ff64b8d2bbb125))
### [0.1.3](https://github.com/joinmarket-webui/jam/compare/v0.1.2...v0.1.3) (2022-11-10)
#### Fixed
* **docker**: wait for bitcoind to accept RPC calls ([#559](https://github.com/joinmarket-webui/jam/pull/559)) ([6e2ee47](https://github.com/joinmarket-webui/jam/commit/6e2ee47538fe225f7b84eb2de245993a90dfd042))
* **pagination:** colors of option element in dark mode ([#554](https://github.com/joinmarket-webui/jam/issues/554)) ([86dc2c5](https://github.com/joinmarket-webui/jam/commit/86dc2c5d545037e2a1a593049ac6b96e31910d07))
- **docker**: wait for bitcoind to accept RPC calls ([#559](https://github.com/joinmarket-webui/jam/pull/559)) ([6e2ee47](https://github.com/joinmarket-webui/jam/commit/6e2ee47538fe225f7b84eb2de245993a90dfd042))
- **pagination:** colors of option element in dark mode ([#554](https://github.com/joinmarket-webui/jam/issues/554)) ([86dc2c5](https://github.com/joinmarket-webui/jam/commit/86dc2c5d545037e2a1a593049ac6b96e31910d07))
#### Added
* quickly review/adapt fee settings before sweeping ([#565](https://github.com/joinmarket-webui/jam/issues/565)) ([0d4dd0d](https://github.com/joinmarket-webui/jam/commit/0d4dd0d4d550a7052b6958326cd70adebea6cd60))
* **orderbook:** improve readability with alternating colors ([#563](https://github.com/joinmarket-webui/jam/pull/563)) ([691faf7](https://github.com/joinmarket-webui/jam/pull/563))
- quickly review/adapt fee settings before sweeping ([#565](https://github.com/joinmarket-webui/jam/issues/565)) ([0d4dd0d](https://github.com/joinmarket-webui/jam/commit/0d4dd0d4d550a7052b6958326cd70adebea6cd60))
- **orderbook:** improve readability with alternating colors ([#563](https://github.com/joinmarket-webui/jam/pull/563)) ([691faf7](https://github.com/joinmarket-webui/jam/pull/563))
### [0.1.2](https://github.com/joinmarket-webui/jam/compare/v0.1.1...v0.1.2) (2022-10-28)
#### Fixed
* display error message if backend is unreachable ([#540](https://github.com/joinmarket-webui/jam/issues/540)) ([f2e346e](https://github.com/joinmarket-webui/jam/commit/f2e346e87296c53c5dee89aa4610cfeed39065b8))
* do not enable debug features on `npm start` ([#549](https://github.com/joinmarket-webui/jam/issues/549)) ([fda77c2](https://github.com/joinmarket-webui/jam/commit/fda77c202d32f32b5ca39a60f983748038cc0cf5))
- display error message if backend is unreachable ([#540](https://github.com/joinmarket-webui/jam/issues/540)) ([f2e346e](https://github.com/joinmarket-webui/jam/commit/f2e346e87296c53c5dee89aa4610cfeed39065b8))
- do not enable debug features on `npm start` ([#549](https://github.com/joinmarket-webui/jam/issues/549)) ([fda77c2](https://github.com/joinmarket-webui/jam/commit/fda77c202d32f32b5ca39a60f983748038cc0cf5))
### [0.1.1](https://github.com/joinmarket-webui/jam/compare/v0.1.0...v0.1.1) (2022-10-07)
#### Added
* basic fee settings ([#522](https://github.com/joinmarket-webui/jam/issues/522)) ([54dd396](https://github.com/joinmarket-webui/jam/commit/54dd3969d954112af46d746e7fcc6e78db2ef32f))
* display fee settings on Send page ([#532](https://github.com/joinmarket-webui/jam/issues/532)) ([26f911a](https://github.com/joinmarket-webui/jam/commit/26f911aea80f472d6f9484a1187e27f7dffe1ec2))
* **jar:** show sum of selected utxos ([#514](https://github.com/joinmarket-webui/jam/issues/514)) ([85d131c](https://github.com/joinmarket-webui/jam/commit/85d131cf75ba614245de450492bdde1905a6b51c))
* **send:** show txid on successful direct-send ([#510](https://github.com/joinmarket-webui/jam/issues/510)) ([13496a0](https://github.com/joinmarket-webui/jam/commit/13496a0a066bebb6f8d8b4ef72e25a00f7f347ac))
- basic fee settings ([#522](https://github.com/joinmarket-webui/jam/issues/522)) ([54dd396](https://github.com/joinmarket-webui/jam/commit/54dd3969d954112af46d746e7fcc6e78db2ef32f))
- display fee settings on Send page ([#532](https://github.com/joinmarket-webui/jam/issues/532)) ([26f911a](https://github.com/joinmarket-webui/jam/commit/26f911aea80f472d6f9484a1187e27f7dffe1ec2))
- **jar:** show sum of selected utxos ([#514](https://github.com/joinmarket-webui/jam/issues/514)) ([85d131c](https://github.com/joinmarket-webui/jam/commit/85d131cf75ba614245de450492bdde1905a6b51c))
- **send:** show txid on successful direct-send ([#510](https://github.com/joinmarket-webui/jam/issues/510)) ([13496a0](https://github.com/joinmarket-webui/jam/commit/13496a0a066bebb6f8d8b4ef72e25a00f7f347ac))
#### Fixed
* **fees:** mitigate construction of non-forwardable transactions ([#536](https://github.com/joinmarket-webui/jam/issues/536)) ([f2f3944](https://github.com/joinmarket-webui/jam/commit/f2f39444ccd59d4a5a440286a425d1b1fd7c238d))
* **navbar:** send before earn ([#507](https://github.com/joinmarket-webui/jam/issues/507)) ([c1fb2bc](https://github.com/joinmarket-webui/jam/commit/c1fb2bc68008980087581d4a0d845624f6816b85))
* **readme:** link to development heading ([0351367](https://github.com/joinmarket-webui/jam/commit/0351367e2971b9717221fa705953d444ff37c587))
* **settings:** consistent case ([#511](https://github.com/joinmarket-webui/jam/issues/511)) ([e4bd89c](https://github.com/joinmarket-webui/jam/commit/e4bd89c648ea27cd9fb4dd996248eacff0172586))
* **sweep:** reload wallet info after scheduled sweep ([#530](https://github.com/joinmarket-webui/jam/issues/530)) ([0757280](https://github.com/joinmarket-webui/jam/commit/0757280bf1d1e3b46fdf50c6bcd6268ae379714d))
* **sweep:** wait for scheduler start/stop ([#529](https://github.com/joinmarket-webui/jam/issues/529)) ([509a15e](https://github.com/joinmarket-webui/jam/commit/509a15e90a5dc58971256eab9cf7d8be1fe796ad))
- **fees:** mitigate construction of non-forwardable transactions ([#536](https://github.com/joinmarket-webui/jam/issues/536)) ([f2f3944](https://github.com/joinmarket-webui/jam/commit/f2f39444ccd59d4a5a440286a425d1b1fd7c238d))
- **navbar:** send before earn ([#507](https://github.com/joinmarket-webui/jam/issues/507)) ([c1fb2bc](https://github.com/joinmarket-webui/jam/commit/c1fb2bc68008980087581d4a0d845624f6816b85))
- **readme:** link to development heading ([0351367](https://github.com/joinmarket-webui/jam/commit/0351367e2971b9717221fa705953d444ff37c587))
- **settings:** consistent case ([#511](https://github.com/joinmarket-webui/jam/issues/511)) ([e4bd89c](https://github.com/joinmarket-webui/jam/commit/e4bd89c648ea27cd9fb4dd996248eacff0172586))
- **sweep:** reload wallet info after scheduled sweep ([#530](https://github.com/joinmarket-webui/jam/issues/530)) ([0757280](https://github.com/joinmarket-webui/jam/commit/0757280bf1d1e3b46fdf50c6bcd6268ae379714d))
- **sweep:** wait for scheduler start/stop ([#529](https://github.com/joinmarket-webui/jam/issues/529)) ([509a15e](https://github.com/joinmarket-webui/jam/commit/509a15e90a5dc58971256eab9cf7d8be1fe796ad))
## [0.1.0](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.10...v0.1.0) (2022-09-16)
#### Fixed
* create non-descriptor wallet ([#487](https://github.com/joinmarket-webui/joinmarket-webui/issues/487)) ([0d70415](https://github.com/joinmarket-webui/joinmarket-webui/commit/0d704158f4b41f1a1dc0adef058c13f6d6932190))
* pass api token to session request ([#456](https://github.com/joinmarket-webui/joinmarket-webui/issues/456)) ([27e1a10](https://github.com/joinmarket-webui/joinmarket-webui/commit/27e1a10f63382b757baed53e98869b3ffbd2191d))
* pass mixdepth prop as number in request body ([#457](https://github.com/joinmarket-webui/joinmarket-webui/issues/457)) ([155f9bd](https://github.com/joinmarket-webui/joinmarket-webui/commit/155f9bd55b4e385f49d49295ff52461f069f6b51))
* precondition for collaborative transactions ([#485](https://github.com/joinmarket-webui/joinmarket-webui/issues/485)) ([db29235](https://github.com/joinmarket-webui/joinmarket-webui/commit/db292356f59de24bd80622d4a1eb57414d514e49))
* proper margin for sweep button on invalid inputs ([#471](https://github.com/joinmarket-webui/joinmarket-webui/issues/471)) ([4a20c9f](https://github.com/joinmarket-webui/joinmarket-webui/commit/4a20c9fdd2eff40a2856d5c461dc4f18dba8f4f2))
* re-add Joining icon ([#474](https://github.com/joinmarket-webui/joinmarket-webui/issues/474)) ([1d0f0cc](https://github.com/joinmarket-webui/joinmarket-webui/commit/1d0f0ccf5fc5e516d6781ee041588a43b7135070))
* redirect to home if no wallet is active on route `/wallet` ([#492](https://github.com/joinmarket-webui/joinmarket-webui/issues/492)) ([2c3d6f7](https://github.com/joinmarket-webui/joinmarket-webui/commit/2c3d6f76feafd4400905dd66b5436a1fe93a0f09))
* refresh orderbook ([#462](https://github.com/joinmarket-webui/joinmarket-webui/issues/462)) ([505e960](https://github.com/joinmarket-webui/joinmarket-webui/commit/505e9606f50d13d6d2b67f1002662254f94953bd))
* reload wallet info after stopping scheduler manually ([#494](https://github.com/joinmarket-webui/joinmarket-webui/issues/494)) ([89698f2](https://github.com/joinmarket-webui/joinmarket-webui/commit/89698f27f56bb8d12894a9a9f0e6827cbf120837))
* remove jar source from scheduler options ([#465](https://github.com/joinmarket-webui/joinmarket-webui/issues/465)) ([b743357](https://github.com/joinmarket-webui/joinmarket-webui/commit/b7433571e9f3fdc3eee4acdf454cd578895b71ec))
* serialize values of `/maker/start` request body as strings ([#458](https://github.com/joinmarket-webui/joinmarket-webui/issues/458)) ([dd7943b](https://github.com/joinmarket-webui/joinmarket-webui/commit/dd7943b979d546821e1cd824e533e0b803688ce8))
* **settings:** matrix link ([#473](https://github.com/joinmarket-webui/joinmarket-webui/issues/473)) ([250f523](https://github.com/joinmarket-webui/joinmarket-webui/commit/250f523422c292232ec4db0c376d7582e29c8862))
- create non-descriptor wallet ([#487](https://github.com/joinmarket-webui/joinmarket-webui/issues/487)) ([0d70415](https://github.com/joinmarket-webui/joinmarket-webui/commit/0d704158f4b41f1a1dc0adef058c13f6d6932190))
- pass api token to session request ([#456](https://github.com/joinmarket-webui/joinmarket-webui/issues/456)) ([27e1a10](https://github.com/joinmarket-webui/joinmarket-webui/commit/27e1a10f63382b757baed53e98869b3ffbd2191d))
- pass mixdepth prop as number in request body ([#457](https://github.com/joinmarket-webui/joinmarket-webui/issues/457)) ([155f9bd](https://github.com/joinmarket-webui/joinmarket-webui/commit/155f9bd55b4e385f49d49295ff52461f069f6b51))
- precondition for collaborative transactions ([#485](https://github.com/joinmarket-webui/joinmarket-webui/issues/485)) ([db29235](https://github.com/joinmarket-webui/joinmarket-webui/commit/db292356f59de24bd80622d4a1eb57414d514e49))
- proper margin for sweep button on invalid inputs ([#471](https://github.com/joinmarket-webui/joinmarket-webui/issues/471)) ([4a20c9f](https://github.com/joinmarket-webui/joinmarket-webui/commit/4a20c9fdd2eff40a2856d5c461dc4f18dba8f4f2))
- re-add Joining icon ([#474](https://github.com/joinmarket-webui/joinmarket-webui/issues/474)) ([1d0f0cc](https://github.com/joinmarket-webui/joinmarket-webui/commit/1d0f0ccf5fc5e516d6781ee041588a43b7135070))
- redirect to home if no wallet is active on route `/wallet` ([#492](https://github.com/joinmarket-webui/joinmarket-webui/issues/492)) ([2c3d6f7](https://github.com/joinmarket-webui/joinmarket-webui/commit/2c3d6f76feafd4400905dd66b5436a1fe93a0f09))
- refresh orderbook ([#462](https://github.com/joinmarket-webui/joinmarket-webui/issues/462)) ([505e960](https://github.com/joinmarket-webui/joinmarket-webui/commit/505e9606f50d13d6d2b67f1002662254f94953bd))
- reload wallet info after stopping scheduler manually ([#494](https://github.com/joinmarket-webui/joinmarket-webui/issues/494)) ([89698f2](https://github.com/joinmarket-webui/joinmarket-webui/commit/89698f27f56bb8d12894a9a9f0e6827cbf120837))
- remove jar source from scheduler options ([#465](https://github.com/joinmarket-webui/joinmarket-webui/issues/465)) ([b743357](https://github.com/joinmarket-webui/joinmarket-webui/commit/b7433571e9f3fdc3eee4acdf454cd578895b71ec))
- serialize values of `/maker/start` request body as strings ([#458](https://github.com/joinmarket-webui/joinmarket-webui/issues/458)) ([dd7943b](https://github.com/joinmarket-webui/joinmarket-webui/commit/dd7943b979d546821e1cd824e533e0b803688ce8))
- **settings:** matrix link ([#473](https://github.com/joinmarket-webui/joinmarket-webui/issues/473)) ([250f523](https://github.com/joinmarket-webui/joinmarket-webui/commit/250f523422c292232ec4db0c376d7582e29c8862))
#### Added
* ability to retrieve logs ([#478](https://github.com/joinmarket-webui/joinmarket-webui/issues/478)) ([ace3734](https://github.com/joinmarket-webui/joinmarket-webui/commit/ace3734712518bec3868d6fefc921726a5c18b76))
* abort collaborative transaction ([#497](https://github.com/joinmarket-webui/joinmarket-webui/issues/497)) ([80e40ff](https://github.com/joinmarket-webui/joinmarket-webui/commit/80e40ff51086e00e73de0fcf5cc977e8a1720cfc))
* **cheatsheet:** update order ([#496](https://github.com/joinmarket-webui/joinmarket-webui/issues/496)) ([ff50e25](https://github.com/joinmarket-webui/joinmarket-webui/commit/ff50e25c11cc4600bc98b5799af0d0db4503186c))
* click on active "joining" icon opens relevant screen ([#463](https://github.com/joinmarket-webui/joinmarket-webui/issues/463)) ([033babd](https://github.com/joinmarket-webui/joinmarket-webui/commit/033babd1ac1149343d9ef628aead754ac796208b))
* colored jars with names ([#476](https://github.com/joinmarket-webui/joinmarket-webui/issues/476)) ([6a050f4](https://github.com/joinmarket-webui/joinmarket-webui/commit/6a050f4ccc60a74bb2f7cabf4792caea2a6267f5))
* highlight own orders in orderbook ([#472](https://github.com/joinmarket-webui/joinmarket-webui/issues/472)) ([b19689d](https://github.com/joinmarket-webui/joinmarket-webui/commit/b19689de0c96b58d26931fb5cedfdd781bcc1d2b))
* **jam:** remove "keep funds in jam" ([#484](https://github.com/joinmarket-webui/joinmarket-webui/issues/484)) ([5ada591](https://github.com/joinmarket-webui/joinmarket-webui/commit/5ada5911e1c161c1f949ada5cd2c0a851df02f68))
* **navbar:** align app flow ([#490](https://github.com/joinmarket-webui/joinmarket-webui/issues/490)) ([6322c44](https://github.com/joinmarket-webui/joinmarket-webui/commit/6322c44440e149409bfb84945f598c890d815150))
* rename "Joining" to "Jamming" ([#475](https://github.com/joinmarket-webui/joinmarket-webui/issues/475)) ([077b62a](https://github.com/joinmarket-webui/joinmarket-webui/commit/077b62ab802ebf9c94eaf12312273795f53efc67))
* **send:** warn users with send button if preconditions not met ([#498](https://github.com/joinmarket-webui/joinmarket-webui/issues/498)) ([5dd6ce6](https://github.com/joinmarket-webui/joinmarket-webui/commit/5dd6ce62d54fd2b6d8b5618ddc58bb90cacb623f))
* show active offers ([#461](https://github.com/joinmarket-webui/joinmarket-webui/issues/461)) ([c355d41](https://github.com/joinmarket-webui/joinmarket-webui/commit/c355d41f8b4420d3746116094a2be1b58a33b8fb))
- ability to retrieve logs ([#478](https://github.com/joinmarket-webui/joinmarket-webui/issues/478)) ([ace3734](https://github.com/joinmarket-webui/joinmarket-webui/commit/ace3734712518bec3868d6fefc921726a5c18b76))
- abort collaborative transaction ([#497](https://github.com/joinmarket-webui/joinmarket-webui/issues/497)) ([80e40ff](https://github.com/joinmarket-webui/joinmarket-webui/commit/80e40ff51086e00e73de0fcf5cc977e8a1720cfc))
- **cheatsheet:** update order ([#496](https://github.com/joinmarket-webui/joinmarket-webui/issues/496)) ([ff50e25](https://github.com/joinmarket-webui/joinmarket-webui/commit/ff50e25c11cc4600bc98b5799af0d0db4503186c))
- click on active "joining" icon opens relevant screen ([#463](https://github.com/joinmarket-webui/joinmarket-webui/issues/463)) ([033babd](https://github.com/joinmarket-webui/joinmarket-webui/commit/033babd1ac1149343d9ef628aead754ac796208b))
- colored jars with names ([#476](https://github.com/joinmarket-webui/joinmarket-webui/issues/476)) ([6a050f4](https://github.com/joinmarket-webui/joinmarket-webui/commit/6a050f4ccc60a74bb2f7cabf4792caea2a6267f5))
- highlight own orders in orderbook ([#472](https://github.com/joinmarket-webui/joinmarket-webui/issues/472)) ([b19689d](https://github.com/joinmarket-webui/joinmarket-webui/commit/b19689de0c96b58d26931fb5cedfdd781bcc1d2b))
- **jam:** remove "keep funds in jam" ([#484](https://github.com/joinmarket-webui/joinmarket-webui/issues/484)) ([5ada591](https://github.com/joinmarket-webui/joinmarket-webui/commit/5ada5911e1c161c1f949ada5cd2c0a851df02f68))
- **navbar:** align app flow ([#490](https://github.com/joinmarket-webui/joinmarket-webui/issues/490)) ([6322c44](https://github.com/joinmarket-webui/joinmarket-webui/commit/6322c44440e149409bfb84945f598c890d815150))
- rename "Joining" to "Jamming" ([#475](https://github.com/joinmarket-webui/joinmarket-webui/issues/475)) ([077b62a](https://github.com/joinmarket-webui/joinmarket-webui/commit/077b62ab802ebf9c94eaf12312273795f53efc67))
- **send:** warn users with send button if preconditions not met ([#498](https://github.com/joinmarket-webui/joinmarket-webui/issues/498)) ([5dd6ce6](https://github.com/joinmarket-webui/joinmarket-webui/commit/5dd6ce62d54fd2b6d8b5618ddc58bb90cacb623f))
- show active offers ([#461](https://github.com/joinmarket-webui/joinmarket-webui/issues/461)) ([c355d41](https://github.com/joinmarket-webui/joinmarket-webui/commit/c355d41f8b4420d3746116094a2be1b58a33b8fb))
### [0.0.10](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.9...v0.0.10) (2022-08-05)
#### Fixed
* accordion bg color ([#449](https://github.com/joinmarket-webui/joinmarket-webui/issues/449)) ([c3159a2](https://github.com/joinmarket-webui/joinmarket-webui/commit/c3159a252e55f754fbbe4a0f40fc05ddcd0b5fe2))
* cheatsheet icon ([#447](https://github.com/joinmarket-webui/joinmarket-webui/issues/447)) ([1113e21](https://github.com/joinmarket-webui/joinmarket-webui/commit/1113e21147a171a778e3439ee8df5474dce840dd))
* color of light-button in dark mode ([#442](https://github.com/joinmarket-webui/joinmarket-webui/issues/442)) ([f27a27a](https://github.com/joinmarket-webui/joinmarket-webui/commit/f27a27a93df4f070b9f308d75409290dd9357eff))
* color of selected collaborators-selector-input ([#410](https://github.com/joinmarket-webui/joinmarket-webui/issues/410)) ([a51cdbe](https://github.com/joinmarket-webui/joinmarket-webui/commit/a51cdbe53e30fc3436764f1cdd5d81c0b7cc6e0d))
* do not display freeze info when all utxos have been selected ([#420](https://github.com/joinmarket-webui/joinmarket-webui/issues/420)) ([662faf2](https://github.com/joinmarket-webui/joinmarket-webui/commit/662faf2c5523c073385a81053ff5bd861429d039))
* docs icon in settings ([#439](https://github.com/joinmarket-webui/joinmarket-webui/issues/439)) ([c4a76fa](https://github.com/joinmarket-webui/joinmarket-webui/commit/c4a76fa967365c6d39cbf974c9838c7d17163a15))
* invalid DOM property 'class' on Earn page ([#431](https://github.com/joinmarket-webui/joinmarket-webui/issues/431)) ([3d3706f](https://github.com/joinmarket-webui/joinmarket-webui/commit/3d3706f5b2df715dd20e3b03d1be034b3de6d67e))
* jar spacing ([#417](https://github.com/joinmarket-webui/joinmarket-webui/issues/417)) ([484afbc](https://github.com/joinmarket-webui/joinmarket-webui/commit/484afbc8eabee75fc8896e868ff559c479dc1380))
* remove logs ([#452](https://github.com/joinmarket-webui/joinmarket-webui/issues/452)) ([2097b35](https://github.com/joinmarket-webui/joinmarket-webui/commit/2097b35a5bd4a5cba40864e52814bc10927bfceb))
* spacing in jar overlay header and `onKeyDown` ([#421](https://github.com/joinmarket-webui/joinmarket-webui/issues/421)) ([24c0107](https://github.com/joinmarket-webui/joinmarket-webui/commit/24c01070365cbfcfc52d6cddad73d866bbbcf0dc))
* text and border colors after bootstrap update ([#432](https://github.com/joinmarket-webui/joinmarket-webui/issues/432)) ([96a401c](https://github.com/joinmarket-webui/joinmarket-webui/commit/96a401ccadaa5609c61162f739bf8eca5b5367c0))
- accordion bg color ([#449](https://github.com/joinmarket-webui/joinmarket-webui/issues/449)) ([c3159a2](https://github.com/joinmarket-webui/joinmarket-webui/commit/c3159a252e55f754fbbe4a0f40fc05ddcd0b5fe2))
- cheatsheet icon ([#447](https://github.com/joinmarket-webui/joinmarket-webui/issues/447)) ([1113e21](https://github.com/joinmarket-webui/joinmarket-webui/commit/1113e21147a171a778e3439ee8df5474dce840dd))
- color of light-button in dark mode ([#442](https://github.com/joinmarket-webui/joinmarket-webui/issues/442)) ([f27a27a](https://github.com/joinmarket-webui/joinmarket-webui/commit/f27a27a93df4f070b9f308d75409290dd9357eff))
- color of selected collaborators-selector-input ([#410](https://github.com/joinmarket-webui/joinmarket-webui/issues/410)) ([a51cdbe](https://github.com/joinmarket-webui/joinmarket-webui/commit/a51cdbe53e30fc3436764f1cdd5d81c0b7cc6e0d))
- do not display freeze info when all utxos have been selected ([#420](https://github.com/joinmarket-webui/joinmarket-webui/issues/420)) ([662faf2](https://github.com/joinmarket-webui/joinmarket-webui/commit/662faf2c5523c073385a81053ff5bd861429d039))
- docs icon in settings ([#439](https://github.com/joinmarket-webui/joinmarket-webui/issues/439)) ([c4a76fa](https://github.com/joinmarket-webui/joinmarket-webui/commit/c4a76fa967365c6d39cbf974c9838c7d17163a15))
- invalid DOM property 'class' on Earn page ([#431](https://github.com/joinmarket-webui/joinmarket-webui/issues/431)) ([3d3706f](https://github.com/joinmarket-webui/joinmarket-webui/commit/3d3706f5b2df715dd20e3b03d1be034b3de6d67e))
- jar spacing ([#417](https://github.com/joinmarket-webui/joinmarket-webui/issues/417)) ([484afbc](https://github.com/joinmarket-webui/joinmarket-webui/commit/484afbc8eabee75fc8896e868ff559c479dc1380))
- remove logs ([#452](https://github.com/joinmarket-webui/joinmarket-webui/issues/452)) ([2097b35](https://github.com/joinmarket-webui/joinmarket-webui/commit/2097b35a5bd4a5cba40864e52814bc10927bfceb))
- spacing in jar overlay header and `onKeyDown` ([#421](https://github.com/joinmarket-webui/joinmarket-webui/issues/421)) ([24c0107](https://github.com/joinmarket-webui/joinmarket-webui/commit/24c01070365cbfcfc52d6cddad73d866bbbcf0dc))
- text and border colors after bootstrap update ([#432](https://github.com/joinmarket-webui/joinmarket-webui/issues/432)) ([96a401c](https://github.com/joinmarket-webui/joinmarket-webui/commit/96a401ccadaa5609c61162f739bf8eca5b5367c0))
#### Added
* ability to sort and filter orderbook ([#434](https://github.com/joinmarket-webui/joinmarket-webui/issues/434)) ([952e24b](https://github.com/joinmarket-webui/joinmarket-webui/commit/952e24b2e9e4362a05e2eba2b2b9e19c398586e9))
* add description for second fidelity bond ([#414](https://github.com/joinmarket-webui/joinmarket-webui/issues/414)) ([ad50747](https://github.com/joinmarket-webui/joinmarket-webui/commit/ad5074781831918156869bb51c6aa967b1d5c88e))
* add sorting and filtering to earn report ([#451](https://github.com/joinmarket-webui/joinmarket-webui/issues/451)) ([bd8fd97](https://github.com/joinmarket-webui/joinmarket-webui/commit/bd8fd974f565ca525ac94aa9e1f51f7592ce7f6e))
* **cheatsheet:** link to jamdocs.org ([#427](https://github.com/joinmarket-webui/joinmarket-webui/issues/427)) ([b442d58](https://github.com/joinmarket-webui/joinmarket-webui/commit/b442d58b61c8ff24f65bb6c12c295cdda195e0a1))
* **cheatsheet:** link to jamdocs.org ([#429](https://github.com/joinmarket-webui/joinmarket-webui/issues/429)) ([05c908d](https://github.com/joinmarket-webui/joinmarket-webui/commit/05c908d6f6a671e19d41021cd883cf9483dc4052))
* **cheatsheet:** re-word to remove 'yield' ([#426](https://github.com/joinmarket-webui/joinmarket-webui/issues/426)) ([893dfd0](https://github.com/joinmarket-webui/joinmarket-webui/commit/893dfd03978909ecff36a9aa0aedd084e9b1c669)), closes [#326](https://github.com/joinmarket-webui/joinmarket-webui/issues/326)
* enable orderbook for all users ([#445](https://github.com/joinmarket-webui/joinmarket-webui/issues/445)) ([2d9d13e](https://github.com/joinmarket-webui/joinmarket-webui/commit/2d9d13ea907bde1c5f49a1344fca80defcfd4b57))
* human readable locktime duration for fidelity bonds ([#450](https://github.com/joinmarket-webui/joinmarket-webui/issues/450)) ([9d8e656](https://github.com/joinmarket-webui/joinmarket-webui/commit/9d8e65698ef44b46ddf7bc4b1c32490a135781af))
* improve earn report ([#409](https://github.com/joinmarket-webui/joinmarket-webui/issues/409)) ([dc36271](https://github.com/joinmarket-webui/joinmarket-webui/commit/dc36271aa61d2790eae8fcf7b79e06cd2610a3f3))
* Orderbook ([#422](https://github.com/joinmarket-webui/joinmarket-webui/issues/422)) ([2406c04](https://github.com/joinmarket-webui/joinmarket-webui/commit/2406c04a7aad901393df7eb65dc2427587a3a8bc))
* payment confirm modal ([#446](https://github.com/joinmarket-webui/joinmarket-webui/issues/446)) ([29eca37](https://github.com/joinmarket-webui/joinmarket-webui/commit/29eca37535b026d2c3eddb87170c8345bf287bc2))
* prevent address reuse on Jam page ([#433](https://github.com/joinmarket-webui/joinmarket-webui/issues/433)) ([6a8830f](https://github.com/joinmarket-webui/joinmarket-webui/commit/6a8830f28d01aa6312dff6a53f70c7df3882f827))
* **settings:** add link to Matrix and Jam's twitter ([#436](https://github.com/joinmarket-webui/joinmarket-webui/issues/436)) ([ca3cc20](https://github.com/joinmarket-webui/joinmarket-webui/commit/ca3cc20e15c41176b2aff1fedb8f871b63764ba9))
* **settings:** add links to docs ([#437](https://github.com/joinmarket-webui/joinmarket-webui/issues/437)) ([01515d7](https://github.com/joinmarket-webui/joinmarket-webui/commit/01515d7f1432b10c5d532ac20446be720507cabd))
* show address reuse warning ([#411](https://github.com/joinmarket-webui/joinmarket-webui/issues/411)) ([b2faeb7](https://github.com/joinmarket-webui/joinmarket-webui/commit/b2faeb747613beb74060e55b15c7f701e2ff6caa))
* utxo list ([#430](https://github.com/joinmarket-webui/joinmarket-webui/issues/430)) ([61a3956](https://github.com/joinmarket-webui/joinmarket-webui/commit/61a39566f657353f4fd08851e1479724433e888a))
- ability to sort and filter orderbook ([#434](https://github.com/joinmarket-webui/joinmarket-webui/issues/434)) ([952e24b](https://github.com/joinmarket-webui/joinmarket-webui/commit/952e24b2e9e4362a05e2eba2b2b9e19c398586e9))
- add description for second fidelity bond ([#414](https://github.com/joinmarket-webui/joinmarket-webui/issues/414)) ([ad50747](https://github.com/joinmarket-webui/joinmarket-webui/commit/ad5074781831918156869bb51c6aa967b1d5c88e))
- add sorting and filtering to earn report ([#451](https://github.com/joinmarket-webui/joinmarket-webui/issues/451)) ([bd8fd97](https://github.com/joinmarket-webui/joinmarket-webui/commit/bd8fd974f565ca525ac94aa9e1f51f7592ce7f6e))
- **cheatsheet:** link to jamdocs.org ([#427](https://github.com/joinmarket-webui/joinmarket-webui/issues/427)) ([b442d58](https://github.com/joinmarket-webui/joinmarket-webui/commit/b442d58b61c8ff24f65bb6c12c295cdda195e0a1))
- **cheatsheet:** link to jamdocs.org ([#429](https://github.com/joinmarket-webui/joinmarket-webui/issues/429)) ([05c908d](https://github.com/joinmarket-webui/joinmarket-webui/commit/05c908d6f6a671e19d41021cd883cf9483dc4052))
- **cheatsheet:** re-word to remove 'yield' ([#426](https://github.com/joinmarket-webui/joinmarket-webui/issues/426)) ([893dfd0](https://github.com/joinmarket-webui/joinmarket-webui/commit/893dfd03978909ecff36a9aa0aedd084e9b1c669)), closes [#326](https://github.com/joinmarket-webui/joinmarket-webui/issues/326)
- enable orderbook for all users ([#445](https://github.com/joinmarket-webui/joinmarket-webui/issues/445)) ([2d9d13e](https://github.com/joinmarket-webui/joinmarket-webui/commit/2d9d13ea907bde1c5f49a1344fca80defcfd4b57))
- human readable locktime duration for fidelity bonds ([#450](https://github.com/joinmarket-webui/joinmarket-webui/issues/450)) ([9d8e656](https://github.com/joinmarket-webui/joinmarket-webui/commit/9d8e65698ef44b46ddf7bc4b1c32490a135781af))
- improve earn report ([#409](https://github.com/joinmarket-webui/joinmarket-webui/issues/409)) ([dc36271](https://github.com/joinmarket-webui/joinmarket-webui/commit/dc36271aa61d2790eae8fcf7b79e06cd2610a3f3))
- Orderbook ([#422](https://github.com/joinmarket-webui/joinmarket-webui/issues/422)) ([2406c04](https://github.com/joinmarket-webui/joinmarket-webui/commit/2406c04a7aad901393df7eb65dc2427587a3a8bc))
- payment confirm modal ([#446](https://github.com/joinmarket-webui/joinmarket-webui/issues/446)) ([29eca37](https://github.com/joinmarket-webui/joinmarket-webui/commit/29eca37535b026d2c3eddb87170c8345bf287bc2))
- prevent address reuse on Jam page ([#433](https://github.com/joinmarket-webui/joinmarket-webui/issues/433)) ([6a8830f](https://github.com/joinmarket-webui/joinmarket-webui/commit/6a8830f28d01aa6312dff6a53f70c7df3882f827))
- **settings:** add link to Matrix and Jam's twitter ([#436](https://github.com/joinmarket-webui/joinmarket-webui/issues/436)) ([ca3cc20](https://github.com/joinmarket-webui/joinmarket-webui/commit/ca3cc20e15c41176b2aff1fedb8f871b63764ba9))
- **settings:** add links to docs ([#437](https://github.com/joinmarket-webui/joinmarket-webui/issues/437)) ([01515d7](https://github.com/joinmarket-webui/joinmarket-webui/commit/01515d7f1432b10c5d532ac20446be720507cabd))
- show address reuse warning ([#411](https://github.com/joinmarket-webui/joinmarket-webui/issues/411)) ([b2faeb7](https://github.com/joinmarket-webui/joinmarket-webui/commit/b2faeb747613beb74060e55b15c7f701e2ff6caa))
- utxo list ([#430](https://github.com/joinmarket-webui/joinmarket-webui/issues/430)) ([61a3956](https://github.com/joinmarket-webui/joinmarket-webui/commit/61a39566f657353f4fd08851e1479724433e888a))
### [0.0.9](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.8...v0.0.9) (2022-07-14)
#### Fixed
* advanced wording and behavior ([#390](https://github.com/joinmarket-webui/joinmarket-webui/issues/390)) ([c25c3ce](https://github.com/joinmarket-webui/joinmarket-webui/commit/c25c3ce6259112c702e249e4af3396001f8bbe3e))
* disable 'create wallet' link when unlocking wallet ([#334](https://github.com/joinmarket-webui/joinmarket-webui/issues/334)) ([e3083b9](https://github.com/joinmarket-webui/joinmarket-webui/commit/e3083b9c8ba5a99747f588188769b954ed03f0c2))
* do not show FB create form when maker is running ([#384](https://github.com/joinmarket-webui/joinmarket-webui/issues/384)) ([e0f51fa](https://github.com/joinmarket-webui/joinmarket-webui/commit/e0f51faacbef44a3914c3f000722761362b47ee0))
* do not show expired fidelity bonds as locked ([#378](https://github.com/joinmarket-webui/joinmarket-webui/issues/378)) ([0f7d590](https://github.com/joinmarket-webui/joinmarket-webui/commit/0f7d590e352d0dd1f8dce6e55a495b789aef2d31))
* encode wallet name param in url path ([#389](https://github.com/joinmarket-webui/joinmarket-webui/issues/389)) ([a98317b](https://github.com/joinmarket-webui/joinmarket-webui/commit/a98317b34403d07ef8ca3b878e201458fcd9f8fe))
* link to new fidelity bonds form in Cheatsheet component ([#376](https://github.com/joinmarket-webui/joinmarket-webui/issues/376)) ([21757e9](https://github.com/joinmarket-webui/joinmarket-webui/commit/21757e9588a38af21304f1109cc7573155162b3b))
* pass body of confirm modal via child node ([#377](https://github.com/joinmarket-webui/joinmarket-webui/issues/377)) ([23f7383](https://github.com/joinmarket-webui/joinmarket-webui/commit/23f738355153df8f88164584bbde236426e5d30e))
* reload utxos after creating a fidelity bond ([#380](https://github.com/joinmarket-webui/joinmarket-webui/issues/380)) ([72a3e8c](https://github.com/joinmarket-webui/joinmarket-webui/commit/72a3e8cff6bfa2cc6bbbd3f6afcefe51f4830d15))
- advanced wording and behavior ([#390](https://github.com/joinmarket-webui/joinmarket-webui/issues/390)) ([c25c3ce](https://github.com/joinmarket-webui/joinmarket-webui/commit/c25c3ce6259112c702e249e4af3396001f8bbe3e))
- disable 'create wallet' link when unlocking wallet ([#334](https://github.com/joinmarket-webui/joinmarket-webui/issues/334)) ([e3083b9](https://github.com/joinmarket-webui/joinmarket-webui/commit/e3083b9c8ba5a99747f588188769b954ed03f0c2))
- do not show FB create form when maker is running ([#384](https://github.com/joinmarket-webui/joinmarket-webui/issues/384)) ([e0f51fa](https://github.com/joinmarket-webui/joinmarket-webui/commit/e0f51faacbef44a3914c3f000722761362b47ee0))
- do not show expired fidelity bonds as locked ([#378](https://github.com/joinmarket-webui/joinmarket-webui/issues/378)) ([0f7d590](https://github.com/joinmarket-webui/joinmarket-webui/commit/0f7d590e352d0dd1f8dce6e55a495b789aef2d31))
- encode wallet name param in url path ([#389](https://github.com/joinmarket-webui/joinmarket-webui/issues/389)) ([a98317b](https://github.com/joinmarket-webui/joinmarket-webui/commit/a98317b34403d07ef8ca3b878e201458fcd9f8fe))
- link to new fidelity bonds form in Cheatsheet component ([#376](https://github.com/joinmarket-webui/joinmarket-webui/issues/376)) ([21757e9](https://github.com/joinmarket-webui/joinmarket-webui/commit/21757e9588a38af21304f1109cc7573155162b3b))
- pass body of confirm modal via child node ([#377](https://github.com/joinmarket-webui/joinmarket-webui/issues/377)) ([23f7383](https://github.com/joinmarket-webui/joinmarket-webui/commit/23f738355153df8f88164584bbde236426e5d30e))
- reload utxos after creating a fidelity bond ([#380](https://github.com/joinmarket-webui/joinmarket-webui/issues/380)) ([72a3e8c](https://github.com/joinmarket-webui/joinmarket-webui/commit/72a3e8cff6bfa2cc6bbbd3f6afcefe51f4830d15))
#### Added
* batch unfreeze UTXOs after creating fidelity bond ([#388](https://github.com/joinmarket-webui/joinmarket-webui/issues/388)) ([efa3361](https://github.com/joinmarket-webui/joinmarket-webui/commit/efa336117779b6f24a5165b41c9e863c85b90c7c))
* move fidelity bonds to earn screen ([#361](https://github.com/joinmarket-webui/joinmarket-webui/issues/361)) ([8608329](https://github.com/joinmarket-webui/joinmarket-webui/commit/8608329f3e45f1cc6c5cae43f8960ae01a9e147c))
* visual warning when selecting non cj-out UTXOs for fidelity bond ([#392](https://github.com/joinmarket-webui/joinmarket-webui/issues/392)) ([bad9a57](https://github.com/joinmarket-webui/joinmarket-webui/commit/bad9a5741ad15e9c8608582a99c963875ae51bfb))
- batch unfreeze UTXOs after creating fidelity bond ([#388](https://github.com/joinmarket-webui/joinmarket-webui/issues/388)) ([efa3361](https://github.com/joinmarket-webui/joinmarket-webui/commit/efa336117779b6f24a5165b41c9e863c85b90c7c))
- move fidelity bonds to earn screen ([#361](https://github.com/joinmarket-webui/joinmarket-webui/issues/361)) ([8608329](https://github.com/joinmarket-webui/joinmarket-webui/commit/8608329f3e45f1cc6c5cae43f8960ae01a9e147c))
- visual warning when selecting non cj-out UTXOs for fidelity bond ([#392](https://github.com/joinmarket-webui/joinmarket-webui/issues/392)) ([bad9a57](https://github.com/joinmarket-webui/joinmarket-webui/commit/bad9a5741ad15e9c8608582a99c963875ae51bfb))
### [0.0.8](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.7...v0.0.8) (2022-06-28)
#### Added
* **jars:** add receive shortcut if jar 0 is empty ([#344](https://github.com/joinmarket-webui/joinmarket-webui/issues/344)) ([01afb88](https://github.com/joinmarket-webui/joinmarket-webui/commit/01afb88888c173b677f4979c5ae8eef350a9da19))
* **jars:** destination jar selector on receive screen ([#346](https://github.com/joinmarket-webui/joinmarket-webui/issues/346)) ([911177c](https://github.com/joinmarket-webui/joinmarket-webui/commit/911177c899fe43fe2d889718dc644c6630629d8f))
* **jars:** destination jar selector on send screen ([#345](https://github.com/joinmarket-webui/joinmarket-webui/issues/345)) ([a08c584](https://github.com/joinmarket-webui/joinmarket-webui/commit/a08c584f5ee51fd88e5b16c862626717955c7102))
- **jars:** add receive shortcut if jar 0 is empty ([#344](https://github.com/joinmarket-webui/joinmarket-webui/issues/344)) ([01afb88](https://github.com/joinmarket-webui/joinmarket-webui/commit/01afb88888c173b677f4979c5ae8eef350a9da19))
- **jars:** destination jar selector on receive screen ([#346](https://github.com/joinmarket-webui/joinmarket-webui/issues/346)) ([911177c](https://github.com/joinmarket-webui/joinmarket-webui/commit/911177c899fe43fe2d889718dc644c6630629d8f))
- **jars:** destination jar selector on send screen ([#345](https://github.com/joinmarket-webui/joinmarket-webui/issues/345)) ([a08c584](https://github.com/joinmarket-webui/joinmarket-webui/commit/a08c584f5ee51fd88e5b16c862626717955c7102))
#### Fixed
* Check preconditions before send request ([#349](https://github.com/joinmarket-webui/joinmarket-webui/issues/349)) ([581184d](https://github.com/joinmarket-webui/joinmarket-webui/commit/581184d92fa4bab1054d1d6e34d7777abebe7f00))
* checked state of ToggleSwitch can be controlled by caller ([#332](https://github.com/joinmarket-webui/joinmarket-webui/issues/332)) ([c9007f5](https://github.com/joinmarket-webui/joinmarket-webui/commit/c9007f5428ae4ba79d68cd39972565a1747120e1))
* remove fidelity bond feature flag ([9fe84c8](https://github.com/joinmarket-webui/joinmarket-webui/commit/9fe84c8cda8e3959d13fa436ea9f650bf1d0b3ed))
- Check preconditions before send request ([#349](https://github.com/joinmarket-webui/joinmarket-webui/issues/349)) ([581184d](https://github.com/joinmarket-webui/joinmarket-webui/commit/581184d92fa4bab1054d1d6e34d7777abebe7f00))
- checked state of ToggleSwitch can be controlled by caller ([#332](https://github.com/joinmarket-webui/joinmarket-webui/issues/332)) ([c9007f5](https://github.com/joinmarket-webui/joinmarket-webui/commit/c9007f5428ae4ba79d68cd39972565a1747120e1))
- remove fidelity bond feature flag ([9fe84c8](https://github.com/joinmarket-webui/joinmarket-webui/commit/9fe84c8cda8e3959d13fa436ea9f650bf1d0b3ed))
### [0.0.7](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.6...v0.0.7) (2022-06-20)
#### Fixed
* loading state on Send page ([#300](https://github.com/joinmarket-webui/joinmarket-webui/issues/300)) ([db4f5ab](https://github.com/joinmarket-webui/joinmarket-webui/commit/db4f5ab18a6dcef6784c2f85adffc69a014570cc))
* mobile layout issues ([#311](https://github.com/joinmarket-webui/joinmarket-webui/issues/311)) ([8f62a42](https://github.com/joinmarket-webui/joinmarket-webui/commit/8f62a42302e40672eeeb78dc896e6116501a5905))
* prevent unnecessary session requests ([#298](https://github.com/joinmarket-webui/joinmarket-webui/issues/298)) ([bf627e7](https://github.com/joinmarket-webui/joinmarket-webui/commit/bf627e7ce128dea4679ba8e4035f578fad7a710b))
* prevent unnecessary wallet info requests ([#297](https://github.com/joinmarket-webui/joinmarket-webui/issues/297)) ([9377b33](https://github.com/joinmarket-webui/joinmarket-webui/commit/9377b33a11abbbafefbc93e0e34f2df59dd1749a))
* show balance in unit based on settings on Send screen ([#276](https://github.com/joinmarket-webui/joinmarket-webui/issues/276)) ([b0c8c4f](https://github.com/joinmarket-webui/joinmarket-webui/commit/b0c8c4f83c7e98b61be4f9a551a2b7d70c40aa43))
* styles ([#329](https://github.com/joinmarket-webui/joinmarket-webui/issues/329)) ([f5e8227](https://github.com/joinmarket-webui/joinmarket-webui/commit/f5e822743e8ad9ac0e8da77c0deb98573fcb325a))
- loading state on Send page ([#300](https://github.com/joinmarket-webui/joinmarket-webui/issues/300)) ([db4f5ab](https://github.com/joinmarket-webui/joinmarket-webui/commit/db4f5ab18a6dcef6784c2f85adffc69a014570cc))
- mobile layout issues ([#311](https://github.com/joinmarket-webui/joinmarket-webui/issues/311)) ([8f62a42](https://github.com/joinmarket-webui/joinmarket-webui/commit/8f62a42302e40672eeeb78dc896e6116501a5905))
- prevent unnecessary session requests ([#298](https://github.com/joinmarket-webui/joinmarket-webui/issues/298)) ([bf627e7](https://github.com/joinmarket-webui/joinmarket-webui/commit/bf627e7ce128dea4679ba8e4035f578fad7a710b))
- prevent unnecessary wallet info requests ([#297](https://github.com/joinmarket-webui/joinmarket-webui/issues/297)) ([9377b33](https://github.com/joinmarket-webui/joinmarket-webui/commit/9377b33a11abbbafefbc93e0e34f2df59dd1749a))
- show balance in unit based on settings on Send screen ([#276](https://github.com/joinmarket-webui/joinmarket-webui/issues/276)) ([b0c8c4f](https://github.com/joinmarket-webui/joinmarket-webui/commit/b0c8c4f83c7e98b61be4f9a551a2b7d70c40aa43))
- styles ([#329](https://github.com/joinmarket-webui/joinmarket-webui/issues/329)) ([f5e8227](https://github.com/joinmarket-webui/joinmarket-webui/commit/f5e822743e8ad9ac0e8da77c0deb98573fcb325a))
#### Added
* add share button to receive screen ([#310](https://github.com/joinmarket-webui/joinmarket-webui/issues/310)) ([ed03476](https://github.com/joinmarket-webui/joinmarket-webui/commit/ed03476766eaf31eed4589aa25e594d13023c29d))
* basic fidelity bonds ([#307](https://github.com/joinmarket-webui/joinmarket-webui/issues/307)) ([c68e4c5](https://github.com/joinmarket-webui/joinmarket-webui/commit/c68e4c5c64c8daa1f79307adf9b0d13b5ad6704c))
* enable report overlay ([#305](https://github.com/joinmarket-webui/joinmarket-webui/issues/305)) ([69c4211](https://github.com/joinmarket-webui/joinmarket-webui/commit/69c4211b8271b9c0b2f77d74d9bf630180a50495))
* first draft of jars on main wallet screen ([#324](https://github.com/joinmarket-webui/joinmarket-webui/issues/324)) ([216100a](https://github.com/joinmarket-webui/joinmarket-webui/commit/216100a3b973f4e91dedfbd866b40d9e268cca41))
* improve wallet control in settings ([#325](https://github.com/joinmarket-webui/joinmarket-webui/issues/325)) ([9d00212](https://github.com/joinmarket-webui/joinmarket-webui/commit/9d0021287f75c1a7349363fb484ec4f36810b36d))
* make jars interactive ([#331](https://github.com/joinmarket-webui/joinmarket-webui/issues/331)) ([95b3f09](https://github.com/joinmarket-webui/joinmarket-webui/commit/95b3f09696bcfb39bc9bd94aaacf8953542d5a90))
* **navbar:** remove wallets item ([#316](https://github.com/joinmarket-webui/joinmarket-webui/issues/316)) ([da99a3e](https://github.com/joinmarket-webui/joinmarket-webui/commit/da99a3e5ea188480b4b74599a782b43e35e7e6b1)), closes [#315](https://github.com/joinmarket-webui/joinmarket-webui/issues/315)
- add share button to receive screen ([#310](https://github.com/joinmarket-webui/joinmarket-webui/issues/310)) ([ed03476](https://github.com/joinmarket-webui/joinmarket-webui/commit/ed03476766eaf31eed4589aa25e594d13023c29d))
- basic fidelity bonds ([#307](https://github.com/joinmarket-webui/joinmarket-webui/issues/307)) ([c68e4c5](https://github.com/joinmarket-webui/joinmarket-webui/commit/c68e4c5c64c8daa1f79307adf9b0d13b5ad6704c))
- enable report overlay ([#305](https://github.com/joinmarket-webui/joinmarket-webui/issues/305)) ([69c4211](https://github.com/joinmarket-webui/joinmarket-webui/commit/69c4211b8271b9c0b2f77d74d9bf630180a50495))
- first draft of jars on main wallet screen ([#324](https://github.com/joinmarket-webui/joinmarket-webui/issues/324)) ([216100a](https://github.com/joinmarket-webui/joinmarket-webui/commit/216100a3b973f4e91dedfbd866b40d9e268cca41))
- improve wallet control in settings ([#325](https://github.com/joinmarket-webui/joinmarket-webui/issues/325)) ([9d00212](https://github.com/joinmarket-webui/joinmarket-webui/commit/9d0021287f75c1a7349363fb484ec4f36810b36d))
- make jars interactive ([#331](https://github.com/joinmarket-webui/joinmarket-webui/issues/331)) ([95b3f09](https://github.com/joinmarket-webui/joinmarket-webui/commit/95b3f09696bcfb39bc9bd94aaacf8953542d5a90))
- **navbar:** remove wallets item ([#316](https://github.com/joinmarket-webui/joinmarket-webui/issues/316)) ([da99a3e](https://github.com/joinmarket-webui/joinmarket-webui/commit/da99a3e5ea188480b4b74599a782b43e35e7e6b1)), closes [#315](https://github.com/joinmarket-webui/joinmarket-webui/issues/315)
### [0.0.6](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.5...v0.0.6) (2022-05-19)
#### Added
* add cheatsheet ([#211](https://github.com/joinmarket-webui/joinmarket-webui/issues/211)) ([825f725](https://github.com/joinmarket-webui/joinmarket-webui/commit/825f725053f4c11a696929516cc48f40ffc1aee5))
* add French translation ([#216](https://github.com/joinmarket-webui/joinmarket-webui/issues/216)) ([69fcbaf](https://github.com/joinmarket-webui/joinmarket-webui/commit/69fcbaff42af41f7723fd46d318009e969a5ed12))
* confirm password on Create Wallet screen ([#210](https://github.com/joinmarket-webui/joinmarket-webui/issues/210)) ([0c019db](https://github.com/joinmarket-webui/joinmarket-webui/commit/0c019db742e19efda1dab1f81f857d286c3ca1b5))
* **footer:** show Jam version ([#281](https://github.com/joinmarket-webui/joinmarket-webui/issues/281)) ([3c886f3](https://github.com/joinmarket-webui/joinmarket-webui/commit/3c886f3e05f81377f6a4b06e2ada6a020b6ce2aa))
* Individual balance toggle ([#247](https://github.com/joinmarket-webui/joinmarket-webui/issues/247)) ([e6c4cc1](https://github.com/joinmarket-webui/joinmarket-webui/commit/e6c4cc1c59925a630d0595148025ce784f3641ce))
* prevent address reuse on Jam screen ([#272](https://github.com/joinmarket-webui/joinmarket-webui/issues/272)) ([c05b431](https://github.com/joinmarket-webui/joinmarket-webui/commit/c05b431191f49718f63032d3b0fc10ee8991596d))
* rearrange order of tabs ([#258](https://github.com/joinmarket-webui/joinmarket-webui/issues/258)) ([8f527d7](https://github.com/joinmarket-webui/joinmarket-webui/commit/8f527d7be83c9326e6583fa14888be84a37ecfff))
* reload wallet data after send ([#236](https://github.com/joinmarket-webui/joinmarket-webui/issues/236)) ([edd5818](https://github.com/joinmarket-webui/joinmarket-webui/commit/edd5818b824276dd54a03d45103c810502e496bc))
* scheduled transactions prototype ([#242](https://github.com/joinmarket-webui/joinmarket-webui/issues/242)) ([0e1d0a8](https://github.com/joinmarket-webui/joinmarket-webui/commit/0e1d0a8a692633459f53edf156c1e4446db62852))
* simple progress report for scheduled transactions ([#262](https://github.com/joinmarket-webui/joinmarket-webui/issues/262)) ([0e3b7b8](https://github.com/joinmarket-webui/joinmarket-webui/commit/0e3b7b8b9416331bda7837fc5bb39b6d9ba3e869))
* split up scheduler destination addresses over 3 mixdepths ([#283](https://github.com/joinmarket-webui/joinmarket-webui/issues/283)) ([471cbc7](https://github.com/joinmarket-webui/joinmarket-webui/commit/471cbc7e985c2521f9053e950067c781649666b9))
- add cheatsheet ([#211](https://github.com/joinmarket-webui/joinmarket-webui/issues/211)) ([825f725](https://github.com/joinmarket-webui/joinmarket-webui/commit/825f725053f4c11a696929516cc48f40ffc1aee5))
- add French translation ([#216](https://github.com/joinmarket-webui/joinmarket-webui/issues/216)) ([69fcbaf](https://github.com/joinmarket-webui/joinmarket-webui/commit/69fcbaff42af41f7723fd46d318009e969a5ed12))
- confirm password on Create Wallet screen ([#210](https://github.com/joinmarket-webui/joinmarket-webui/issues/210)) ([0c019db](https://github.com/joinmarket-webui/joinmarket-webui/commit/0c019db742e19efda1dab1f81f857d286c3ca1b5))
- **footer:** show Jam version ([#281](https://github.com/joinmarket-webui/joinmarket-webui/issues/281)) ([3c886f3](https://github.com/joinmarket-webui/joinmarket-webui/commit/3c886f3e05f81377f6a4b06e2ada6a020b6ce2aa))
- Individual balance toggle ([#247](https://github.com/joinmarket-webui/joinmarket-webui/issues/247)) ([e6c4cc1](https://github.com/joinmarket-webui/joinmarket-webui/commit/e6c4cc1c59925a630d0595148025ce784f3641ce))
- prevent address reuse on Jam screen ([#272](https://github.com/joinmarket-webui/joinmarket-webui/issues/272)) ([c05b431](https://github.com/joinmarket-webui/joinmarket-webui/commit/c05b431191f49718f63032d3b0fc10ee8991596d))
- rearrange order of tabs ([#258](https://github.com/joinmarket-webui/joinmarket-webui/issues/258)) ([8f527d7](https://github.com/joinmarket-webui/joinmarket-webui/commit/8f527d7be83c9326e6583fa14888be84a37ecfff))
- reload wallet data after send ([#236](https://github.com/joinmarket-webui/joinmarket-webui/issues/236)) ([edd5818](https://github.com/joinmarket-webui/joinmarket-webui/commit/edd5818b824276dd54a03d45103c810502e496bc))
- scheduled transactions prototype ([#242](https://github.com/joinmarket-webui/joinmarket-webui/issues/242)) ([0e1d0a8](https://github.com/joinmarket-webui/joinmarket-webui/commit/0e1d0a8a692633459f53edf156c1e4446db62852))
- simple progress report for scheduled transactions ([#262](https://github.com/joinmarket-webui/joinmarket-webui/issues/262)) ([0e3b7b8](https://github.com/joinmarket-webui/joinmarket-webui/commit/0e3b7b8b9416331bda7837fc5bb39b6d9ba3e869))
- split up scheduler destination addresses over 3 mixdepths ([#283](https://github.com/joinmarket-webui/joinmarket-webui/issues/283)) ([471cbc7](https://github.com/joinmarket-webui/joinmarket-webui/commit/471cbc7e985c2521f9053e950067c781649666b9))
#### Fixed
* do not hide CreateWallet component on connection errors ([#199](https://github.com/joinmarket-webui/joinmarket-webui/issues/199)) ([963dc49](https://github.com/joinmarket-webui/joinmarket-webui/commit/963dc49e723072d91f91649610e3f733bef358d1))
* force-close pending websockets connections ([#200](https://github.com/joinmarket-webui/joinmarket-webui/issues/200)) ([33b35f7](https://github.com/joinmarket-webui/joinmarket-webui/commit/33b35f7ba9bd0f97c115172ea5918bb06d308422))
* link to dev docs on contributing page ([#224](https://github.com/joinmarket-webui/joinmarket-webui/issues/224)) ([ef23b4b](https://github.com/joinmarket-webui/joinmarket-webui/commit/ef23b4b0850c2fa1d28a0ae9b56f2f53293b296f))
* possible reference error in catch clause ([#265](https://github.com/joinmarket-webui/joinmarket-webui/issues/265)) ([2526eac](https://github.com/joinmarket-webui/joinmarket-webui/commit/2526eacf3b67307ece0a86a442094377df8c77c5))
* prevent operations when maker/taker service is running ([#218](https://github.com/joinmarket-webui/joinmarket-webui/issues/218)) ([035dd80](https://github.com/joinmarket-webui/joinmarket-webui/commit/035dd8034ab5a45ef377a17877b343132cdf933f))
* prevent starting scheduler when utxo preconditions are not met ([#263](https://github.com/joinmarket-webui/joinmarket-webui/issues/263)) ([a500b02](https://github.com/joinmarket-webui/joinmarket-webui/commit/a500b02f9bb1c11681a8ae8c532b77dfb18add6a))
* prevent starting/stopping scheduler while data is loading ([#260](https://github.com/joinmarket-webui/joinmarket-webui/issues/260)) ([593981d](https://github.com/joinmarket-webui/joinmarket-webui/commit/593981dfd3d823ad02f09bbb683a652027b5383e))
* reload session info on Earn and Wallets screen ([#231](https://github.com/joinmarket-webui/joinmarket-webui/issues/231)) ([df34272](https://github.com/joinmarket-webui/joinmarket-webui/commit/df342722c45d9c0184031a566e54fb31acae9685))
* Remove skip button quiz screen ([#198](https://github.com/joinmarket-webui/joinmarket-webui/issues/198)) ([6c5e149](https://github.com/joinmarket-webui/joinmarket-webui/commit/6c5e149516454e1008aebf5f5cef74da9309f5f6))
* reset wallet when token became invalid ([#223](https://github.com/joinmarket-webui/joinmarket-webui/issues/223)) ([70ffc99](https://github.com/joinmarket-webui/joinmarket-webui/commit/70ffc990402df242cf5c1fcc30308762ca918b8f))
- do not hide CreateWallet component on connection errors ([#199](https://github.com/joinmarket-webui/joinmarket-webui/issues/199)) ([963dc49](https://github.com/joinmarket-webui/joinmarket-webui/commit/963dc49e723072d91f91649610e3f733bef358d1))
- force-close pending websockets connections ([#200](https://github.com/joinmarket-webui/joinmarket-webui/issues/200)) ([33b35f7](https://github.com/joinmarket-webui/joinmarket-webui/commit/33b35f7ba9bd0f97c115172ea5918bb06d308422))
- link to dev docs on contributing page ([#224](https://github.com/joinmarket-webui/joinmarket-webui/issues/224)) ([ef23b4b](https://github.com/joinmarket-webui/joinmarket-webui/commit/ef23b4b0850c2fa1d28a0ae9b56f2f53293b296f))
- possible reference error in catch clause ([#265](https://github.com/joinmarket-webui/joinmarket-webui/issues/265)) ([2526eac](https://github.com/joinmarket-webui/joinmarket-webui/commit/2526eacf3b67307ece0a86a442094377df8c77c5))
- prevent operations when maker/taker service is running ([#218](https://github.com/joinmarket-webui/joinmarket-webui/issues/218)) ([035dd80](https://github.com/joinmarket-webui/joinmarket-webui/commit/035dd8034ab5a45ef377a17877b343132cdf933f))
- prevent starting scheduler when utxo preconditions are not met ([#263](https://github.com/joinmarket-webui/joinmarket-webui/issues/263)) ([a500b02](https://github.com/joinmarket-webui/joinmarket-webui/commit/a500b02f9bb1c11681a8ae8c532b77dfb18add6a))
- prevent starting/stopping scheduler while data is loading ([#260](https://github.com/joinmarket-webui/joinmarket-webui/issues/260)) ([593981d](https://github.com/joinmarket-webui/joinmarket-webui/commit/593981dfd3d823ad02f09bbb683a652027b5383e))
- reload session info on Earn and Wallets screen ([#231](https://github.com/joinmarket-webui/joinmarket-webui/issues/231)) ([df34272](https://github.com/joinmarket-webui/joinmarket-webui/commit/df342722c45d9c0184031a566e54fb31acae9685))
- Remove skip button quiz screen ([#198](https://github.com/joinmarket-webui/joinmarket-webui/issues/198)) ([6c5e149](https://github.com/joinmarket-webui/joinmarket-webui/commit/6c5e149516454e1008aebf5f5cef74da9309f5f6))
- reset wallet when token became invalid ([#223](https://github.com/joinmarket-webui/joinmarket-webui/issues/223)) ([70ffc99](https://github.com/joinmarket-webui/joinmarket-webui/commit/70ffc990402df242cf5c1fcc30308762ca918b8f))
### [0.0.5](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.4...v0.0.5) (2022-03-29)
#### Added
* add seed phrase backup confirmation during wallet creation ([#156](https://github.com/joinmarket-webui/joinmarket-webui/issues/156)) ([0719dc6](https://github.com/joinmarket-webui/joinmarket-webui/commit/0719dc62359387282363e0f3e658106d64c4051f))
* satscomma formatting for bitcoin balances ([#171](https://github.com/joinmarket-webui/joinmarket-webui/issues/171)) ([fe94945](https://github.com/joinmarket-webui/joinmarket-webui/commit/fe94945f2ae95a96f011cddf4d7a8604c1e76d2e))
* sweep mixdepths ([#184](https://github.com/joinmarket-webui/joinmarket-webui/issues/184)) ([81876b7](https://github.com/joinmarket-webui/joinmarket-webui/commit/81876b72146e2ab5c69ef52ccc183576a5929a34))
* translate screens ([#174](https://github.com/joinmarket-webui/joinmarket-webui/issues/174)) ([63018ac](https://github.com/joinmarket-webui/joinmarket-webui/commit/63018ac96b8dd743ef47cb9a9a010f773f1542e4))
- add seed phrase backup confirmation during wallet creation ([#156](https://github.com/joinmarket-webui/joinmarket-webui/issues/156)) ([0719dc6](https://github.com/joinmarket-webui/joinmarket-webui/commit/0719dc62359387282363e0f3e658106d64c4051f))
- satscomma formatting for bitcoin balances ([#171](https://github.com/joinmarket-webui/joinmarket-webui/issues/171)) ([fe94945](https://github.com/joinmarket-webui/joinmarket-webui/commit/fe94945f2ae95a96f011cddf4d7a8604c1e76d2e))
- sweep mixdepths ([#184](https://github.com/joinmarket-webui/joinmarket-webui/issues/184)) ([81876b7](https://github.com/joinmarket-webui/joinmarket-webui/commit/81876b72146e2ab5c69ef52ccc183576a5929a34))
- translate screens ([#174](https://github.com/joinmarket-webui/joinmarket-webui/issues/174)) ([63018ac](https://github.com/joinmarket-webui/joinmarket-webui/commit/63018ac96b8dd743ef47cb9a9a010f773f1542e4))
#### Fixed
* make websocket health state work across browsers ([#186](https://github.com/joinmarket-webui/joinmarket-webui/issues/186)) ([39019cc](https://github.com/joinmarket-webui/joinmarket-webui/commit/39019ccbb668637b20c7220277b9b3cfcb6a7942))
* pass correct request body in send-direct request ([#180](https://github.com/joinmarket-webui/joinmarket-webui/issues/180)) ([182b09c](https://github.com/joinmarket-webui/joinmarket-webui/commit/182b09c359e492531c3be7a1ad6d91310c7c5546))
- make websocket health state work across browsers ([#186](https://github.com/joinmarket-webui/joinmarket-webui/issues/186)) ([39019cc](https://github.com/joinmarket-webui/joinmarket-webui/commit/39019ccbb668637b20c7220277b9b3cfcb6a7942))
- pass correct request body in send-direct request ([#180](https://github.com/joinmarket-webui/joinmarket-webui/issues/180)) ([182b09c](https://github.com/joinmarket-webui/joinmarket-webui/commit/182b09c359e492531c3be7a1ad6d91310c7c5546))
### [0.0.4](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.3...v0.0.4) (2022-03-10)
#### Fixed
* address copy button on http sites ([#165](https://github.com/joinmarket-webui/joinmarket-webui/issues/165)) ([34f8d2d](https://github.com/joinmarket-webui/joinmarket-webui/commit/34f8d2dc2b2fb50def6ed3e316e50db769f84154))
* page reloads ([#162](https://github.com/joinmarket-webui/joinmarket-webui/issues/162)) ([78d15a1](https://github.com/joinmarket-webui/joinmarket-webui/commit/78d15a1a30c5bc755085820bbd85d02056c78eeb))
* suggest number of collaborators based on configured minimum ([#116](https://github.com/joinmarket-webui/joinmarket-webui/issues/116)) ([d2c36bf](https://github.com/joinmarket-webui/joinmarket-webui/commit/d2c36bfc65311163f05c7415993a09c5d5131f82))
* update suggested number of collaborators ([#150](https://github.com/joinmarket-webui/joinmarket-webui/issues/150)) ([26ffe8c](https://github.com/joinmarket-webui/joinmarket-webui/commit/26ffe8cdeb2146a757c90d05b67d3304485af918))
* qrcode on receive page ([#146](https://github.com/joinmarket-webui/joinmarket-webui/issues/146)) ([87299b3](https://github.com/joinmarket-webui/joinmarket-webui/commit/87299b3268fbcb3710ce81cf3db8419325994138))
* warn on missing config vars ([#152](https://github.com/joinmarket-webui/joinmarket-webui/issues/152)) ([3180103](https://github.com/joinmarket-webui/joinmarket-webui/commit/3180103da67f70dfd2030cc9db403d30a58a1b4a))
- address copy button on http sites ([#165](https://github.com/joinmarket-webui/joinmarket-webui/issues/165)) ([34f8d2d](https://github.com/joinmarket-webui/joinmarket-webui/commit/34f8d2dc2b2fb50def6ed3e316e50db769f84154))
- page reloads ([#162](https://github.com/joinmarket-webui/joinmarket-webui/issues/162)) ([78d15a1](https://github.com/joinmarket-webui/joinmarket-webui/commit/78d15a1a30c5bc755085820bbd85d02056c78eeb))
- suggest number of collaborators based on configured minimum ([#116](https://github.com/joinmarket-webui/joinmarket-webui/issues/116)) ([d2c36bf](https://github.com/joinmarket-webui/joinmarket-webui/commit/d2c36bfc65311163f05c7415993a09c5d5131f82))
- update suggested number of collaborators ([#150](https://github.com/joinmarket-webui/joinmarket-webui/issues/150)) ([26ffe8c](https://github.com/joinmarket-webui/joinmarket-webui/commit/26ffe8cdeb2146a757c90d05b67d3304485af918))
- qrcode on receive page ([#146](https://github.com/joinmarket-webui/joinmarket-webui/issues/146)) ([87299b3](https://github.com/joinmarket-webui/joinmarket-webui/commit/87299b3268fbcb3710ce81cf3db8419325994138))
- warn on missing config vars ([#152](https://github.com/joinmarket-webui/joinmarket-webui/issues/152)) ([3180103](https://github.com/joinmarket-webui/joinmarket-webui/commit/3180103da67f70dfd2030cc9db403d30a58a1b4a))
#### Added
* show seed phrase in settings ([#160](https://github.com/joinmarket-webui/joinmarket-webui/issues/160)) ([7fb76ff](https://github.com/joinmarket-webui/joinmarket-webui/commit/7fb76ff5d54a03e3ef0763e94f3f10bc3e73c503))
* i18n ([#153](https://github.com/joinmarket-webui/joinmarket-webui/issues/153)) ([cc168eb](https://github.com/joinmarket-webui/joinmarket-webui/commit/cc168eb49faf7495bc653ff104f6cac1c090dbbe))
- show seed phrase in settings ([#160](https://github.com/joinmarket-webui/joinmarket-webui/issues/160)) ([7fb76ff](https://github.com/joinmarket-webui/joinmarket-webui/commit/7fb76ff5d54a03e3ef0763e94f3f10bc3e73c503))
- i18n ([#153](https://github.com/joinmarket-webui/joinmarket-webui/issues/153)) ([cc168eb](https://github.com/joinmarket-webui/joinmarket-webui/commit/cc168eb49faf7495bc653ff104f6cac1c090dbbe))
### [0.0.3](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.2...v0.0.3) (2022-02-18)
#### Fixed
* display info text on send page if service is running ([#86](https://github.com/joinmarket-webui/joinmarket-webui/issues/86)) ([4070d70](https://github.com/joinmarket-webui/joinmarket-webui/commit/4070d7026c0ba7eac7fca02a3c14c65da510f67b))
* fix and dry up font features ([#98](https://github.com/joinmarket-webui/joinmarket-webui/issues/98)) ([ac762c5](https://github.com/joinmarket-webui/joinmarket-webui/commit/ac762c520486e33fc493b3de59ad8b02f6aa5332))
* improve onboarding mobile layout ([#109](https://github.com/joinmarket-webui/joinmarket-webui/issues/109)) ([4159fac](https://github.com/joinmarket-webui/joinmarket-webui/commit/4159fac2303233faa93c57c2e2cec9faa3b8fbd6))
* navigate to root on unmapped path ([#89](https://github.com/joinmarket-webui/joinmarket-webui/issues/89)) ([6f0f515](https://github.com/joinmarket-webui/joinmarket-webui/commit/6f0f515e87c177c8e5bf71009782497f779eac83))
* update sat symbol ([#36](https://github.com/joinmarket-webui/joinmarket-webui/issues/36)) ([765c9d7](https://github.com/joinmarket-webui/joinmarket-webui/commit/765c9d7315f96843cefeddbe51b49682f652ea2a))
- display info text on send page if service is running ([#86](https://github.com/joinmarket-webui/joinmarket-webui/issues/86)) ([4070d70](https://github.com/joinmarket-webui/joinmarket-webui/commit/4070d7026c0ba7eac7fca02a3c14c65da510f67b))
- fix and dry up font features ([#98](https://github.com/joinmarket-webui/joinmarket-webui/issues/98)) ([ac762c5](https://github.com/joinmarket-webui/joinmarket-webui/commit/ac762c520486e33fc493b3de59ad8b02f6aa5332))
- improve onboarding mobile layout ([#109](https://github.com/joinmarket-webui/joinmarket-webui/issues/109)) ([4159fac](https://github.com/joinmarket-webui/joinmarket-webui/commit/4159fac2303233faa93c57c2e2cec9faa3b8fbd6))
- navigate to root on unmapped path ([#89](https://github.com/joinmarket-webui/joinmarket-webui/issues/89)) ([6f0f515](https://github.com/joinmarket-webui/joinmarket-webui/commit/6f0f515e87c177c8e5bf71009782497f779eac83))
- update sat symbol ([#36](https://github.com/joinmarket-webui/joinmarket-webui/issues/36)) ([765c9d7](https://github.com/joinmarket-webui/joinmarket-webui/commit/765c9d7315f96843cefeddbe51b49682f652ea2a))
#### Added
* ability for reverse proxy to enforce own auth scheme ([#102](https://github.com/joinmarket-webui/joinmarket-webui/issues/102)) ([5b7fc98](https://github.com/joinmarket-webui/joinmarket-webui/commit/5b7fc982e5241219e5f43fd5cd77f1d9a65abbfe))
* add connection indicator to footer ([#55](https://github.com/joinmarket-webui/joinmarket-webui/issues/55)) ([6470ab4](https://github.com/joinmarket-webui/joinmarket-webui/commit/6470ab431627c091a40016b9baa2516157ca5ba2))
* add privacy levels ([#51](https://github.com/joinmarket-webui/joinmarket-webui/issues/51)) ([7546ca7](https://github.com/joinmarket-webui/joinmarket-webui/commit/7546ca74d44369c4d8dba6961b4d441bd98edb09))
* add quick hide balance ([#106](https://github.com/joinmarket-webui/joinmarket-webui/issues/106)) ([56b4d1e](https://github.com/joinmarket-webui/joinmarket-webui/commit/56b4d1e64f51dbe243d5b9877800c94caa5f0fa9))
* add sat symbol & update balance UI ([#11](https://github.com/joinmarket-webui/joinmarket-webui/issues/11)) ([4351d13](https://github.com/joinmarket-webui/joinmarket-webui/commit/4351d1380e3a3d7ba872dce1f2281d192ad095ec))
* add settings screen ([#26](https://github.com/joinmarket-webui/joinmarket-webui/issues/26)) ([867fff3](https://github.com/joinmarket-webui/joinmarket-webui/commit/867fff330bac37c4c741ca528e7f9ffa0511d2fd))
* add theming support ([#10](https://github.com/joinmarket-webui/joinmarket-webui/issues/10)) ([d7268aa](https://github.com/joinmarket-webui/joinmarket-webui/commit/d7268aa70c54fa08a094b0e4c1e8eb2c88e00c77))
* developer docs ([#37](https://github.com/joinmarket-webui/joinmarket-webui/issues/37)) ([d843770](https://github.com/joinmarket-webui/joinmarket-webui/commit/d84377071a87c2011b5fe1bc957243f491f66cfd))
* display basic yield generator report ([#73](https://github.com/joinmarket-webui/joinmarket-webui/issues/73)) ([b37de70](https://github.com/joinmarket-webui/joinmarket-webui/commit/b37de7083ecb9575d798a2df2aa69a6d21dba0a0))
* freeze/unfreeze utxos ([#110](https://github.com/joinmarket-webui/joinmarket-webui/issues/110)) ([2c2d228](https://github.com/joinmarket-webui/joinmarket-webui/commit/2c2d22875422771ffc2962f584ba60db78c8398d))
* hide sensitive info on wallet create ([#77](https://github.com/joinmarket-webui/joinmarket-webui/issues/77)) ([174726b](https://github.com/joinmarket-webui/joinmarket-webui/commit/174726b74fd889980c124b9d56e2c1d1766e8a2f))
* update create wallet flow ([#62](https://github.com/joinmarket-webui/joinmarket-webui/issues/62)) ([8ed27ae](https://github.com/joinmarket-webui/joinmarket-webui/commit/8ed27ae745f46ffda246006b5f7ba549493aa39b))
* update earn page ([#82](https://github.com/joinmarket-webui/joinmarket-webui/issues/82)) ([7d62a38](https://github.com/joinmarket-webui/joinmarket-webui/commit/7d62a3820a83103a691633c8030bc26312e8310d))
* update receive page ([#85](https://github.com/joinmarket-webui/joinmarket-webui/issues/85)) ([756d8e8](https://github.com/joinmarket-webui/joinmarket-webui/commit/756d8e8a93185933d0a92713f92bf92f7ccdf664))
* update send page ([#76](https://github.com/joinmarket-webui/joinmarket-webui/issues/76)) ([0d71915](https://github.com/joinmarket-webui/joinmarket-webui/commit/0d71915180dafb03ce69c8ba82c3c76fa5b2db46))
* update wallets page ([#108](https://github.com/joinmarket-webui/joinmarket-webui/issues/108)) ([68e7a7f](https://github.com/joinmarket-webui/joinmarket-webui/commit/68e7a7fab85015efcdbcebad38c0e04ceb2024fc), [#64](https://github.com/joinmarket-webui/joinmarket-webui/issues/64)) ([f900344](https://github.com/joinmarket-webui/joinmarket-webui/commit/f90034454920fb77ddb7fedcbe3e92f1bb994322))
- ability for reverse proxy to enforce own auth scheme ([#102](https://github.com/joinmarket-webui/joinmarket-webui/issues/102)) ([5b7fc98](https://github.com/joinmarket-webui/joinmarket-webui/commit/5b7fc982e5241219e5f43fd5cd77f1d9a65abbfe))
- add connection indicator to footer ([#55](https://github.com/joinmarket-webui/joinmarket-webui/issues/55)) ([6470ab4](https://github.com/joinmarket-webui/joinmarket-webui/commit/6470ab431627c091a40016b9baa2516157ca5ba2))
- add privacy levels ([#51](https://github.com/joinmarket-webui/joinmarket-webui/issues/51)) ([7546ca7](https://github.com/joinmarket-webui/joinmarket-webui/commit/7546ca74d44369c4d8dba6961b4d441bd98edb09))
- add quick hide balance ([#106](https://github.com/joinmarket-webui/joinmarket-webui/issues/106)) ([56b4d1e](https://github.com/joinmarket-webui/joinmarket-webui/commit/56b4d1e64f51dbe243d5b9877800c94caa5f0fa9))
- add sat symbol & update balance UI ([#11](https://github.com/joinmarket-webui/joinmarket-webui/issues/11)) ([4351d13](https://github.com/joinmarket-webui/joinmarket-webui/commit/4351d1380e3a3d7ba872dce1f2281d192ad095ec))
- add settings screen ([#26](https://github.com/joinmarket-webui/joinmarket-webui/issues/26)) ([867fff3](https://github.com/joinmarket-webui/joinmarket-webui/commit/867fff330bac37c4c741ca528e7f9ffa0511d2fd))
- add theming support ([#10](https://github.com/joinmarket-webui/joinmarket-webui/issues/10)) ([d7268aa](https://github.com/joinmarket-webui/joinmarket-webui/commit/d7268aa70c54fa08a094b0e4c1e8eb2c88e00c77))
- developer docs ([#37](https://github.com/joinmarket-webui/joinmarket-webui/issues/37)) ([d843770](https://github.com/joinmarket-webui/joinmarket-webui/commit/d84377071a87c2011b5fe1bc957243f491f66cfd))
- display basic yield generator report ([#73](https://github.com/joinmarket-webui/joinmarket-webui/issues/73)) ([b37de70](https://github.com/joinmarket-webui/joinmarket-webui/commit/b37de7083ecb9575d798a2df2aa69a6d21dba0a0))
- freeze/unfreeze utxos ([#110](https://github.com/joinmarket-webui/joinmarket-webui/issues/110)) ([2c2d228](https://github.com/joinmarket-webui/joinmarket-webui/commit/2c2d22875422771ffc2962f584ba60db78c8398d))
- hide sensitive info on wallet create ([#77](https://github.com/joinmarket-webui/joinmarket-webui/issues/77)) ([174726b](https://github.com/joinmarket-webui/joinmarket-webui/commit/174726b74fd889980c124b9d56e2c1d1766e8a2f))
- update create wallet flow ([#62](https://github.com/joinmarket-webui/joinmarket-webui/issues/62)) ([8ed27ae](https://github.com/joinmarket-webui/joinmarket-webui/commit/8ed27ae745f46ffda246006b5f7ba549493aa39b))
- update earn page ([#82](https://github.com/joinmarket-webui/joinmarket-webui/issues/82)) ([7d62a38](https://github.com/joinmarket-webui/joinmarket-webui/commit/7d62a3820a83103a691633c8030bc26312e8310d))
- update receive page ([#85](https://github.com/joinmarket-webui/joinmarket-webui/issues/85)) ([756d8e8](https://github.com/joinmarket-webui/joinmarket-webui/commit/756d8e8a93185933d0a92713f92bf92f7ccdf664))
- update send page ([#76](https://github.com/joinmarket-webui/joinmarket-webui/issues/76)) ([0d71915](https://github.com/joinmarket-webui/joinmarket-webui/commit/0d71915180dafb03ce69c8ba82c3c76fa5b2db46))
- update wallets page ([#108](https://github.com/joinmarket-webui/joinmarket-webui/issues/108)) ([68e7a7f](https://github.com/joinmarket-webui/joinmarket-webui/commit/68e7a7fab85015efcdbcebad38c0e04ceb2024fc), [#64](https://github.com/joinmarket-webui/joinmarket-webui/issues/64)) ([f900344](https://github.com/joinmarket-webui/joinmarket-webui/commit/f90034454920fb77ddb7fedcbe3e92f1bb994322))
### [0.0.2](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.1...v0.0.2) (2022-02-16)
#### Fixed
* remove displayed artifact on missing fidelity bonds ([#2](https://github.com/joinmarket-webui/joinmarket-webui/pull/2)) ([17d5afa](https://github.com/joinmarket-webui/joinmarket-webui/commit/17d5afaa578b6390a27a1d195dd31523f5228546))
- remove displayed artifact on missing fidelity bonds ([#2](https://github.com/joinmarket-webui/joinmarket-webui/pull/2)) ([17d5afa](https://github.com/joinmarket-webui/joinmarket-webui/commit/17d5afaa578b6390a27a1d195dd31523f5228546))
### [0.0.1](https://github.com/joinmarket-webui/joinmarket-webui/compare/2b9704d...v0.0.1) (2022-02-16)
#### Added
* fork [JoinMarket-Org/jm-web-client](https://github.com/JoinMarket-Org/jm-web-client) ([79e90ea](https://github.com/joinmarket-webui/joinmarket-webui/commit/79e90eadaa772689d30bbc7e9107887dad331183))
* refactor app ([a72e0a9](https://github.com/joinmarket-webui/joinmarket-webui/commit/a72e0a97c1d4c425f8ad1d6f928f985d464aa59d))
* UI updates ([c838a7b](https://github.com/joinmarket-webui/joinmarket-webui/commit/c838a7b9dab915c8a20655f63430837ecea4f290))
- fork [JoinMarket-Org/jm-web-client](https://github.com/JoinMarket-Org/jm-web-client) ([79e90ea](https://github.com/joinmarket-webui/joinmarket-webui/commit/79e90eadaa772689d30bbc7e9107887dad331183))
- refactor app ([a72e0a9](https://github.com/joinmarket-webui/joinmarket-webui/commit/a72e0a97c1d4c425f8ad1d6f928f985d464aa59d))
- UI updates ([c838a7b](https://github.com/joinmarket-webui/joinmarket-webui/commit/c838a7b9dab915c8a20655f63430837ecea4f290))

View file

@ -39,6 +39,7 @@ Initialize the regtest setup:
```sh
npm run regtest:init
```
This creates and funds a wallet `Satoshi` with password `test`.
Start the UI on port 3000:
@ -95,6 +96,7 @@ Initialize the regtest setup:
```sh
npm run regtest:init
```
This creates and funds a wallet `Satoshi` with password `test`.
Start the UI on port 3000:

View file

@ -25,7 +25,6 @@ It aims to provide sensible defaults and be easy to use for beginners while stil
- 💬 Join our [Matrix room](https://matrix.to/#/%23jam:bitcoin.kyoto) to get help and [contribute](https://jamdocs.org/contribute/)!
- 📚 Check out the [documentation](https://jamdocs.org) and the [wiki](https://github.com/joinmarket-webui/jam/wiki) for resources such as meeting notes, call recordings, ideas, and discussions.
## 📸
<div align="center">

21
components.json Normal file
View file

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View file

@ -3,10 +3,11 @@ Starts a staging joinmarket directory node that you can point to in your regtest
- start-dn.py taken from https://github.com/JoinMarket-Org/custom-scripts/blob/e3c5fb548c704fc56cdaa869705797955a9821dd/start-dn.py
(might already be in master - last check on 2022-05-11)
You must mount the directory specified in `hidden_service_dir`, which contains hostname, public and private key,
You must mount the directory specified in `hidden_service_dir`, which contains hostname, public and private key,
and provide the correct onion hostname via `directory_nodes` yourself!
e.g. in a docker-compose setup:
```yml
joinmarket_directory_node:
[...]

View file

@ -8,6 +8,7 @@ All containers will have a wallet named `Satoshi.jmdat` with password `test`.
The second container has basic auth enabled (username `joinmarket` and password `joinmarket`).
## Common flow
```sh
# (optional) once in a while rebuild the images
npm run regtest:rebuild
@ -65,9 +66,11 @@ npm run regtest:clear
```
### Mine
Mine regtest blocks in a fixed interval (current default is every 11 seconds).
This is useful for features that await confirmations or need incoming blocks regularly.
e.g. This is necessary for scheduled transactions to execute successfully.
```sh
npm run regtest:mine
```
@ -86,6 +89,7 @@ This is useful if you want to perform regression tests.
One additional JoinMarket container acts as [Directory Node](https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/docs/onion-message-channels.md#directory) and exists solely to enable communication between peers.
### Build
```sh
# building the images
npm run regtest:build

31
eslint.config.js Normal file
View file

@ -0,0 +1,31 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from 'eslint-plugin-storybook'
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
// Allow unused variables when they start with underscore
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
},
},
storybook.configs['flat/recommended'],
)

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en" suppressHydrationWarning>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jam</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

24595
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,65 +1,19 @@
{
"name": "jam",
"version": "0.4.0",
"private": true,
"description": "Your sats. Your privacy. Your profit.",
"repository": "git@github.com:joinmarket-webui/jam.git",
"license": "MIT",
"engines": {
"node": ">=22.11.0",
"npm": ">=10.9.0"
},
"homepage": ".",
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^29.5.14",
"@types/node": "^22.8.5",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"conventional-changelog": "^6.0.0",
"http-proxy-middleware": "^3.0.5",
"husky": "^9.1.7",
"jest-watch-typeahead": "^2.2.2",
"jest-websocket-mock": "^2.5.0",
"lint-staged": "^15.5.2",
"prettier": "^3.5.3",
"react-scripts": "^5.0.1",
"typescript": "^4.9.5"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@table-library/react-table-library": "^4.1.15",
"bootstrap": "^5.3.6",
"classnames": "^2.5.1",
"formik": "^2.4.6",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.1.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-bootstrap": "^2.10.10",
"react-dom": "^18.3.1",
"react-i18next": "^15.4.1",
"react-router-bootstrap": "^0.26.3",
"react-router-dom": "^6.30.1"
},
"version": "0.0.0",
"type": "module",
"scripts": {
"dev:start": "echo 'Deprecated command will be removed soon. Please use `npm run dev` instead.'",
"dev:start:secondary": "echo 'Deprecated command will be removed soon. Please use `npm run dev:secondary` instead.'",
"dev": "REACT_APP_JAM_DEV_MODE=true npm start",
"dev:secondary": "PORT=3001 JAM_BACKEND=jam-standalone JAM_API_PORT=29080 npm run dev:start",
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"prepare": "husky",
"lint": "prettier --check --no-error-on-unmatched-pattern 'src/**/*.{js,jsx,ts,tsx,json,css,md}'",
"format": "prettier --write --no-error-on-unmatched-pattern 'src/**/*.{js,jsx,ts,tsx,json,css,md}'",
"version": "node scripts/changelog.mjs && git checkout -b \"prepare-v${npm_package_version}-$(date +%s)\" && git add --all && git commit --message \"chore(release): v${npm_package_version}\" && git push --set-upstream origin $(git branch --show-current)",
"postversion": "which gh && gh pr create --title \"chore(release): v${npm_package_version}\" --body \"Prepares the v${npm_package_version} release.\" --assignee @me --label release --repo joinmarket-webui/jam --draft",
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"prettier": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
"format": "npm run lint -- --fix && npm run prettier",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"prepare": "husky install",
"test": "vitest run",
"regtest:build": "npm run regtest:clear && docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml build --pull",
"regtest:rebuild": "rm -rf docker/regtest/.tmp && npm run regtest:build -- --no-cache",
"regtest:clear": "docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml down --volumes --remove-orphans",
@ -72,40 +26,69 @@
"regtest:init": "./docker/regtest/init-setup.sh",
"regtest:mine": "watch -n 11 ./docker/regtest/mine-block.sh"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,css,md}": "prettier --write"
},
"prettier": {
"printWidth": 120,
"semi": false,
"singleQuote": true
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
"dependencies": {
"@hey-api/client-fetch": "^0.13.1",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.10",
"@tanstack/react-query": "^5.80.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.518.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.2",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.10",
"tw-animate-css": "^1.3.4"
},
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx,json,css,md}": "prettier --no-error-on-unmatched-pattern --write"
"devDependencies": {
"@chromatic-com/storybook": "^4.0.0",
"@eslint/js": "^9.29.0",
"@playwright/test": "^1.53.1",
"@storybook/addon-a11y": "^9.0.12",
"@storybook/addon-docs": "^9.0.12",
"@storybook/addon-vitest": "^9.0.12",
"@storybook/react-vite": "^9.0.12",
"@types/node": "^22.15.29",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"@vitest/browser": "^3.2.4",
"conventional-changelog": "^7.1.0",
"eslint": "^9.29.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-storybook": "^9.0.12",
"globals": "^16.2.0",
"husky": "^8.0.0",
"lint-staged": "^16.1.2",
"prettier": "^3.5.3",
"storybook": "^9.0.12",
"typescript": "~5.8.3",
"typescript-eslint": "^8.34.1",
"vite": "^6.3.5",
"vitest": "^3.2.4"
},
"jest": {
"extraGlobals": [
"Math",
"localStorage"
],
"clearMocks": true,
"transformIgnorePatterns": [
"node_modules/(?!@table-library)"
]
"overrides": {
"storybook": "$storybook"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#ffffff</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 24 KiB

View file

@ -1,41 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="base-url" href="." />
<link rel="public-url" href="%PUBLIC_URL%" data-value="%PUBLIC_URL%" />
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
<link rel="icon" href="%PUBLIC_URL%/favicon.svg" />
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest" crossorigin="use-credentials" />
<link rel="mask-icon" href="%PUBLIC_URL%/safari-pinned-tab.svg" color="#000000" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta name="theme-color" content="#ffffff" />
<title>Jam for JoinMarket</title>
<script>
window.JM = { SETTINGS_STORE_KEY: 'jm-settings', THEMES: ['light', 'dark'], THEME_ROOT_ATTR: 'data-theme' }
// determine base path depending on whether PUBLIC_URL is set
const baseHref = document.querySelector('link[rel="base-url"]').href
const publicHref = document.querySelector('link[rel="public-url"]').href
const publicUrl = document.querySelector('link[rel="public-url"]').dataset.value
const base = publicUrl.length === 0 || baseHref.length > publicHref.length ? baseHref : publicHref
window.JM.PUBLIC_PATH = base
.replace(`${window.location.protocol}//${window.location.host}`, '') // remove domain part
.replace(/\/$/, '') // remove trailing slash
// theme
const settings = JSON.parse(window.localStorage.getItem(JM.SETTINGS_STORE_KEY) || '{}')
const userColorMode = settings.theme
const systemColorMode = window.matchMedia('(prefers-color-scheme: dark)').matches ? JM.THEMES[1] : JM.THEMES[0]
const initialColorMode = JM.THEMES.includes(userColorMode) ? userColorMode : systemColorMode
if (!userColorMode) {
const updatedSettings = Object.assign(settings, { theme: initialColorMode })
window.localStorage.setItem(JM.SETTINGS_STORE_KEY, JSON.stringify(updatedSettings))
}
document.documentElement.setAttribute(JM.THEME_ROOT_ATTR, initialColorMode)
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

View file

@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.8 KiB

View file

@ -1,19 +0,0 @@
{
"name": "JoinMarket",
"short_name": "JoinMarket",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 103 KiB

1
public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

112
src/App.tsx Normal file
View file

@ -0,0 +1,112 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import JamLanding from './components/JamLanding'
import LoginPage from './components/Login'
import CreateWallet from './components/CreateWallet'
import { Layout } from './components/layout/Layout'
import { clearSession, getSession, setSession } from './lib/session'
import { Toaster } from './components/ui/sonner'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from './lib/queryClient'
import { useApiClient } from './hooks/useApiClient'
import { useEffect } from 'react'
import { JM_API_AUTH_TOKEN_EXPIRY } from './constants/jm'
import { setIntervalDebounced } from './lib/utils'
import { toast } from 'sonner'
import { token } from './lib/jm-api/generated/client'
import { ThemeProvider } from 'next-themes'
const isAuthenticated = () => {
const session = getSession()
return session?.auth?.token !== undefined
}
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
return isAuthenticated() ? <>{children}</> : <Navigate to="/login" replace />
}
function App() {
return (
<ThemeProvider defaultTheme="dark" enableSystem>
<QueryClientProvider client={queryClient}>
<RefreshApiToken />
<Router>
<Routes>
<Route path="/login" element={isAuthenticated() ? <Navigate to="/" replace /> : <LoginPage />} />
<Route path="/create-wallet" element={isAuthenticated() ? <Navigate to="/" replace /> : <CreateWallet />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout>
<JamLanding />
</Layout>
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
<Toaster closeButton />
</Router>
</QueryClientProvider>
</ThemeProvider>
)
}
const API_AUTH_TOKEN_RENEW_INTERVAL = Math.round(JM_API_AUTH_TOKEN_EXPIRY * 0.75)
function RefreshApiToken() {
const client = useApiClient()
// TODO: stop this interval if no wallet if active
useEffect(() => {
if (import.meta.env.DEV) {
toast.info(`[DEV] setup refresh interval`)
}
let intervalId: NodeJS.Timeout
setIntervalDebounced(
async () => {
const session = getSession()
if (session?.auth?.refresh_token === undefined) return
const response = await token({
client,
body: {
grant_type: 'refresh_token',
refresh_token: session.auth.refresh_token,
},
})
if (!response.data) {
clearSession()
if (import.meta.env.DEV) {
const message = response.error?.message || response.error?.error_description || 'Unknown error.'
toast.error(`[DEV] Error while refreshing auth token: ${message}`)
}
} else {
setSession({
auth: {
token: response.data.token,
refresh_token: response.data.refresh_token,
},
})
if (import.meta.env.DEV) {
toast.info(`[DEV] Successfully refreshed auth token.`)
}
}
},
API_AUTH_TOKEN_RENEW_INTERVAL,
(timerId) => (intervalId = timerId),
)
return () => {
clearInterval(intervalId)
}
}, [client])
return <></>
}
export default App

View file

@ -1,61 +0,0 @@
import { ReactNode, PropsWithChildren, useState } from 'react'
import classNames from 'classnames'
import { useSettings } from '../context/SettingsContext'
import * as rb from 'react-bootstrap'
import Sprite from './Sprite'
interface AccordionProps {
title: ReactNode | string
defaultOpen?: boolean
disabled?: boolean
variant?: 'warning' | 'danger'
}
const Accordion = ({
title,
defaultOpen = false,
disabled = false,
variant,
children,
}: PropsWithChildren<AccordionProps>) => {
const settings = useSettings()
const [isOpen, setIsOpen] = useState(defaultOpen)
return (
<div>
<rb.Button
variant={settings.theme}
className="d-flex align-items-center bg-transparent border-0 rounded-0 w-100 px-0 py-2"
onClick={() => setIsOpen((current) => !current)}
disabled={disabled}
>
<div
className={classNames('d-flex align-items-center', {
'text-danger': variant === 'danger',
})}
>
{variant && (
<div
className={classNames('badge rounded-pill p-0 me-2', {
'text-dark': variant === 'warning',
'bg-warning': variant === 'warning',
'text-light': variant === 'danger',
'bg-danger': variant === 'danger',
})}
>
<Sprite symbol="warn" width="20" height="20" />
</div>
)}
{title}
</div>
<Sprite symbol={`caret-${isOpen ? 'up' : 'down'}`} className="ms-1" width="20" height="20" />
</rb.Button>
<div className="m-0 mb-4 border-0 border-bottom" />
<rb.Collapse in={isOpen}>
<div>{children}</div>
</rb.Collapse>
</div>
)
}
export default Accordion

View file

@ -1,45 +0,0 @@
import { PropsWithChildren } from 'react'
import classNames from 'classnames'
import Sprite from './Sprite'
interface ActivityIndicatorProps {
isOn: boolean
}
function ActivityIndicator({ isOn, children }: PropsWithChildren<ActivityIndicatorProps>) {
return (
<span className={`activity-indicator ${isOn ? 'activity-indicator-on' : 'activity-indicator-off'}`}>
{children}
</span>
)
}
interface JoiningIndicatorProps {
isOn: boolean
size?: number
title?: string
className?: string
}
export function JoiningIndicator({ isOn, size = 32, className = '', ...props }: JoiningIndicatorProps) {
return (
<span className="joining-indicator">
<ActivityIndicator isOn={isOn}>
{isOn && <Sprite symbol="mixed" width={size} height={size} className={`${className}`} {...props} />}
</ActivityIndicator>
</span>
)
}
interface TabActivityIndicatorProps {
isOn: boolean
className?: string
}
export function TabActivityIndicator({ isOn, className }: TabActivityIndicatorProps) {
return (
<span className={classNames('earn-indicator', className)}>
<ActivityIndicator isOn={isOn} />
</span>
)
}

View file

@ -1,20 +0,0 @@
import { useState } from 'react'
import { Alert as BsAlert } from 'react-bootstrap'
export default function Alert({ message, onClose, ...props }: SimpleAlert) {
const [show, setShow] = useState(true)
return (
<BsAlert
className="my-3"
onClose={(a: any, b: any) => {
setShow(false)
onClose && onClose(a, b)
}}
show={show}
{...props}
>
{message}
</BsAlert>
)
}

View file

@ -1,88 +0,0 @@
import { render, screen, act } from '../testUtils'
import user from '@testing-library/user-event'
import * as apiMock from '../libs/JmWalletApi'
import App from './App'
jest.mock('../libs/JmWalletApi', () => ({
...jest.requireActual('../libs/JmWalletApi'),
getGetinfo: jest.fn(),
getSession: jest.fn(),
}))
describe('<App />', () => {
beforeEach(() => {
const neverResolvingPromise = new Promise(() => {})
;(apiMock.getGetinfo as jest.Mock).mockResolvedValue(neverResolvingPromise)
;(apiMock.getSession as jest.Mock).mockResolvedValue(neverResolvingPromise)
})
it('should display Onboarding screen initially', async () => {
await act(async () => render(<App />))
// Onboarding screen
expect(screen.getByText('onboarding.splashscreen_button_get_started')).toBeInTheDocument()
expect(screen.getByText('onboarding.splashscreen_button_skip_intro')).toBeInTheDocument()
// Wallets screen shown after Intro is skipped
expect(screen.queryByText('wallets.title')).not.toBeInTheDocument()
const skipIntro = screen.getByText('onboarding.splashscreen_button_skip_intro')
await user.click(skipIntro)
expect(screen.getByText('wallets.title')).toBeInTheDocument()
})
it('should display Wallets screen directly when Onboarding screen has been shown', async () => {
global.__DEV__.addToAppSettings({ showOnboarding: false })
await act(async () => render(<App />))
// Wallets screen
expect(screen.getByText('wallets.title')).toBeInTheDocument()
expect(screen.getByText('wallets.button_new_wallet')).toBeInTheDocument()
})
it('should display a modal with beta warning information', async () => {
global.__DEV__.addToAppSettings({ showOnboarding: false })
await act(async () => render(<App />))
expect(screen.getByText('Read this before using.')).toBeInTheDocument()
expect(screen.queryByText(/While JoinMarket is tried and tested, Jam is not./)).not.toBeInTheDocument()
const readThis = screen.getByText('Read this before using.')
await user.click(readThis)
expect(screen.getByText('footer.warning_alert_text')).toBeInTheDocument()
expect(screen.getByText('footer.warning_alert_button_ok')).toBeInTheDocument()
})
it('should display websocket connection indicator as CONNECTED', async () => {
global.__DEV__.addToAppSettings({ showOnboarding: false })
await act(async () => {
render(<App />)
})
await global.__DEV__.JM_WEBSOCKET_SERVER_MOCK.connected
expect(screen.getByTestId('connection-indicator-icon').classList.contains('text-success')).toBe(true)
expect(screen.getByTestId('connection-indicator-icon').classList.contains('text-secondary')).toBe(false)
})
it('should display websocket connection indicator AS DISCONNECTED', async () => {
global.__DEV__.addToAppSettings({ showOnboarding: false })
await act(async () => {
render(<App />)
})
await act(async () => {
global.__DEV__.JM_WEBSOCKET_SERVER_MOCK.close()
})
expect(screen.getByTestId('connection-indicator-icon').classList.contains('text-success')).toBe(false)
expect(screen.getByTestId('connection-indicator-icon').classList.contains('text-secondary')).toBe(true)
})
})

View file

@ -1,359 +0,0 @@
import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from 'react'
import * as rb from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import {
createBrowserRouter,
createRoutesFromElements,
Navigate,
Route,
RouterProvider,
Outlet,
} from 'react-router-dom'
import classNames from 'classnames'
import * as Api from '../libs/JmWalletApi'
import { routes } from '../constants/routes'
import { useServiceInfo, useSessionConnectionError } from '../context/ServiceInfoContext'
import { useSettings } from '../context/SettingsContext'
import {
WalletInfo,
CurrentWallet,
useCurrentWallet,
useSetCurrentWallet,
useClearCurrentWallet,
useReloadCurrentWalletInfo,
} from '../context/WalletContext'
import { clearSession, setSession } from '../session'
import { isDebugFeatureEnabled } from '../constants/debugFeatures'
import CreateWallet from './CreateWallet'
import ImportWallet from './ImportWallet'
import Earn from './Earn'
import ErrorPage from './ErrorPage'
import Footer from './Footer'
import Jam from './Jam'
import Layout from './Layout'
import MainWalletView from './MainWalletView'
import Navbar from './Navbar'
import Onboarding from './Onboarding'
import Receive from './Receive'
import Send from './Send'
import RescanChain from './RescanChain'
import Settings from './Settings'
import Wallets from './Wallets'
const DevSetupPage = lazy(() => import('./DevSetupPage'))
export default function App() {
const { t } = useTranslation()
const settings = useSettings()
const currentWallet = useCurrentWallet()
const setCurrentWallet = useSetCurrentWallet()
const clearCurrentWallet = useClearCurrentWallet()
const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
const serviceInfo = useServiceInfo()
const sessionConnectionError = useSessionConnectionError()
const [reloadingWalletInfoCounter, setReloadingWalletInfoCounter] = useState(0)
const isReloadingWalletInfo = useMemo(() => reloadingWalletInfoCounter > 0, [reloadingWalletInfoCounter])
const startWallet = useCallback(
(walletFileName: Api.WalletFileName, auth: Api.ApiAuthContext) => {
setSession({ walletFileName, auth })
setCurrentWallet({ walletFileName, token: auth.token })
},
[setCurrentWallet],
)
const stopWallet = useCallback(() => {
clearCurrentWallet()
clearSession()
}, [clearCurrentWallet])
const reloadWalletInfo = useCallback(
({ delay, force }: { delay: Milliseconds; force: boolean }) => {
setReloadingWalletInfoCounter((current) => current + 1)
console.info('Reloading wallet info...')
return new Promise<WalletInfo>((resolve, reject) =>
setTimeout(() => {
const reload = force ? reloadCurrentWalletInfo.reloadAllForce : reloadCurrentWalletInfo.reloadAll
const abortCtrl = new AbortController()
reload({ signal: abortCtrl.signal })
.then((result) => resolve(result))
.catch((error) => reject(error))
.finally(() => {
console.info('Finished reloading wallet info.')
setReloadingWalletInfoCounter((current) => current - 1)
})
}, delay),
)
},
[reloadCurrentWalletInfo],
)
const router = createBrowserRouter(
createRoutesFromElements(
<Route
id="base"
element={
<>
<Navbar />
<rb.Container as="main" className="py-4 py-lg-5" fluid="xl">
<Outlet />
</rb.Container>
<Footer />
</>
}
errorElement={<ErrorPage />}
>
<Route
id="error-boundary"
element={
<Layout>
<Outlet />
</Layout>
}
errorElement={
<Layout variant="wide">
<ErrorPage />
</Layout>
}
>
{/**
* This sections defines all routes that can be displayed, even if the connection
* to the backend is down, e.g. "create-wallet" shows the seed quiz and it is important
* that it stays visible in case the backend becomes unavailable.
*/}
<Route
id="create-wallet"
path={routes.createWallet}
element={<CreateWallet parentRoute={'home'} startWallet={startWallet} />}
/>
{sessionConnectionError ? (
<Route
id="404"
path="*"
element={
<rb.Alert variant="danger">
<h5 className="alert-heading">
{t('app.alert_no_connection', { connectionError: sessionConnectionError.message })}
</h5>
<p>
<Trans
i18nKey="app.alert_no_connection_details"
components={{
1: (
<a
className="alert-link"
href="https://jamdocs.org/FAQ/#how-to-resolve-no-connection-to-gateway"
target="_blank"
rel="noopener noreferrer"
>
the docs
</a>
),
}}
/>
</p>
{sessionConnectionError.response && !sessionConnectionError.response.ok && (
<pre>
{sessionConnectionError.response.status}&nbsp;{sessionConnectionError.response.statusText}&nbsp;
{sessionConnectionError.response.url}
</pre>
)}
</rb.Alert>
}
/>
) : (
<>
{/**
* This section defines all routes that are displayed only if the backend is reachable.
*/}
<Route
id="wallets"
path={routes.home}
element={<Wallets currentWallet={currentWallet} startWallet={startWallet} stopWallet={stopWallet} />}
/>
<Route
id="import-wallet"
path={routes.importWallet}
element={<ImportWallet parentRoute={'home'} startWallet={startWallet} />}
/>
{currentWallet && (
<>
<Route id="wallet" path={routes.wallet} element={<MainWalletView wallet={currentWallet} />} />
<Route id="jam" path={routes.jam} element={<Jam wallet={currentWallet} />} />
<Route id="send" path={routes.send} element={<Send wallet={currentWallet} />} />
<Route id="earn" path={routes.earn} element={<Earn wallet={currentWallet} />} />
<Route id="receive" path={routes.receive} element={<Receive wallet={currentWallet} />} />
<Route id="rescan" path={routes.rescanChain} element={<RescanChain wallet={currentWallet} />} />
<Route
id="settings"
path={routes.settings}
element={<Settings wallet={currentWallet} stopWallet={stopWallet} />}
/>
</>
)}
{isDebugFeatureEnabled('errorExamplePage') && (
<Route id="error-example" path={routes.__errorExample} element={<ErrorThrowingComponent />} />
)}
{isDebugFeatureEnabled('devSetupPage') && (
<Route
id="dev-env"
path={routes.__devSetup}
element={
<Suspense fallback={<Loading />}>
<DevSetupPage />
</Suspense>
}
/>
)}
<Route id="404" path="*" element={<Navigate to={routes.home} replace={true} />} />
</>
)}
</Route>
</Route>,
),
{
basename: window.JM.PUBLIC_PATH,
future: {
v7_fetcherPersist: true,
v7_normalizeFormMethod: true,
},
},
)
if (settings.showOnboarding === true) {
return (
<rb.Container className="onboarding pt-3 pt-md-5">
<rb.Row className="justify-content-center">
<rb.Col xs={10} sm={10} md={8} lg={6} xl={4}>
<Onboarding />
</rb.Col>
</rb.Row>
</rb.Container>
)
}
return (
<>
<div
className={classNames('app', {
'jam-reload-wallet-info-in-progress': isReloadingWalletInfo,
'jm-coinjoin-in-progress': serviceInfo?.coinjoinInProgress === true,
'jm-rescan-in-progress': serviceInfo?.rescanning === true,
'jm-maker-running': serviceInfo?.makerRunning === true,
})}
>
<RouterProvider router={router} />
</div>
<WalletInfoAutoReload currentWallet={currentWallet} reloadWalletInfo={reloadWalletInfo} />
</>
)
}
const Loading = () => {
const { t } = useTranslation()
return (
<div className="text-center">
<rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" className="me-2" />
{t('global.loading')}
</div>
)
}
const ErrorThrowingComponent = () => {
useEffect(() => {
throw new Error('This error is thrown on purpose. Only to be used for testing.')
}, [])
return <></>
}
const RELOAD_WALLET_INFO_DELAY: {
AFTER_RESCAN: Milliseconds
AFTER_UNLOCK: Milliseconds
} = {
// After rescanning, it is necessary to give the JM backend some time to synchronize.
// A couple of seconds should be enough, however, this depends on the user hardware
// and the delay might need to be increased if users encounter problems, e.g. the
// balance changes again when switching views.
// As reference: 4 seconds was not enough, even on regtest. But keep in mind, this only
// takes effect after rescanning the chain, which should happen quite infrequently.
AFTER_RESCAN: 8_000,
// No delay is needed after normal unlock of wallet
AFTER_UNLOCK: 0,
}
const MAX_RECURSIVE_WALLET_INFO_RELOADS = 10
interface WalletInfoAutoReloadProps {
currentWallet: CurrentWallet | null
reloadWalletInfo: ({ delay, force }: { delay: Milliseconds; force: boolean }) => Promise<WalletInfo>
}
/**
* A component that automatically reloads wallet information on certain state changes,
* e.g. when the wallet is unlocked or rescanning the chain finished successfully.
*
* If the auto-reloading on wallet change fails, the error can currently
* only be logged and cannot be displayed to the user satisfactorily.
* This might change in the future but is okay for now - components can
* always trigger a reload on demand and inform the user as they see fit.
*/
const WalletInfoAutoReload = ({ currentWallet, reloadWalletInfo }: WalletInfoAutoReloadProps) => {
const serviceInfo = useServiceInfo()
const [previousRescanning, setPreviousRescanning] = useState(serviceInfo?.rescanning || false)
const [currentRescanning, setCurrentRescanning] = useState(serviceInfo?.rescanning || false)
const rescanningFinished = useMemo(
() => previousRescanning === true && currentRescanning === false,
[previousRescanning, currentRescanning],
)
useEffect(() => {
setPreviousRescanning(currentRescanning)
setCurrentRescanning(serviceInfo?.rescanning || false)
}, [serviceInfo, currentRescanning])
useEffect(
function reloadAfterUnlock() {
if (!currentWallet) return
reloadWalletInfo({ delay: RELOAD_WALLET_INFO_DELAY.AFTER_UNLOCK, force: true }).catch((err) => console.error(err))
},
[currentWallet, reloadWalletInfo],
)
useEffect(
function reloadAfterRescan() {
if (!currentWallet || !rescanningFinished) return
// Hacky: If the balance changes after a reload, the backend might still not have been fully synchronized - try again!
// Hint 1: Wallet might be empty after the first attempt
// Hint 2: Just because wallet balance did not change, it does not mean everything has been found.
const reloadWhileBalanceChangesRecursively = async (
currentBalance: Api.AmountSats,
delay: Milliseconds,
maxCalls: number,
callCounter: number = 0,
) => {
if (callCounter >= maxCalls) return
const info = await reloadWalletInfo({ delay, force: false })
const newBalance = info.balanceSummary.calculatedTotalBalanceInSats
if (newBalance > currentBalance) {
await reloadWhileBalanceChangesRecursively(newBalance, delay, maxCalls, callCounter++)
}
}
reloadWalletInfo({ delay: RELOAD_WALLET_INFO_DELAY.AFTER_RESCAN, force: false })
.then((info) =>
reloadWhileBalanceChangesRecursively(
info.balanceSummary.calculatedTotalBalanceInSats,
RELOAD_WALLET_INFO_DELAY.AFTER_RESCAN,
MAX_RECURSIVE_WALLET_INFO_RELOADS,
),
)
.catch((err) => console.error(err))
},
[currentWallet, rescanningFinished, reloadWalletInfo],
)
return <></>
}

View file

@ -1,84 +0,0 @@
:root {
--jam-balance-color: #212529;
--jam-balance-deemphasize-color: #9eacba;
}
:root[data-theme='dark'] {
--jam-balance-color: #ffffff;
--jam-balance-deemphasize-color: #555c62;
}
.frozen {
--jam-balance-color: #0d6efd;
--jam-balance-deemphasize-color: #7eb2ff;
}
:root[data-theme='dark'] .frozen {
--jam-balance-color: #1372ff;
--jam-balance-deemphasize-color: #1153b5;
}
.balanceColor {
color: var(--jam-balance-color);
}
.hideSymbol {
padding-left: 0.1em;
color: var(--jam-balance-deemphasize-color);
}
.bitcoinSymbol {
order: -1;
width: 1em;
padding-right: 0.1em;
}
.satsSymbol {
order: 5;
}
.frozenSymbol {
order: 5;
}
.bitcoinAmount + .frozenSymbol {
order: -2;
width: 1em;
height: 1em;
}
.frozenSymbol,
.bitcoinSymbol,
.satsSymbol {
display: flex;
justify-content: center;
}
.bitcoinAmountSpacing .fractionalPart :nth-child(3)::before,
.bitcoinAmountSpacing .fractionalPart :nth-child(6)::before {
content: '\202F';
}
/** Integer Part **/
.bitcoinAmountColor[data-integer-part-is-zero="true"] .integerPart,
/** Decimal Point **/
.bitcoinAmountColor[data-integer-part-is-zero="true"] .decimalPoint,
.bitcoinAmountColor[data-fractional-part-starts-with-zero="true"] .decimalPoint,
/** Fractional Part **/
.bitcoinAmountColor[data-integer-part-is-zero="false"] .fractionalPart,
.bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]),
.bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]) + span[data-digit="0"],
.bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]) + span[data-digit="0"] + span[data-digit="0"],
.bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]) + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"],
.bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]) + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"],
.bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]) + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"],
.bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]) + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"],
.bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]) + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"],
/** Symbol */
.bitcoinAmountColor[data-raw-value="0"] + .bitcoinSymbol {
color: var(--jam-balance-deemphasize-color);
}
.satsAmountColor[data-raw-value='0'],
.satsAmountColor[data-raw-value='0'] + .satsSymbol {
color: var(--jam-balance-deemphasize-color);
}

View file

@ -1,199 +0,0 @@
import { render } from '@testing-library/react'
import user from '@testing-library/user-event'
import { screen } from '../testUtils'
import { BTC, SATS } from '../utils'
import Balance from './Balance'
describe('<Balance />', () => {
it('should render invalid param as given', () => {
render(<Balance valueString={'NaN'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByText(`NaN`)).toBeInTheDocument()
})
it('should render balance in BTC', () => {
render(<Balance valueString={'123.456'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`123.45600000`)
expect(screen.getByTestId('bitcoin-symbol')).toBeVisible()
expect(screen.queryByTestId('sats-symbol')).not.toBeInTheDocument()
expect(screen.queryByTestId('frozen-symbol')).not.toBeInTheDocument()
})
it('should render balance in SATS', () => {
render(<Balance valueString={'123.456'} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByTestId('sats-amount')).toHaveTextContent(`12,345,600,000`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
expect(screen.queryByTestId('bitcoin-symbol')).not.toBeInTheDocument()
expect(screen.queryByTestId('frozen-symbol')).not.toBeInTheDocument()
})
it('should hide balance for BTC by default', () => {
render(<Balance valueString={'123.456'} convertToUnit={BTC} />)
expect(screen.getByText(`*****`)).toBeInTheDocument()
expect(screen.queryByTestId('bitcoin-amount')).not.toBeInTheDocument()
expect(screen.queryByTestId('bitcoin-symbol')).not.toBeInTheDocument()
})
it('should hide balance for SATS by default', () => {
render(<Balance valueString={'123'} convertToUnit={SATS} />)
expect(screen.getByText(`*****`)).toBeInTheDocument()
expect(screen.queryByTestId(`sats-amount`)).not.toBeInTheDocument()
expect(screen.queryByTestId('sats-symbol')).not.toBeInTheDocument()
})
it('should render a string BTC value correctly as BTC', () => {
render(<Balance valueString={'123.03224961'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`123.03224961`)
expect(screen.getByTestId('bitcoin-symbol')).toBeVisible()
})
it('should render a string BTC value correctly as SATS', () => {
render(<Balance valueString={'123.03224961'} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`12,303,224,961`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
})
it('should render a zero string BTC value correctly as BTC', () => {
render(<Balance valueString={'0.00000000'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`0.00000000`)
})
it('should render a zero string BTC value correctly as SATS', () => {
render(<Balance valueString={'0.00000000'} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`0`)
})
it('should render a large string BTC value correctly as BTC', () => {
render(<Balance valueString={'20999999.97690000'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
})
it('should render a large string BTC value correctly as SATS', () => {
render(<Balance valueString={'20999999.97690000'} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
})
it('should render a max string BTC value correctly as BTC', () => {
render(<Balance valueString={'21000000.00000000'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
})
it('should render a max string BTC value correctly as SATS', () => {
render(<Balance valueString={'21000000.00000000'} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
})
it('should render a string SATS value correctly as SATS', () => {
render(<Balance valueString={'43000'} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`43,000`)
})
it('should render a string SATS value correctly as BTC', () => {
render(<Balance valueString={'43000'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`0.00043000`)
})
it('should render a zero string SATS value correctly as BTC', () => {
render(<Balance valueString={'0'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`0.00000000`)
})
it('should render a zero string SATS value correctly as SATS', () => {
render(<Balance valueString={'0'} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`0`)
})
it('should render a large string SATS value correctly as BTC', () => {
render(<Balance valueString={'2099999997690000'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
})
it('should render a large string SATS value correctly as SATS', () => {
render(<Balance valueString={'2099999997690000'} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
})
it('should render a max string SATS value correctly as BTC', () => {
render(<Balance valueString={'2100000000000000'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
})
it('should render a max string SATS value correctly as SATS', () => {
render(<Balance valueString={'2100000000000000'} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
})
it('should render frozen balance in BTC', () => {
render(<Balance valueString={'123.456'} convertToUnit={BTC} showBalance={true} frozen={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`123.45600000`)
expect(screen.getByTestId('bitcoin-symbol')).toBeVisible()
expect(screen.getByTestId('frozen-symbol')).toBeVisible()
})
it('should render frozen balance in SATS', () => {
render(<Balance valueString={'123.456'} convertToUnit={SATS} showBalance={true} frozen={true} />)
expect(screen.getByTestId('sats-amount')).toHaveTextContent(`12,345,600,000`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
expect(screen.getByTestId('frozen-symbol')).toBeVisible()
})
it('should render balance without symbol', () => {
render(<Balance valueString={'123.456'} convertToUnit={SATS} showBalance={true} frozen={true} showSymbol={false} />)
expect(screen.getByTestId('sats-amount')).toBeVisible()
expect(screen.getByTestId('frozen-symbol')).toBeVisible()
expect(screen.queryByTestId('sats-symbol')).not.toBeInTheDocument()
})
it('should toggle visibility of initially hidden balance on click by default', async () => {
render(<Balance valueString={`21`} convertToUnit={SATS} showBalance={false} />)
expect(screen.queryByTestId(`sats-amount`)).not.toBeInTheDocument()
expect(screen.getByText(`*****`)).toBeInTheDocument()
await user.click(screen.getByText(`*****`))
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
await user.click(screen.getByTestId(`sats-amount`))
expect(screen.queryByTestId(`sats-amount`)).not.toBeInTheDocument()
expect(screen.getByText(`*****`)).toBeInTheDocument()
})
it('should NOT toggle visibility of initially hidden balance on click when disabled via flag', async () => {
render(<Balance valueString={`21`} convertToUnit={SATS} showBalance={false} enableVisibilityToggle={false} />)
expect(screen.queryByTestId(`sats-amount`)).not.toBeInTheDocument()
expect(screen.getByText(`*****`)).toBeInTheDocument()
await user.click(screen.getByText(`*****`))
expect(screen.queryByTestId(`sats-amount`)).not.toBeInTheDocument()
expect(screen.getByText(`*****`)).toBeInTheDocument()
})
it('should NOT toggle visibility of initially visible balance on click by default', async () => {
render(<Balance valueString={`21`} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
await user.click(screen.getByTestId(`sats-amount`))
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
})
it('should toggle visibility of initially visible balance on click when enabled via flag', async () => {
render(<Balance valueString={`21`} convertToUnit={SATS} showBalance={true} enableVisibilityToggle={true} />)
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
await user.click(screen.getByTestId(`sats-amount`))
expect(screen.queryByTestId(`sats-amount`)).not.toBeInTheDocument()
expect(screen.getByText(`*****`)).toBeInTheDocument()
await user.click(screen.getByText(`*****`))
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
})
})

View file

@ -1,211 +0,0 @@
import { PropsWithChildren, MouseEventHandler, useEffect, useMemo, useState } from 'react'
import classNames from 'classnames'
import Sprite from './Sprite'
import { SATS, BTC, btcToSats, satsToBtc, isValidNumber, formatBtc, formatSats } from '../utils'
import styles from './Balance.module.css'
const DISPLAY_MODE_BTC = 0
const DISPLAY_MODE_SATS = 1
const DISPLAY_MODE_HIDDEN = 2
const getDisplayMode = (unit: Unit, showBalance: boolean) => {
if (showBalance && unit === SATS) return DISPLAY_MODE_SATS
if (showBalance && unit === BTC) return DISPLAY_MODE_BTC
return DISPLAY_MODE_HIDDEN
}
const BTC_SYMBOL = (
<span data-testid="bitcoin-symbol" className={styles.bitcoinSymbol}>
{'\u20BF'}
</span>
)
const SAT_SYMBOL = (
<Sprite data-testid="sats-symbol" className={styles.satsSymbol} symbol="sats" width="1.2em" height="1.2em" />
)
const FROZEN_SYMBOL = (
<Sprite
data-testid="frozen-symbol"
className={`${styles.frozenSymbol} frozen-symbol-hook`}
symbol="snowflake"
width="1.2em"
height="1.2em"
/>
)
interface BalanceComponentProps {
symbol?: React.ReactNode
showSymbol?: boolean
frozen?: boolean
colored?: boolean
frozenSymbol?: boolean
}
const BalanceComponent = ({
symbol,
showSymbol = true,
frozen = false,
colored = true,
frozenSymbol = true,
children,
}: PropsWithChildren<BalanceComponentProps>) => {
return (
<span
className={classNames('balance-hook', 'd-inline-flex align-items-center', {
[styles.frozen]: frozen,
[styles.balanceColor]: colored,
})}
>
{children}
{showSymbol && symbol}
{frozen && frozenSymbol && FROZEN_SYMBOL}
</span>
)
}
const DECIMAL_POINT_CHAR = '.'
type BitcoinBalanceProps = Omit<BalanceComponentProps, 'symbol'> & { value: number }
const BitcoinBalance = ({ value, colored = true, ...props }: BitcoinBalanceProps) => {
const numberString = formatBtc(value)
const [integerPart, fractionalPart] = numberString.split(DECIMAL_POINT_CHAR)
const fractionPartArray = fractionalPart.split('')
const integerPartIsZero = integerPart === '0'
const fractionalPartStartsWithZero = fractionPartArray[0] === '0'
return (
<BalanceComponent symbol={BTC_SYMBOL} colored={colored} {...props}>
<span
className={classNames(`slashed-zeroes`, styles.bitcoinAmount, styles.bitcoinAmountSpacing, {
[styles.bitcoinAmountColor]: colored,
})}
data-testid="bitcoin-amount"
data-integer-part-is-zero={integerPartIsZero}
data-fractional-part-starts-with-zero={fractionalPartStartsWithZero}
data-raw-value={value}
data-formatted-value={numberString}
>
<span className={styles.integerPart}>{integerPart}</span>
<span className={styles.decimalPoint}>{DECIMAL_POINT_CHAR}</span>
<span className={styles.fractionalPart}>
{fractionPartArray.map((digit, index) => (
<span key={index} data-digit={digit}>
{digit}
</span>
))}
</span>
</span>
</BalanceComponent>
)
}
type SatsBalanceProps = Omit<BalanceComponentProps, 'symbol'> & { value: number }
const SatsBalance = ({ value, colored = true, ...props }: SatsBalanceProps) => {
return (
<BalanceComponent symbol={SAT_SYMBOL} colored={colored} {...props}>
<span
className={classNames(`slashed-zeroes`, { [styles.satsAmountColor]: colored })}
data-testid="sats-amount"
data-raw-value={value}
>
{formatSats(value)}
</span>
</BalanceComponent>
)
}
/**
* Options argument for Balance component.
*
* @param {valueString}: The balance value to render.
* Integer values are treated as SATS while decimal numbers with a decimal point (.) are treated as BTC.
* For example:
* - 0, 10, 2100000000000000 are treated as a value in SATS; while
* - 0.00000000, 150.00000001, 21000000.00000000 are treated as a value in BTC.
* @param {convertToUnit}: The unit to convert the `valueString` to. Type {@link Unit}.
* @param {showBalance}: A flag indicating whether to render or hide the balance.
* Hidden balances are masked with `*****`.
* @param {loading}: A loading flag that renders a placeholder while true.
* @param {enableVisibilityToggle}: A flag that controls whether the balance can mask/unmask when clicked
*/
type BalanceProps = Omit<BalanceComponentProps, 'symbol'> & {
valueString: string
convertToUnit: Unit
showBalance?: boolean
enableVisibilityToggle?: boolean
}
/**
* Render balances nicely formatted.
*/
export default function Balance({
valueString,
convertToUnit,
showBalance = false,
enableVisibilityToggle = !showBalance,
...props
}: BalanceProps) {
const [isBalanceVisible, setIsBalanceVisible] = useState(showBalance)
const displayMode = useMemo(() => getDisplayMode(convertToUnit, isBalanceVisible), [convertToUnit, isBalanceVisible])
useEffect(() => {
setIsBalanceVisible(showBalance)
}, [showBalance])
const toggleVisibility: MouseEventHandler = (e) => {
e.preventDefault()
e.stopPropagation()
setIsBalanceVisible((current) => !current)
}
const balanceComponent = useMemo(() => {
if (displayMode === DISPLAY_MODE_HIDDEN) {
return (
<BalanceComponent
symbol={<Sprite symbol="hide" width="1.2em" height="1.2em" className={styles.hideSymbol} />}
{...props}
>
<span className="slashed-zeroes">{'*****'}</span>
</BalanceComponent>
)
}
const valueNumber = parseFloat(valueString)
if (!isValidNumber(valueNumber)) {
console.warn('<Balance /> component expects number input as string')
return <BalanceComponent {...props}>{valueString}</BalanceComponent>
}
// Treat integers as sats.
const valueIsSats = valueString === parseInt(valueString, 10).toString()
// Treat decimal numbers as btc.
const valueIsBtc = !valueIsSats && valueString.indexOf('.') > -1
if (valueIsBtc && displayMode === DISPLAY_MODE_BTC) return <BitcoinBalance value={valueNumber} {...props} />
if (valueIsSats && displayMode === DISPLAY_MODE_SATS) return <SatsBalance value={valueNumber} {...props} />
if (valueIsBtc && displayMode === DISPLAY_MODE_SATS)
return <SatsBalance value={btcToSats(valueString)} {...props} />
if (valueIsSats && displayMode === DISPLAY_MODE_BTC)
return <BitcoinBalance value={satsToBtc(valueString)} {...props} />
console.warn('<Balance /> component cannot determine balance format')
return <BalanceComponent {...props}>{valueString}</BalanceComponent>
}, [valueString, displayMode, props])
if (!enableVisibilityToggle) {
return <>{balanceComponent}</>
} else {
return (
<span onClick={toggleVisibility} className="cursor-pointer">
{balanceComponent}
</span>
)
}
}

View file

@ -1,119 +0,0 @@
import user from '@testing-library/user-event'
import { render, screen } from '../testUtils'
import { noop } from '../utils'
import BitcoinAmountInput, { AmountValue, BitcoinAmountInputProps } from './BitcoinAmountInput'
import { Formik } from 'formik'
describe('<BitcoinAmountInput />', () => {
const setup = (props: Omit<BitcoinAmountInputProps, 'field' | 'form'>) => {
render(
<Formik initialValues={{ value: undefined }} onSubmit={noop}>
{(form) => {
const valueField = form.getFieldProps<AmountValue>('value')
return <BitcoinAmountInput form={form} field={valueField} {...props} />
}}
</Formik>,
)
}
it('should render without errors', () => {
setup({
label: 'test-label',
})
const inputElement = screen.getByLabelText('test-label')
expect(inputElement).toBeVisible()
expect(inputElement.dataset.value).toBe(undefined)
expect(inputElement.dataset.displayUnit).toBe(undefined)
expect(inputElement.dataset.displayValue).toBe(undefined)
})
it('amount can be entered in sats', async () => {
setup({
label: 'test-label',
})
const inputElement = screen.getByLabelText('test-label')
await user.type(inputElement, '123456')
expect(inputElement).toHaveFocus()
expect(inputElement.dataset.value).toBe(`123456`)
expect(inputElement.dataset.displayUnit).toBe(`sats`)
expect(inputElement.dataset.displayValue).toBe(`123456`)
// changes to display unit to BTC after element loses focus
await user.tab()
expect(inputElement).not.toHaveFocus()
expect(inputElement.dataset.value).toBe(`123456`)
expect(inputElement.dataset.displayUnit).toBe(`BTC`)
expect(inputElement.dataset.displayValue).toBe(`0.00 123 456`)
})
it('amount can be entered in BTC', async () => {
setup({
label: 'test-label',
})
const inputElement = screen.getByLabelText('test-label')
await user.type(inputElement, '1.234')
expect(inputElement).toHaveFocus()
expect(inputElement.dataset.value).toBe(`123400000`)
expect(inputElement.dataset.displayUnit).toBe(`BTC`)
expect(inputElement.dataset.displayValue).toBe(`1.234`)
// keeps display unit in BTC after element loses focus
await user.tab()
expect(inputElement).not.toHaveFocus()
expect(inputElement.dataset.value).toBe(`123400000`)
expect(inputElement.dataset.displayUnit).toBe(`BTC`)
expect(inputElement.dataset.displayValue).toBe(`1.23 400 000`)
})
it('given input must be a number', async () => {
setup({
label: 'test-label',
})
const inputElement = screen.getByLabelText('test-label')
await user.type(inputElement, 'test')
expect(inputElement).toHaveFocus()
expect(inputElement.dataset.value).toBe(undefined)
expect(inputElement.dataset.displayUnit).toBe(undefined)
expect(inputElement.dataset.displayValue).toBe(undefined)
await user.tab()
expect(inputElement).not.toHaveFocus()
expect(inputElement.dataset.value).toBe(undefined)
expect(inputElement.dataset.displayUnit).toBe(undefined)
expect(inputElement.dataset.displayValue).toBe('')
})
it('amount 1.0 should be interpreted as 1 BTC', async () => {
setup({
label: 'test-label',
})
const inputElement = screen.getByLabelText('test-label')
await user.type(inputElement, '1.0')
expect(inputElement).toHaveFocus()
expect(inputElement.dataset.value).toBe('100000000')
expect(inputElement.dataset.displayUnit).toBe('BTC')
expect(inputElement.dataset.displayValue).toBe(`1.0`)
await user.tab()
expect(inputElement).not.toHaveFocus()
expect(inputElement.dataset.value).toBe('100000000')
expect(inputElement.dataset.displayUnit).toBe('BTC')
expect(inputElement.dataset.displayValue).toBe(`1.00 000 000`)
})
})

View file

@ -1,181 +0,0 @@
import { PropsWithChildren, forwardRef, useMemo, useState } from 'react'
import * as rb from 'react-bootstrap'
import classNames from 'classnames'
import { FieldInputProps, FormikContextType } from 'formik'
import Sprite from './Sprite'
import * as Api from '../libs/JmWalletApi'
import { BITCOIN_SYMBOL, formatBtcDisplayValue, isValidNumber } from '../utils'
export type AmountValue = {
value: Api.AmountSats | null
isSweep: boolean
userRawInputValue?: string
displayValue?: string
}
export const toAmountValue = (value: Api.AmountSats): AmountValue => ({
value,
isSweep: false,
userRawInputValue: String(value),
displayValue: formatBtcDisplayValue(value),
})
const unitFromValue = (value: string | undefined): Unit | undefined => {
return value !== undefined && value !== '' ? (value?.includes('.') ? 'BTC' : 'sats') : undefined
}
export type BitcoinAmountInputProps = {
label: string
className?: string
inputGroupTextClassName?: string
disabled?: boolean
placeholder?: string
field: FieldInputProps<AmountValue | undefined>
form: FormikContextType<any>
}
const BitcoinAmountInput = forwardRef(
(
{
label,
className,
inputGroupTextClassName,
disabled,
placeholder,
field,
form,
children,
}: PropsWithChildren<BitcoinAmountInputProps>,
ref: React.Ref<HTMLInputElement>,
) => {
const [inputType, setInputType] = useState<{ type: 'text' | 'number'; inputMode?: 'decimal' }>({
type: 'text',
inputMode: 'decimal',
})
const displayInputUnit = useMemo(() => {
return inputType.type === 'number'
? unitFromValue(field.value?.userRawInputValue)
: field.value?.displayValue
? 'BTC'
: undefined
}, [field, inputType])
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
setInputType({
type: 'text',
inputMode: 'decimal',
})
let displayValue = String(field.value?.value || '')
if (isValidNumber(field.value?.value)) {
displayValue = formatBtcDisplayValue(field.value!.value!)
}
form.setFieldValue(
field.name,
{
...field.value,
displayValue,
},
false,
)
field.onBlur(e)
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const rawUserInputOrEmpty = e.target.value ?? ''
const validNumberRegex = /^-?\d*\.?\d*$/
if (!validNumberRegex.test(rawUserInputOrEmpty)) {
return
}
const floatValueOrNan = parseFloat(rawUserInputOrEmpty)
if (!isValidNumber(floatValueOrNan)) {
form.setFieldValue(
field.name,
{
...field.value,
value: null,
userRawInputValue: e.target.value,
displayValue: e.target.value,
},
true,
)
return
} else {
const value: number = floatValueOrNan
let numberValues: string | undefined
const unit =
rawUserInputOrEmpty.includes('.') && parseFloat(rawUserInputOrEmpty)
? unitFromValue(String(rawUserInputOrEmpty))
: unitFromValue(String(value))
if (unit === 'BTC') {
const splitted = String(value).split('.')
const [integerPart, fractionalPart = ''] = splitted
const paddedFractionalPart = fractionalPart.padEnd(8, '0').substring(0, 8)
numberValues = `${integerPart}${paddedFractionalPart}`
} else {
numberValues = value.toLocaleString('en-US', {
maximumFractionDigits: 0,
useGrouping: false,
})
}
form.setFieldValue(
field.name,
{
value: parseInt(numberValues, 10),
userRawInputValue: e.target.value,
displayValue: e.target.value,
},
true,
)
}
}
return (
<>
<rb.InputGroup hasValidation={true}>
<rb.InputGroup.Text className={inputGroupTextClassName}>
{displayInputUnit === undefined && <></>}
{displayInputUnit === 'sats' && <Sprite symbol="sats" width="24" height="24" />}
{displayInputUnit === 'BTC' && <span style={{ fontSize: '1.175rem' }}>{BITCOIN_SYMBOL}</span>}
</rb.InputGroup.Text>
<rb.Form.Control
ref={ref}
aria-label={label}
data-value={field.value?.value}
data-display-unit={displayInputUnit}
data-display-value={field.value?.displayValue}
name={field.name}
autoComplete="off"
inputMode={inputType.inputMode}
className={classNames('slashed-zeroes', className)}
value={
inputType.type === 'text'
? (field.value?.displayValue ?? '')
: String(field.value?.userRawInputValue ?? '')
}
placeholder={placeholder}
min={displayInputUnit === 'BTC' ? '0.00000001' : '1'}
step={displayInputUnit === 'BTC' ? '0.00000001' : '1'}
isInvalid={form.touched[field.name] && !!form.errors[field.name]}
disabled={disabled}
required
onFocus={() => {
setInputType({ type: 'number' })
}}
onBlur={handleBlur}
onChange={handleChange}
/>
{children}
<rb.Form.Control.Feedback type="invalid">
<>{form.errors[field.name]}</>
</rb.Form.Control.Feedback>
</rb.InputGroup>
</>
)
},
)
export default BitcoinAmountInput

View file

@ -1,41 +0,0 @@
import { useEffect, useState } from 'react'
import QRCode from 'qrcode'
import { satsToBtc } from '../utils'
import { AmountSats, BitcoinAddress } from '../libs/JmWalletApi'
interface BitcoinQRProps {
address: BitcoinAddress
amount?: AmountSats
errorCorrectionLevel?: QRCode.QRCodeErrorCorrectionLevel
width?: number
}
export const BitcoinQR = ({ address, amount, errorCorrectionLevel = 'H', width = 260 }: BitcoinQRProps) => {
const [data, setData] = useState<string>()
const [image, setImage] = useState<string>()
useEffect(() => {
const btc = amount ? satsToBtc(String(amount)) || 0 : 0
const uri = `bitcoin:${address}${btc > 0 ? `?amount=${btc.toFixed(8)}` : ''}`
QRCode.toDataURL(uri, {
errorCorrectionLevel,
width,
})
.then((val) => {
setImage(val)
setData(uri)
})
.catch(() => {
setImage(undefined)
setData(uri)
})
}, [address, amount, errorCorrectionLevel, width])
return (
<div>
<img src={image} alt={data} title={data} />
</div>
)
}

View file

@ -1,48 +0,0 @@
.cheatsheet {
height: auto !important;
/* page height - navbar height - some spacing*/
max-height: calc(100% - 4.75rem - 1rem) !important;
margin: 0px auto !important;
border: none !important;
border-top-left-radius: 1rem;
border-top-right-radius: 1rem;
width: 33rem;
box-shadow: 6px -3px 12px 3px rgba(0, 0, 0, 0.1);
}
:root[data-theme='dark'] .cheatsheet {
box-shadow: 6px -3px 12px 3px rgba(0, 0, 0, 0.5);
}
.cheatsheet a {
color: inherit !important;
}
.cheatsheet .cheatsheet-list-item {
align-items: start;
}
.cheatsheet-list-item.upcoming-feature {
opacity: 0.25;
}
.cheatsheet-list-item h6 {
margin-bottom: 0.1rem;
}
.numbered {
display: flex;
justify-content: center;
align-items: center;
min-width: 2rem;
height: 2rem;
border-radius: 50%;
background-color: rgb(0, 0, 0);
color: white;
}
:root[data-theme='dark'] .numbered {
color: rgb(0, 0, 0);
background-color: white;
}

View file

@ -1,129 +0,0 @@
import { PropsWithChildren } from 'react'
import * as rb from 'react-bootstrap'
import { Link } from 'react-router-dom'
import { Trans, useTranslation } from 'react-i18next'
import { routes } from '../constants/routes'
import Sprite from './Sprite'
import styles from './Cheatsheet.module.css'
interface CheatsheetProps {
show: boolean
onHide: () => void
}
type NumberedProps = {
number: number | 'last'
className?: string
}
function Numbered({ number }: NumberedProps) {
return (
<div className={styles.numbered}>
{number === 'last' ? (
<>
<Sprite symbol="checkmark" width="24" height="24" />
</>
) : (
<>{number}</>
)}
</div>
)
}
function ListItem({ number, children, ...props }: PropsWithChildren<NumberedProps>) {
return (
<rb.Stack className={`${styles['cheatsheet-list-item']} ${props.className || ''}`} direction="horizontal" gap={3}>
<Numbered number={number} />
<rb.Stack gap={0}>{children}</rb.Stack>
</rb.Stack>
)
}
export default function Cheatsheet({ show = false, onHide }: CheatsheetProps) {
const { t } = useTranslation()
return (
<rb.Offcanvas className={styles.cheatsheet} show={show} onHide={onHide} placement="bottom" onClick={onHide}>
<rb.Offcanvas.Header>
<rb.Stack>
<rb.Offcanvas.Title>{t('cheatsheet.title')}</rb.Offcanvas.Title>
<div className="small text-secondary">
<Trans i18nKey="cheatsheet.description">
Follow the steps below to increase your financial privacy. It is advisable to switch from{' '}
<a href="https://jamdocs.org/glossary/#maker" target="_blank" rel="noopener noreferrer">
earning as a maker
</a>{' '}
to{' '}
<a href="https://jamdocs.org/glossary/#taker" target="_blank" rel="noopener noreferrer">
sending as a taker
</a>{' '}
back and forth.{' '}
<a href="https://jamdocs.org/interface/00-cheatsheet/" target="_blank" rel="noopener noreferrer">
Learn more.
</a>
</Trans>
</div>
</rb.Stack>
<rb.Button variant="link" className="unstyled p-0 mb-auto" onClick={onHide}>
<Sprite symbol="cancel" width="32" height="32" />
</rb.Button>
</rb.Offcanvas.Header>
<rb.Offcanvas.Body>
<rb.Stack className="mb-4" gap={4}>
<ListItem number={1}>
<h6>
<Trans i18nKey="cheatsheet.receive.title">
<Link to={routes.receive}>Fund</Link> your wallet.
</Trans>
</h6>
<div className="small text-secondary">{t('cheatsheet.receive.description')}</div>
</ListItem>
<ListItem number={2}>
<h6>
<Trans i18nKey="cheatsheet.send.title">
<Link to={routes.send}>Send</Link> a collaborative transaction to another jar.
</Trans>
</h6>
<div className="small text-secondary">{t('cheatsheet.send.description')}</div>
</ListItem>
<ListItem number={3}>
<h6>
<Trans i18nKey="cheatsheet.bond.title">
Optional: <Link to={routes.earn}>Lock</Link> funds in a fidelity bond.
</Trans>
</h6>
<div className="small text-secondary">{t('cheatsheet.bond.description')}</div>
</ListItem>
<ListItem number={4}>
<h6>
<Trans i18nKey="cheatsheet.earn.title">
<Link to={routes.earn}>Earn</Link> sats by providing liquidity.
</Trans>
</h6>
<div className="small text-secondary">{t('cheatsheet.earn.description')}</div>
</ListItem>
<ListItem number={5}>
<h6>
<Trans i18nKey="cheatsheet.schedule.title">
Schedule <Link to={routes.jam}>sweep</Link> transactions to empty your wallet.
</Trans>
</h6>
<div className="small text-secondary">{t('cheatsheet.schedule.description')}</div>
</ListItem>
<ListItem number={'last'}>
<h6>{t('cheatsheet.repeat.title')}</h6>
<div className="small text-secondary">
<Trans i18nKey="cheatsheet.repeat.description">
Still confused?{' '}
<a href="https://jamdocs.org/interface/00-cheatsheet/" target="_blank" rel="noopener noreferrer">
Dig into the documentation
</a>
.
</Trans>
</div>
</ListItem>
</rb.Stack>
</rb.Offcanvas.Body>
</rb.Offcanvas>
)
}

View file

@ -1,100 +0,0 @@
import { Ref, forwardRef } from 'react'
import * as rb from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { useSettings } from '../context/SettingsContext'
import { CoinjoinRequirementSummary } from '../hooks/CoinjoinRequirements'
import { jarInitial } from './jars/Jar'
import { shortenStringMiddle } from '../utils'
import Sprite from './Sprite'
import Balance from './Balance'
interface CoinjoinPreconditionViolationAlertProps {
summary: CoinjoinRequirementSummary
i18nPrefix?: string
}
export const CoinjoinPreconditionViolationAlert = forwardRef(
({ summary, i18nPrefix = '' }: CoinjoinPreconditionViolationAlertProps, ref: Ref<HTMLDivElement>) => {
const { t } = useTranslation()
const settings = useSettings()
if (summary.isFulfilled) return <></>
if (summary.numberOfMissingUtxos > 0) {
return (
<rb.Alert variant="warning" ref={ref}>
{t(`${i18nPrefix}hint_missing_utxos`, {
minConfirmations: summary.options.minConfirmations,
})}
</rb.Alert>
)
}
if (summary.numberOfMissingConfirmations > 0) {
return (
<rb.Alert variant="warning" ref={ref}>
{t(`${i18nPrefix}hint_missing_confirmations`, {
minConfirmations: summary.options.minConfirmations,
amountOfMissingConfirmations: summary.numberOfMissingConfirmations,
})}
</rb.Alert>
)
}
const utxosViolatingRetriesLeft = summary.violations
.map((it) => it.utxosViolatingRetriesLeft)
.reduce((acc, utxos) => acc.concat(utxos), [])
if (utxosViolatingRetriesLeft.length > 0) {
return (
<rb.Alert variant="warning" ref={ref}>
<>
<Trans i18nKey={`${i18nPrefix}hint_missing_retries`}>
You tried too many times. See
<a
href="https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/v0.9.7/docs/SOURCING-COMMITMENTS.md"
target="_blank"
rel="noopener noreferrer"
>
the docs
</a>{' '}
for more info.
</Trans>
<br />
<br />
<Trans i18nKey={`${i18nPrefix}hint_missing_retries_detail`} count={utxosViolatingRetriesLeft.length}>
Following utxos have been used unsuccessfully too many times:
<ul className="mt-2 mb-0 ps-2">
{utxosViolatingRetriesLeft.map((utxo, index) => (
<li key={index} className="mb-2 slashed-zeroes small" style={{ display: 'inline-flex' }}>
<span className="pe-1" style={{ display: 'inline-flex' }}>
<Sprite symbol="jar-closed-fill-50" width="20" height="20" />
<span className="slashed-zeroes">
<strong>{jarInitial(utxo.mixdepth)}</strong>
</span>
:
</span>
<div>
<span>{utxo.address}</span>
&nbsp;(
<Balance
valueString={`${utxo.value}`}
convertToUnit={settings.unit}
showBalance={settings.showBalance}
/>
)
<br />
<small>{shortenStringMiddle(utxo.utxo, 32)}</small>
</div>
</li>
))}
</ul>
</Trans>
</>
</rb.Alert>
)
}
return <></>
},
)

View file

@ -1,130 +0,0 @@
import { ReactNode, PropsWithChildren, useState, useEffect, useRef } from 'react'
const copyToClipboard = (
text: string,
fallbackInputField: HTMLInputElement,
errorMessage?: string,
): Promise<boolean> => {
const copyToClipboardFallback = (
inputField: HTMLInputElement,
errorMessage = 'Cannot copy value to clipboard',
): Promise<boolean> =>
new Promise((resolve, reject) => {
inputField.select()
const success = document.execCommand && document.execCommand('copy')
inputField.blur()
success ? resolve(success) : reject(new Error(errorMessage))
})
// The `navigator.clipboard` API might not be available, e.g. on sites served over HTTP.
if (!navigator.clipboard) {
return copyToClipboardFallback(fallbackInputField)
}
return navigator.clipboard
.writeText(text)
.then(() => true)
.catch((e: Error) => {
if (fallbackInputField) {
return copyToClipboardFallback(fallbackInputField, errorMessage)
} else {
throw e
}
})
}
interface CopyableProps {
value: string
onSuccess?: () => void
onError?: (e: Error) => void
className?: string
disabled?: boolean
}
function Copyable({
value,
onSuccess,
onError,
className,
children,
disabled,
...props
}: PropsWithChildren<CopyableProps>) {
const valueFallbackInputRef = useRef(null)
return (
<>
<button
{...props}
type="button"
disabled={disabled}
className={className}
onClick={() => copyToClipboard(value, valueFallbackInputRef.current!).then(onSuccess, onError)}
>
{children}
</button>
<input
readOnly
aria-hidden
ref={valueFallbackInputRef}
value={value}
style={{
position: 'absolute',
left: '-9999px',
top: '-9999px',
}}
/>
</>
)
}
interface CopyButtonProps extends CopyableProps {
text: ReactNode
successText?: ReactNode
successTextTimeout?: number
disabled?: boolean
}
export function CopyButton({
value,
onSuccess,
onError,
text,
successText = text,
successTextTimeout = 1_500,
className,
disabled,
...props
}: CopyButtonProps) {
const [showValueCopiedConfirmation, setShowValueCopiedConfirmation] = useState(false)
const [valueCopiedFlag, setValueCopiedFlag] = useState(0)
useEffect(() => {
if (valueCopiedFlag < 1) return
setShowValueCopiedConfirmation(true)
const timer = setTimeout(() => {
setShowValueCopiedConfirmation(false)
}, successTextTimeout)
return () => clearTimeout(timer)
}, [valueCopiedFlag, successTextTimeout])
return (
<Copyable
{...props}
disabled={disabled}
className={`btn ${className || ''}`}
value={value}
onError={onError}
onSuccess={() => {
setValueCopiedFlag((current) => current + 1)
onSuccess && onSuccess()
}}
>
<div className="d-flex align-items-center justify-content-center">
{showValueCopiedConfirmation ? successText : text}
</div>
</Copyable>
)
}

View file

@ -1,247 +0,0 @@
import { BrowserRouter } from 'react-router-dom'
import user from '@testing-library/user-event'
import { render, screen, act } from '../testUtils'
import { __testSetDebugFeatureEnabled } from '../constants/debugFeatures'
import * as apiMock from '../libs/JmWalletApi'
import { DUMMY_MNEMONIC_PHRASE } from '../utils'
import CreateWallet from './CreateWallet'
jest.mock('../libs/JmWalletApi', () => ({
...jest.requireActual('../libs/JmWalletApi'),
getGetinfo: jest.fn(),
getSession: jest.fn(),
postWalletCreate: jest.fn(),
getWalletAll: jest.fn(),
}))
const NOOP = () => {}
describe('<CreateWallet />', () => {
const testWalletName = 'test_wallet21'
const invalidTestWalletName = 'invalid_wallet_name!'
const testWalletPassword = 'correct horse battery staple'
const setup = ({
startWallet = NOOP,
}: {
startWallet?: (name: apiMock.WalletFileName, auth: apiMock.ApiAuthContext) => void
}) => {
render(
<BrowserRouter>
<CreateWallet startWallet={startWallet} parentRoute="home" />
</BrowserRouter>,
)
}
beforeEach(() => {
const neverResolvingPromise = new Promise(() => {})
;(apiMock.getGetinfo as jest.Mock).mockReturnValue(neverResolvingPromise)
;(apiMock.getSession as jest.Mock).mockReturnValue(neverResolvingPromise)
;(apiMock.getWalletAll as jest.Mock).mockReturnValue(neverResolvingPromise)
})
it('should display alert when rescanning is active', async () => {
;(apiMock.getSession as jest.Mock).mockReturnValue(
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
rescanning: true,
}),
}),
)
await act(async () => setup({}))
expect(screen.getByText('create_wallet.title')).toBeVisible()
expect(screen.getByTestId('alert-rescanning')).toBeVisible()
expect(screen.queryByText('create_wallet.button_create')).not.toBeInTheDocument()
})
it('should render without errors', () => {
setup({})
expect(screen.getByText('create_wallet.title')).toBeVisible()
expect(screen.getByLabelText('create_wallet.label_wallet_name')).toBeVisible()
expect(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name')).toBeVisible()
expect(screen.getByLabelText('create_wallet.label_password')).toBeVisible()
expect(screen.getByPlaceholderText('create_wallet.placeholder_password')).toBeVisible()
expect(screen.getByLabelText('create_wallet.label_password_confirm')).toBeVisible()
expect(screen.getByPlaceholderText('create_wallet.placeholder_password_confirm')).toBeVisible()
expect(screen.getByText('create_wallet.button_create')).toBeVisible()
})
it('should show validation messages to user if form is invalid', async () => {
setup({})
expect(await screen.queryByText('create_wallet.feedback_invalid_wallet_name')).not.toBeInTheDocument()
expect(await screen.queryByText('create_wallet.feedback_invalid_password')).not.toBeInTheDocument()
expect(await screen.queryByText('create_wallet.feedback_invalid_password_confirm')).not.toBeInTheDocument()
expect(await screen.findByText('create_wallet.button_create')).toBeVisible()
// click on the "create" button without filling the form
await user.click(screen.getByText('create_wallet.button_create'))
expect(await screen.findByText('create_wallet.feedback_invalid_wallet_name')).toBeVisible()
expect(await screen.findByText('create_wallet.feedback_invalid_password')).toBeVisible()
expect(await screen.findByText('create_wallet.feedback_invalid_password_confirm')).toBeVisible()
})
it('should show validation message to user if duplicate wallet name', async () => {
;(apiMock.getWalletAll as jest.Mock).mockReturnValue(
Promise.resolve({
ok: true,
json: () => Promise.resolve({ wallets: [`${testWalletName}.jmdat`] }),
}),
)
setup({})
expect(await screen.queryByText('create_wallet.feedback_wallet_name_already_exists')).not.toBeInTheDocument()
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name'), testWalletName)
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password'), testWalletPassword)
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password_confirm'), testWalletPassword)
await user.click(screen.getByText('create_wallet.button_create'))
expect(await screen.findByText('create_wallet.feedback_wallet_name_already_exists')).toBeVisible()
})
it('should not submit form if wallet name contains invalid characters', async () => {
setup({})
expect(await screen.queryByText('create_wallet.feedback_invalid_wallet_name')).not.toBeInTheDocument()
expect(await screen.queryByText('create_wallet.feedback_invalid_password_confirm')).not.toBeInTheDocument()
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name'), invalidTestWalletName)
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password'), testWalletPassword)
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password_confirm'), testWalletPassword)
await user.click(screen.getByText('create_wallet.button_create'))
expect(await screen.findByText('create_wallet.button_create')).toBeVisible()
expect(await screen.findByText('create_wallet.feedback_invalid_wallet_name')).toBeVisible()
expect(await screen.queryByText('create_wallet.feedback_invalid_password_confirm')).not.toBeInTheDocument()
})
it('should not submit form if passwords do not match', async () => {
setup({})
expect(await screen.findByPlaceholderText('create_wallet.placeholder_password')).toBeVisible()
expect(await screen.findByPlaceholderText('create_wallet.placeholder_password_confirm')).toBeVisible()
expect(await screen.queryByText('create_wallet.feedback_invalid_wallet_name')).not.toBeInTheDocument()
expect(await screen.queryByText('create_wallet.feedback_invalid_password_confirm')).not.toBeInTheDocument()
expect(await screen.findByText('create_wallet.button_create')).toBeVisible()
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name'), testWalletName)
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password'), '.*')
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password_confirm'), 'a_mismatching_input')
await user.click(screen.getByText('create_wallet.button_create'))
expect(await screen.findByText('create_wallet.button_create')).toBeVisible()
expect(await screen.queryByText('create_wallet.feedback_invalid_wallet_name')).not.toBeInTheDocument()
expect(await screen.findByText('create_wallet.feedback_invalid_password_confirm')).toBeVisible()
})
it('should advance to WalletCreationConfirmation after wallet is created', async () => {
;(apiMock.postWalletCreate as jest.Mock).mockReturnValue(
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
walletname: `${testWalletName}.jmdat`,
token: 'ANY_TOKEN',
seedphrase: DUMMY_MNEMONIC_PHRASE.join(' '),
}),
}),
)
setup({})
expect(await screen.findByText('create_wallet.button_create')).toBeVisible()
expect(await screen.queryByText('create_wallet.title_wallet_created')).not.toBeInTheDocument()
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name'), testWalletName)
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password'), testWalletPassword)
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password_confirm'), testWalletPassword)
await user.click(screen.getByText('create_wallet.button_create'))
expect(screen.getByText('create_wallet.title_wallet_created')).toBeVisible()
expect(screen.queryByText('create_wallet.button_create')).not.toBeInTheDocument()
})
it('should verify that "skip" button is NOT visible by default (feature is disabled)', async () => {
;(apiMock.postWalletCreate as jest.Mock).mockReturnValue(
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
walletname: `${testWalletName}.jmdat`,
token: 'ANY_TOKEN',
seedphrase: DUMMY_MNEMONIC_PHRASE.join(' '),
}),
}),
)
setup({})
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name'), testWalletName)
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password'), testWalletPassword)
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password_confirm'), testWalletPassword)
const createWalletButton = screen.getByText('create_wallet.button_create')
await user.click(createWalletButton)
const revealToggle = await screen.findByText('create_wallet.confirmation_toggle_reveal_info')
await user.click(revealToggle)
const confirmToggle = screen.getByText('create_wallet.confirmation_toggle_info_written_down')
await user.click(confirmToggle)
const nextButton = screen.getByText('create_wallet.next_button')
await user.click(nextButton)
expect(screen.queryByText('create_wallet.skip_button')).not.toBeInTheDocument()
expect(screen.getByText('create_wallet.back_button')).toBeVisible()
expect(screen.getByText('create_wallet.confirmation_button_fund_wallet')).toBeDisabled()
})
it('should verify that "skip" button IS visible when feature is enabled', async () => {
__testSetDebugFeatureEnabled('skipWalletBackupConfirmation', true)
;(apiMock.postWalletCreate as jest.Mock).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
walletname: `${testWalletName}.jmdat`,
token: 'ANY_TOKEN',
seedphrase: DUMMY_MNEMONIC_PHRASE.join(' '),
}),
})
setup({})
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name'), testWalletName)
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password'), testWalletPassword)
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password_confirm'), testWalletPassword)
const createWalletButton = screen.getByText('create_wallet.button_create')
await user.click(createWalletButton)
const revealToggle = await screen.findByText('create_wallet.confirmation_toggle_reveal_info')
await user.click(revealToggle)
const confirmToggle = screen.getByText('create_wallet.confirmation_toggle_info_written_down')
await user.click(confirmToggle)
const nextButton = screen.getByText('create_wallet.next_button')
await user.click(nextButton)
expect(screen.getByText('create_wallet.skip_button')).toBeVisible()
expect(screen.getByText('create_wallet.back_button')).toBeVisible()
expect(screen.getByText('create_wallet.confirmation_button_fund_wallet')).toBeDisabled()
})
})

View file

@ -1,222 +1,280 @@
import { useState, useCallback, useMemo } from 'react'
import * as rb from 'react-bootstrap'
import { Link, useNavigate } from 'react-router-dom'
import { Trans, useTranslation } from 'react-i18next'
import PageTitle from './PageTitle'
import Sprite from './Sprite'
import WalletCreationConfirmation, { CreatedWalletInfo } from './WalletCreationConfirmation'
import PreventLeavingPageByMistake from './PreventLeavingPageByMistake'
import WalletCreationForm, { CreateWalletFormValues } from './WalletCreationForm'
import MnemonicPhraseInput from './MnemonicPhraseInput'
import { walletDisplayName, walletDisplayNameToFileName } from '../utils'
import { useServiceInfo } from '../context/ServiceInfoContext'
import * as Api from '../libs/JmWalletApi'
import { Route, routes } from '../constants/routes'
import { isDebugFeatureEnabled } from '../constants/debugFeatures'
import React, { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { setSession, clearSession } from '@/lib/session'
import { formatWalletName } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { AlertCircle, Wallet, Lock, Loader2, Eye, EyeOff } from 'lucide-react'
import { toast } from 'sonner'
import { createwallet, session, type CreateWalletResponse } from '@/lib/jm-api/generated/client'
type CreatedWalletWithAuth = CreatedWalletInfo & {
auth: Api.ApiAuthContext
}
const CreateWallet = () => {
const navigate = useNavigate()
const [walletName, setWalletName] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [createWalletResponse, setCreateWalletResponse] = useState<CreateWalletResponse>()
const [step, setStep] = useState<'create' | 'seed' | 'confirm'>('create')
interface BackupConfirmationProps {
wallet: CreatedWalletInfo
onSuccess: () => void
onCancel: () => void
}
const handleCreateWallet = async (e: React.FormEvent) => {
e.preventDefault()
const BackupConfirmation = ({ wallet, onSuccess, onCancel }: BackupConfirmationProps) => {
const { t } = useTranslation()
// Validation
if (!walletName.trim()) {
toast.error('Wallet name is required')
return
}
const seedphrase = useMemo(() => wallet.seedphrase.split(' '), [wallet])
const [givenWords, setGivenWords] = useState(new Array(seedphrase.length).fill(''))
const [showSkipButton] = useState(isDebugFeatureEnabled('skipWalletBackupConfirmation'))
if (password.length < 8) {
toast.error('Password must be at least 8 characters long')
return
}
const isSeedBackupConfirmed = useMemo(
() => givenWords.every((word, index) => word === seedphrase[index]),
[givenWords, seedphrase],
if (password !== confirmPassword) {
toast.error('Passwords do not match')
return
}
try {
setIsLoading(true)
// Clear any existing local session
clearSession()
// Check if there's an active session on the server
try {
const { data: sessionInfo } = await session()
if (sessionInfo?.session || sessionInfo?.wallet_name !== 'None') {
console.warn('Active session detected:', sessionInfo)
toast.error(
`Cannot create wallet as "${formatWalletName(
sessionInfo?.wallet_name || 'Unknown',
)}" wallet is currently active.`,
{
description: (
<div className="text-black dark:text-white">
Alternatively, you can{' '}
<Link to="/login" className="underline hover:no-underline font-medium">
log in with the existing wallet
</Link>{' '}
instead.
</div>
),
duration: 8000,
},
)
return
}
} catch (sessionError) {
console.warn('Could not check session status:', sessionError)
// Continue anyway, wallet creation might still work
}
const walletFileName = walletName.endsWith('.jmdat') ? walletName : `${walletName}.jmdat`
const { data: response, error: createError } = await createwallet({
body: {
walletname: walletFileName,
password,
wallettype: 'sw-fb',
},
})
if (createError) {
throw createError
}
if (response?.seedphrase) {
setCreateWalletResponse(response)
setStep('seed')
} else {
throw new Error('No seedphrase returned')
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create wallet'
toast.error(errorMessage)
} finally {
setIsLoading(false)
}
}
const handleConfirmSeed = () => {
if (createWalletResponse?.seedphrase) {
// Save session and navigate to dashboard
const walletFileName = walletName.endsWith('.jmdat') ? walletName : `${walletName}.jmdat`
setSession({
walletFileName,
auth: { token: createWalletResponse.token, refresh_token: createWalletResponse.refresh_token }, // We'll need to unlock it properly later
})
navigate('/login', {
state: {
message: 'Wallet created successfully! Please log in with your credentials.',
walletName: walletFileName,
},
})
}
}
const renderCreateForm = () => (
<form onSubmit={handleCreateWallet} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="wallet-name">Wallet Name</Label>
<Input
id="wallet-name"
type="text"
value={walletName}
onChange={(e) => setWalletName(e.target.value)}
disabled={isLoading}
placeholder="Enter wallet name"
required
/>
<p className="text-xs text-muted-foreground">Will be saved as {walletName || 'wallet-name'}.jmdat</p>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
placeholder="Enter password"
className="pl-10 pr-10"
required
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2"
onClick={() => {
setShowConfirmPassword(false)
setShowPassword(!showPassword)
}}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="confirm-password"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
placeholder="Confirm password"
className="pl-10 pr-10"
required
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2"
onClick={() => {
setShowPassword(false)
setShowConfirmPassword(!showConfirmPassword)
}}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
<Button type="submit" className="w-full" disabled={isLoading} size="lg">
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating Wallet...
</>
) : (
'Create Wallet'
)}
</Button>
</form>
)
const renderSeedPhrase = () => (
<div className="space-y-6">
<div className="bg-muted p-4 rounded-lg">
<div className="grid grid-cols-3 gap-2 text-sm font-mono">
{createWalletResponse?.seedphrase.split(' ').map((word, index) => (
<div key={index} className="bg-background p-2 rounded border">
<span className="text-muted-foreground mr-2">{index + 1}.</span>
{word}
</div>
))}
</div>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<strong>Important:</strong> Write down this seed phrase and store it safely. It's the only way to recover your
wallet if you lose access.
</AlertDescription>
</Alert>
<Button onClick={handleConfirmSeed} className="w-full" size="lg">
I have saved my seed phrase
</Button>
</div>
)
return (
<div>
<PreventLeavingPageByMistake />
<div className="fs-4">{t('create_wallet.confirm_backup_title')}</div>
<p className="text-secondary">{t('create_wallet.confirm_backup_subtitle')}</p>
<rb.Form noValidate>
<MnemonicPhraseInput
mnemonicPhrase={givenWords}
onChange={(val) => setGivenWords(val)}
isValid={(index) => givenWords[index] === seedphrase[index]}
isDisabled={(index) => givenWords[index] === seedphrase[index]}
/>
</rb.Form>
{isSeedBackupConfirmed && (
<div className="mb-4 text-center text-success">{t('create_wallet.feedback_seed_confirmed')}</div>
)}
<rb.Button
className="w-100 mb-4"
variant="dark"
size="lg"
disabled={!isSeedBackupConfirmed}
onClick={() => onSuccess()}
>
{t('create_wallet.confirmation_button_fund_wallet')}
</rb.Button>
<div className="d-flex justify-content-between mb-4 gap-4">
<rb.Button variant="none" disabled={isSeedBackupConfirmed} onClick={() => onCancel()}>
<div className="d-flex justify-content-center align-items-center">
<Sprite symbol="arrow-left" width="20" height="20" className="me-2" />
{t('create_wallet.back_button')}
</div>
</rb.Button>
{showSkipButton && (
<rb.Button
className="position-relative"
variant="outline-dark"
disabled={isSeedBackupConfirmed}
onClick={() => onSuccess()}
>
<div className="d-flex justify-content-center align-items-center">
{t('create_wallet.skip_button')}
<Sprite symbol="arrow-right" width="20" height="20" className="ms-2" />
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted p-4">
<div className="w-full max-w-md">
<Card className="shadow-lg">
<CardHeader className="text-center space-y-2">
<div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-4">
<Wallet className="w-6 h-6 text-primary" />
</div>
<span className="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-warning">
dev
</span>
</rb.Button>
)}
<CardTitle className="text-2xl font-bold">
{step === 'create' && 'Create New Wallet'}
{step === 'seed' && 'Save Your Seed Phrase'}
</CardTitle>
<CardDescription>
{step === 'create' && 'Set up a new Joinmarket wallet for CoinJoin privacy'}
{step === 'seed' && "This is your wallet's recovery phrase"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{step === 'create' && renderCreateForm()}
{step === 'seed' && renderSeedPhrase()}
{step === 'create' && (
<div className="text-center">
<p className="text-sm text-muted-foreground">
Already have a wallet?{' '}
<Link
to="/login"
className="text-primary hover:text-primary/80 font-medium underline underline-offset-4"
>
Sign in here
</Link>
</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}
interface CreateWalletProps {
parentRoute: Route
startWallet: (name: Api.WalletFileName, auth: Api.ApiAuthContext) => void
}
export default function CreateWallet({ parentRoute, startWallet }: CreateWalletProps) {
const { t } = useTranslation()
const serviceInfo = useServiceInfo()
const navigate = useNavigate()
const [alert, setAlert] = useState<SimpleAlert>()
const [createdWallet, setCreatedWallet] = useState<CreatedWalletWithAuth>()
const isCreated = useMemo(() => !!createdWallet?.walletFileName && !!createdWallet?.auth, [createdWallet])
const canCreate = useMemo(
() => !isCreated && !serviceInfo?.walletFileName && !serviceInfo?.rescanning,
[isCreated, serviceInfo],
)
const createWallet = useCallback(
async ({ walletName, password }: CreateWalletFormValues) => {
setAlert(undefined)
try {
const res = await Api.postWalletCreate({}, { walletname: walletDisplayNameToFileName(walletName), password })
const body = await (res.ok ? res.json() : Api.Helper.throwError(res))
const { seedphrase, walletname: createdWalletFileName } = body
const auth = Api.Helper.parseAuthProps(body)
setCreatedWallet({ walletFileName: createdWalletFileName, seedphrase, password, auth })
} catch (e: any) {
const message = t('create_wallet.error_creating_failed', {
reason: e.message || t('global.errors.reason_unknown'),
})
setAlert({ variant: 'danger', message })
}
},
[setAlert, setCreatedWallet, t],
)
const walletConfirmed = useCallback(() => {
if (createdWallet) {
setAlert(undefined)
startWallet(createdWallet.walletFileName, createdWallet.auth)
navigate(routes.wallet)
} else {
setAlert({ variant: 'danger', message: t('create_wallet.alert_confirmation_failed') })
}
}, [createdWallet, startWallet, navigate, setAlert, t])
const [showBackupConfirmation, setShowBackupConfirmation] = useState(false)
return (
<div className="create-wallet">
{createdWallet ? (
<PageTitle
title={t('create_wallet.title_wallet_created')}
subtitle={t('create_wallet.subtitle_wallet_created')}
success
/>
) : (
<PageTitle title={t('create_wallet.title')} />
)}
{alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
{!canCreate && !isCreated ? (
<>
{serviceInfo?.walletFileName && (
<rb.Alert variant="warning">
<Trans
i18nKey="create_wallet.alert_other_wallet_unlocked"
values={{
walletName: walletDisplayName(serviceInfo.walletFileName),
}}
>
Currently <strong>walletName</strong> is active. You need to lock it first.
<Link to={routes.walletList} className="alert-link">
Go back
</Link>
.
</Trans>
</rb.Alert>
)}
{serviceInfo?.rescanning === true && (
<rb.Alert variant="warning" data-testid="alert-rescanning">
<Trans i18nKey="create_wallet.alert_rescan_in_progress">
Rescanning the timechain is currently in progress. Please wait until the process finishes and then try
again.
<Link to={routes.walletList} className="alert-link">
Go back
</Link>
.
</Trans>
</rb.Alert>
)}
</>
) : (
<>
<PreventLeavingPageByMistake />
{!serviceInfo?.walletFileName && !createdWallet && (
<WalletCreationForm
onCancel={() => navigate(routes[parentRoute])}
onSubmit={createWallet}
submitButtonText={(isSubmitting) =>
t(isSubmitting ? 'create_wallet.button_creating' : 'create_wallet.button_create')
}
/>
)}
{createdWallet &&
(!showBackupConfirmation ? (
<WalletCreationConfirmation
wallet={createdWallet}
submitButtonText={(_) => t('create_wallet.next_button')}
onSubmit={async () => setShowBackupConfirmation(true)}
/>
) : (
<BackupConfirmation
wallet={createdWallet}
onSuccess={walletConfirmed}
onCancel={() => setShowBackupConfirmation(false)}
/>
))}
</>
)}
</div>
)
}
export default CreateWallet

View file

@ -1,111 +0,0 @@
import { Link } from 'react-router-dom'
import Sprite from './Sprite'
import PageTitle from './PageTitle'
import { routes } from '../constants/routes'
const DEFAULT_BASIC_AUTH = {
user: 'joinmarket',
password: 'joinmarket',
}
const LINK_JM_REGTEST_JOINMARKET2 = 'http://localhost:29080'
const LINK_JM_REGTEST_JOINMARKET2_AUTH = DEFAULT_BASIC_AUTH
const LINK_JM_REGTEST_JOINMARKET3 = 'http://localhost:30080'
const LINK_JM_REGTEST_EXPLORER = 'http://localhost:3002'
const LINK_JM_REGTEST_EXPLORER_AUTH = DEFAULT_BASIC_AUTH
const LINK_JM_REGTEST_RPC_TERMINAL = `${LINK_JM_REGTEST_EXPLORER}/rpc-terminal`
export default function DevSetupPage() {
return (
<div>
<PageTitle title="Development setup" subtitle="" />
<div className="d-flex flex-column gap-3">
<div className="mb-4">
<h5>Test Wallet</h5>
<div className="ms-3 my-2">
Name: <span className="font-monospace">Satoshi</span>
<br />
Password: <span className="font-monospace">test</span>
</div>
</div>
</div>
<div className="d-flex flex-column gap-3">
<div className="mb-4">
<h5>Links</h5>
<div className="my-2">
<Link className="link-dark" to={routes.__errorExample}>
Error Example Page
</Link>
</div>
</div>
</div>
<div className="mb-4">
<h5>Jam Instances</h5>
<div>
<div className="d-flex align-items-center">
<Sprite symbol="logo" width="24" height="24" className="me-2" />
<a href={LINK_JM_REGTEST_JOINMARKET2} target="_blank" rel="noopener noreferrer" className="link-dark">
jm_regtest_joinmarket2 ({LINK_JM_REGTEST_JOINMARKET2})
</a>
<span className="badge rounded-pill bg-primary ms-2">secondary</span>
</div>
<div className="ms-5 my-2">
Basic Authentication
<br />
<small>
User: <span className="font-monospace">{LINK_JM_REGTEST_JOINMARKET2_AUTH.user}</span>
<br />
Password: <span className="font-monospace">{LINK_JM_REGTEST_JOINMARKET2_AUTH.password}</span>
</small>
</div>
<div className="d-flex align-items-center">
<Sprite symbol="logo" width="24" height="24" className="me-2" />
<a href={LINK_JM_REGTEST_JOINMARKET3} target="_blank" rel="noopener noreferrer" className="link-dark">
jm_regtest_joinmarket3 ({LINK_JM_REGTEST_JOINMARKET3})
</a>
<span className="badge rounded-pill bg-success ms-2">tertiary</span>
</div>
</div>
</div>
<div className="mb-4">
<h5>Block Explorer</h5>
<div>
{' '}
<div className="d-flex align-items-center">
<Sprite symbol="block" width="24" height="24" className="me-2" />
<a href={LINK_JM_REGTEST_EXPLORER} target="_blank" rel="noopener noreferrer" className="link-dark">
jm_regtest_explorer ({LINK_JM_REGTEST_EXPLORER})
</a>
</div>
<div className="ms-5 my-2">
Basic Authentication
<br />
<small>
User: <span className="font-monospace">{LINK_JM_REGTEST_EXPLORER_AUTH.user}</span>
<br />
Password: <span className="font-monospace">{LINK_JM_REGTEST_EXPLORER_AUTH.password}</span>
</small>
</div>
</div>
<div>
<div className="d-flex align-items-center">
<Sprite symbol="console" width="24" height="24" className="me-2" />
<a href={LINK_JM_REGTEST_RPC_TERMINAL} target="_blank" rel="noopener noreferrer" className="link-dark">
Regtest RPC Terminal ({LINK_JM_REGTEST_RPC_TERMINAL})
</a>
</div>
<div className="ms-5 my-2">
Mine a block, e.g.:
<pre>generatetoaddress 1 bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx</pre>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,31 @@
type DisplayLogoProps = {
displayMode: 'sats' | 'btc'
size?: 'sm' | 'lg'
}
export function DisplayLogo({ displayMode, size = 'lg' }: DisplayLogoProps) {
if (displayMode === 'btc') {
return <span className={size === 'sm' ? 'text-lg ml-1' : 'text-4xl ml-1'}></span>
}
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size === 'sm' ? '18px' : '40px'}
height={size === 'sm' ? '18px' : '40px'}
viewBox="0 0 24 24"
fill="none"
style={{
display: 'inline',
verticalAlign: 'middle',
marginLeft: 4,
}}
>
<path d="M7 7.90906H17" stroke="currentColor" />
<path d="M12 5.45454V3" stroke="currentColor" />
<path d="M12 20.9999V18.5454" stroke="currentColor" />
<path d="M7 12H17" stroke="currentColor" />
<path d="M7 16.0909H17" stroke="currentColor" />
</svg>
)
}

View file

@ -1,32 +0,0 @@
.dividerContainer {
display: flex;
justify-content: space-between;
align-items: center;
}
.dividerContainer .dividerLine {
margin: 0;
width: 50%;
flex-grow: 0;
flex-shrink: 1;
}
.dividerContainer .dividerButton {
display: flex;
justify-content: center;
align-items: center;
margin: 0 1rem;
flex-shrink: 0;
flex-grow: 1;
color: var(--bs-body-color);
cursor: pointer;
background-color: transparent;
border: 1px solid var(--bs-body-color);
border-radius: 50%;
width: 2rem;
height: 2rem;
}
.dividerContainer .dividerButton:disabled {
cursor: not-allowed;
}

View file

@ -1,27 +0,0 @@
import * as rb from 'react-bootstrap'
import classNames from 'classnames'
import Sprite from './Sprite'
import styles from './Divider.module.css'
type DividerProps = rb.ColProps & {
toggled: boolean
onToggle: (current: boolean) => void
disabled?: boolean
className?: string
}
export default function Divider({ toggled, onToggle, disabled, className, ...colProps }: DividerProps) {
return (
<rb.Row className={classNames('d-flex', 'justify-content-center', className)}>
<rb.Col xs={12} {...colProps}>
<div className={styles.dividerContainer}>
<hr className={styles.dividerLine} />
<button className={styles.dividerButton} disabled={disabled} onClick={() => onToggle(toggled)}>
<Sprite symbol={toggled ? 'caret-up' : 'caret-down'} width="20" height="20" />
</button>
<hr className={styles.dividerLine} />
</div>
</rb.Col>
</rb.Row>
)
}

View file

@ -1,53 +0,0 @@
.earn .input-loader {
height: 3.5rem;
border-radius: 0.25rem;
}
.earn .fidelityBondsLoader {
height: 11rem;
border-radius: 0.25rem;
}
.earn form input:not([type='checkbox']) {
height: 3.5rem;
}
.inputGroupText {
width: 5ch;
display: inline-flex;
justify-content: center;
align-items: center;
}
.offerLoader {
height: 10rem;
border-radius: 0.25rem;
margin-bottom: 1.5rem;
}
.offerContainer {
border: 1px solid var(--bs-gray-200);
border-radius: 0.3rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
}
:root[data-theme='dark'] .offerContainer {
border-color: var(--bs-gray-700);
}
.offerContainer .offerTitle {
width: 100%;
font-size: 1.2rem;
color: var(--bs-body-color);
}
.offerContainer .offerLabel {
color: var(--bs-gray-600);
font-size: 0.8rem;
}
.offerContainer .offerContent {
font-size: 0.8rem;
word-break: break-all;
}

View file

@ -1,841 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Formik, FormikErrors } from 'formik'
import * as rb from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { TFunction } from 'i18next'
import { useSettings } from '../context/SettingsContext'
import { CurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo, WalletInfo } from '../context/WalletContext'
import { useServiceInfo, useReloadServiceInfo, Offer } from '../context/ServiceInfoContext'
import {
calcOfferMinsizeMax,
factorToPercentage,
isAbsoluteOffer,
isRelativeOffer,
isValidNumber,
percentageToFactor,
} from '../utils'
import {
OFFER_FEE_ABS_MIN,
OFFER_FEE_REL_MAX,
OFFER_FEE_REL_MIN,
OFFER_FEE_REL_STEP,
OFFER_MINSIZE_MIN,
} from '../constants/jam'
import * as Api from '../libs/JmWalletApi'
import * as fb from './fb/utils'
import Sprite from './Sprite'
import PageTitle from './PageTitle'
import SegmentedTabs from './SegmentedTabs'
import { CreateFidelityBond } from './fb/CreateFidelityBond'
import { ExistingFidelityBond } from './fb/ExistingFidelityBond'
import { RenewFidelityBondModal, SpendFidelityBondModal } from './fb/SpendFidelityBondModal'
import { EarnReportOverlay } from './EarnReport'
import { OrderbookOverlay } from './Orderbook'
import Balance from './Balance'
import Accordion from './Accordion'
import BitcoinAmountInput, { AmountValue, toAmountValue } from './BitcoinAmountInput'
import { isValidAmount } from './Send/helpers'
import styles from './Earn.module.css'
// In order to prevent state mismatch, the 'maker stop' response is delayed shortly.
// Even though the API response suggests that the maker has started or stopped immediately, it seems that this is not always the case.
// There is currently no way to know for sure - adding a delay at least mitigates the problem.
// 2022-04-26: With value of 2_000ms, no state corruption could be provoked in a local dev setup.
const MAKER_STOP_RESPONSE_DELAY_MS = 2_000
// When reloading UTXO after creating a fidelity bond, use a delay to make sure
// that the UTXO corresponding to the fidelity bond is correctly marked as such.
const RELOAD_FIDELITY_BONDS_DELAY_MS = 2_000
const OFFERTYPE_REL: Api.OfferType = 'sw0reloffer'
const OFFERTYPE_ABS: Api.OfferType = 'sw0absoffer'
const FORM_INPUT_LOCAL_STORAGE_KEYS = {
offertype: 'jm-offertype',
feeRel: 'jm-feeRel',
feeAbs: 'jm-feeAbs',
minsize: 'jm-minsize',
}
export interface EarnFormValues {
offertype: Api.OfferType
feeRel: number
feeAbs?: AmountValue
minsize?: AmountValue
}
const FORM_INPUT_DEFAULT_VALUES: Required<EarnFormValues> = {
offertype: OFFERTYPE_REL,
feeRel: 0.000_3,
feeAbs: toAmountValue(250),
minsize: toAmountValue(100_000),
}
const persistFormValues = (values: EarnFormValues) => {
window.localStorage.setItem(FORM_INPUT_LOCAL_STORAGE_KEYS.offertype, values.offertype)
if (values.minsize) {
window.localStorage.setItem(FORM_INPUT_LOCAL_STORAGE_KEYS.minsize, String(values.minsize.value))
}
if (isRelativeOffer(values.offertype)) {
window.localStorage.setItem(FORM_INPUT_LOCAL_STORAGE_KEYS.feeRel, String(values.feeRel))
}
if (isAbsoluteOffer(values.offertype) && values.feeAbs) {
window.localStorage.setItem(FORM_INPUT_LOCAL_STORAGE_KEYS.feeAbs, String(values.feeAbs.value))
}
}
const initialFormValues = (): EarnFormValues => {
const feeRel = parseFloat(
window.localStorage.getItem(FORM_INPUT_LOCAL_STORAGE_KEYS.feeRel) ?? String(FORM_INPUT_DEFAULT_VALUES.feeRel),
)
const feeAbs = parseInt(
window.localStorage.getItem(FORM_INPUT_LOCAL_STORAGE_KEYS.feeAbs) ?? String(FORM_INPUT_DEFAULT_VALUES.feeAbs),
10,
)
const minsize = parseInt(
window.localStorage.getItem(FORM_INPUT_LOCAL_STORAGE_KEYS.minsize) ??
String(FORM_INPUT_DEFAULT_VALUES.minsize.value),
10,
)
const offertype =
window.localStorage.getItem(FORM_INPUT_LOCAL_STORAGE_KEYS.offertype) ?? FORM_INPUT_DEFAULT_VALUES.offertype
return {
offertype,
feeRel: isValidNumber(feeRel) ? feeRel : FORM_INPUT_DEFAULT_VALUES.feeRel,
feeAbs: toAmountValue(isValidNumber(feeAbs) ? feeAbs : FORM_INPUT_DEFAULT_VALUES.feeAbs.value!),
minsize: toAmountValue(isValidNumber(minsize) ? minsize : FORM_INPUT_DEFAULT_VALUES.minsize.value!),
}
}
const renderOfferType = (offer: Offer, t: TFunction) => {
if (isAbsoluteOffer(offer.ordertype)) {
return <rb.Badge bg="info">{t('earn.current.text_offer_type_absolute')}</rb.Badge>
}
if (isRelativeOffer(offer.ordertype)) {
return <rb.Badge bg="primary">{t('earn.current.text_offer_type_relative')}</rb.Badge>
}
return <rb.Badge bg="secondary">{offer.ordertype}</rb.Badge>
}
interface CurrentOfferProps {
offer: Offer
nickname: string
}
function CurrentOffer({ offer, nickname }: CurrentOfferProps) {
const { t } = useTranslation()
const settings = useSettings()
return (
<div className={styles.offerContainer}>
<div className="d-flex justify-content-between align-items-center">
<div className="d-flex flex-column">
<div className={styles.offerLabel}>{t('earn.current.text_offer')}</div>
<div className={`${styles.offerTitle} slashed-zeroes`}>
{nickname}:{offer.oid}
</div>
</div>
<div className="d-flex align-items-center gap-1">{renderOfferType(offer, t)}</div>
</div>
<rb.Container className="mt-2">
<rb.Row className="mb-1">
<rb.Col xs={6}>
<div className="d-flex flex-column">
<div className={styles.offerLabel}>{t('earn.current.text_cjfee')}</div>
<div>
{isRelativeOffer(offer.ordertype) ? (
<>{factorToPercentage(parseFloat(offer.cjfee) || 0)}%</>
) : (
<>
<Balance
valueString={String(offer.cjfee)}
convertToUnit={settings.unit}
showBalance={settings.showBalance}
/>
</>
)}
</div>
</div>
</rb.Col>
<rb.Col xs={6}>
<div className="d-flex flex-column">
<div className={styles.offerLabel}>{t('earn.current.text_minsize')}</div>
<div>
<Balance
valueString={String(offer.minsize)}
convertToUnit={settings.unit}
showBalance={settings.showBalance}
/>
</div>
</div>
</rb.Col>
</rb.Row>
<rb.Row>
<rb.Col xs={6}>
<div className="d-flex flex-column">
<div className={styles.offerLabel}>{t('earn.current.text_txfee')}</div>
<div>
<Balance
valueString={String(offer.txfee)}
convertToUnit={settings.unit}
showBalance={settings.showBalance}
/>
</div>
</div>
</rb.Col>
<rb.Col xs={6}>
<div className="d-flex flex-column">
<div className={styles.offerLabel}>{t('earn.current.text_maxsize')}</div>
<div>
<Balance
valueString={String(offer.maxsize)}
convertToUnit={settings.unit}
showBalance={settings.showBalance}
/>
</div>
</div>
</rb.Col>
</rb.Row>
</rb.Container>
</div>
)
}
interface EarnFormProps {
initialValues?: EarnFormValues
submitButtonText: (isSubmitting: boolean) => React.ReactNode | string
onSubmit: (values: EarnFormValues) => Promise<void>
isLoading: boolean
disabled?: boolean
walletInfo?: WalletInfo
}
const EarnForm = ({
initialValues = FORM_INPUT_DEFAULT_VALUES,
submitButtonText,
onSubmit,
isLoading,
disabled = false,
walletInfo,
}: EarnFormProps) => {
const { t } = useTranslation()
const offerMinsizeMax = useMemo(() => {
return walletInfo === undefined ? 0 : calcOfferMinsizeMax(walletInfo.balanceSummary.accountBalances)
}, [walletInfo])
const validate = (values: EarnFormValues) => {
const errors = {} as FormikErrors<EarnFormValues>
const isRelOffer = isRelativeOffer(values.offertype)
const isAbsOffer = isAbsoluteOffer(values.offertype)
if (!isRelOffer && !isAbsOffer) {
// currently no need for translation, this should never occur -> input is controlled by toggle
errors.offertype = 'Offertype is not supported'
}
if (isRelOffer) {
if (!isValidNumber(values.feeRel) || values.feeRel < OFFER_FEE_REL_MIN || values.feeRel > OFFER_FEE_REL_MAX) {
errors.feeRel = t('earn.feedback_invalid_rel_fee', {
feeRelPercentageMin: `${factorToPercentage(OFFER_FEE_REL_MIN)}%`,
feeRelPercentageMax: `${factorToPercentage(OFFER_FEE_REL_MAX)}%`,
})
}
}
if (isAbsOffer) {
if (!isValidNumber(values.feeAbs?.value) || values.feeAbs!.value! < OFFER_FEE_ABS_MIN) {
errors.feeAbs = t('earn.feedback_invalid_abs_fee')
}
}
if (!isValidAmount(values.minsize?.value ?? null, false)) {
errors.minsize = t('earn.feedback_invalid_min_amount')
} else {
const minsize = values.minsize?.value || 0
if (OFFER_MINSIZE_MIN > offerMinsizeMax) {
errors.minsize = t('earn.feedback_invalid_min_amount_insufficient_funds')
} else if (minsize < OFFER_MINSIZE_MIN || minsize > offerMinsizeMax) {
errors.minsize = t('earn.feedback_invalid_min_amount_range', {
minAmountMin: OFFER_MINSIZE_MIN.toLocaleString(),
minAmountMax: offerMinsizeMax.toLocaleString(),
})
}
}
return errors
}
return (
<Formik initialValues={initialValues} validate={validate} onSubmit={onSubmit}>
{(props) => {
const { handleSubmit, setFieldValue, handleBlur, values, touched, errors, isSubmitting } = props
const minsizeField = props.getFieldProps<AmountValue>('minsize')
const feeAbsField = props.getFieldProps<AmountValue>('feeAbs')
return (
<>
<rb.Form onSubmit={handleSubmit} noValidate>
<Accordion title={t('earn.button_settings')} variant={!props.isValid ? 'danger' : undefined}>
<>
<rb.Form.Group className="mb-4 d-flex justify-content-center" controlId="offertype">
<SegmentedTabs
name="offertype"
tabs={[
{
label: t('earn.radio_abs_offer_label'),
value: OFFERTYPE_ABS,
},
{
label: t('earn.radio_rel_offer_label'),
value: OFFERTYPE_REL,
},
]}
value={values.offertype}
onChange={(tab) => {
setFieldValue('offertype', tab.value, true)
}}
disabled={isLoading || isSubmitting}
/>
</rb.Form.Group>
{values.offertype === OFFERTYPE_REL ? (
<rb.Form.Group className="mb-3" controlId="feeRel">
<rb.Form.Label className="mb-0">
{t('earn.label_rel_fee', {
fee: typeof values.feeRel === 'number' ? `(${factorToPercentage(values.feeRel)}%)` : '',
})}
</rb.Form.Label>
<rb.Form.Text className="d-block text-secondary mb-2">
{t('earn.description_rel_fee')}
</rb.Form.Text>
{isLoading ? (
<rb.Placeholder as="div" animation="wave">
<rb.Placeholder xs={12} className={styles['input-loader']} />
</rb.Placeholder>
) : (
<rb.InputGroup hasValidation>
<rb.InputGroup.Text id="feeRel-addon1" className={styles.inputGroupText}>
%
</rb.InputGroup.Text>
<rb.Form.Control
aria-label={t('earn.label_rel_fee', { fee: '' })}
className="slashed-zeroes"
type="number"
name="feeRel"
disabled={isSubmitting}
onChange={(e) => {
const value = e.target.value || ''
setFieldValue('feeRel', value !== '' ? percentageToFactor(parseFloat(value)) : '', true)
}}
onBlur={handleBlur}
value={typeof values.feeRel === 'number' ? factorToPercentage(values.feeRel) : ''}
isValid={touched.feeRel && !errors.feeRel}
isInvalid={touched.feeRel && !!errors.feeRel}
min={factorToPercentage(OFFER_FEE_REL_MIN)}
step={factorToPercentage(OFFER_FEE_REL_STEP)}
/>
<rb.Form.Control.Feedback type="invalid">{errors.feeRel}</rb.Form.Control.Feedback>
</rb.InputGroup>
)}
</rb.Form.Group>
) : (
<rb.Form.Group className="mb-3" controlId="feeAbs">
<rb.Form.Label className="mb-0">
{t('earn.label_abs_fee', {
fee: '', // empty on purpose
})}
</rb.Form.Label>
<rb.Form.Text className="d-block text-secondary mb-2">
{t('earn.description_abs_fee')}
</rb.Form.Text>
{isLoading ? (
<rb.Placeholder as="div" animation="wave">
<rb.Placeholder xs={12} className={styles['input-loader']} />
</rb.Placeholder>
) : (
<div className={touched.feeAbs && !!errors.feeAbs ? 'is-invalid' : ''}>
<BitcoinAmountInput
inputGroupTextClassName={styles.inputGroupText}
label={t('earn.label_abs_fee')}
placeholder={''}
field={feeAbsField}
form={props}
disabled={isSubmitting}
/>
</div>
)}
</rb.Form.Group>
)}
<rb.Form.Group className="mb-4" controlId="minsize">
<rb.Form.Label>{t('earn.label_min_amount_input')}</rb.Form.Label>
{isLoading ? (
<rb.Placeholder as="div" animation="wave">
<rb.Placeholder xs={12} className={styles['input-loader']} />
</rb.Placeholder>
) : (
<div className={touched.minsize && !!errors.minsize ? 'is-invalid' : ''}>
<BitcoinAmountInput
inputGroupTextClassName={styles.inputGroupText}
label={t('earn.label_min_amount_input')}
placeholder={t('earn.placeholder_min_amount_input')}
field={minsizeField}
form={props}
disabled={isSubmitting}
/>
</div>
)}
</rb.Form.Group>
</>
</Accordion>
<rb.Button
className="w-100 mb-4"
variant="dark"
size="lg"
type="submit"
disabled={isLoading || isSubmitting || disabled}
>
<div className="d-flex justify-content-center align-items-center">{submitButtonText(isSubmitting)}</div>
</rb.Button>
</rb.Form>
</>
)
}}
</Formik>
)
}
const toStartMakerRequest = (values: EarnFormValues): Api.StartMakerRequest => {
// both fee properties need to be provided.
// prevent providing an invalid value by setting the ignored prop to zero
const cjfee_a = isAbsoluteOffer(values.offertype) ? values.feeAbs!.value! : 0
const cjfee_r = isRelativeOffer(values.offertype) ? values.feeRel : 0
return {
ordertype: values.offertype,
minsize: values.minsize!.value!,
cjfee_a,
cjfee_r,
}
}
interface EarnProps {
wallet: CurrentWallet
}
export default function Earn({ wallet }: EarnProps) {
const { t } = useTranslation()
const settings = useSettings()
const currentWalletInfo = useCurrentWalletInfo()
const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
const serviceInfo = useServiceInfo()
const reloadServiceInfo = useReloadServiceInfo()
const [alert, setAlert] = useState<SimpleAlert>()
const [serviceInfoAlert, setServiceInfoAlert] = useState<SimpleAlert>()
const [isLoading, setIsLoading] = useState(true)
const [isSending, setIsSending] = useState(false)
const [isWaitingMakerStart, setIsWaitingMakerStart] = useState(false)
const [isWaitingMakerStop, setIsWaitingMakerStop] = useState(false)
const [isShowReport, setIsShowReport] = useState(false)
const [isShowOrderbook, setIsShowOrderbook] = useState(false)
const [initialValues, setInitialValues] = useState(initialFormValues())
const fidelityBonds = useMemo(() => {
return currentWalletInfo?.fidelityBondSummary.fbOutputs || []
}, [currentWalletInfo])
const [moveToJarFidelityBondId, setMoveToJarFidelityBondId] = useState<Api.UtxoId>()
const [renewFidelityBondId, setRenewFidelityBondId] = useState<Api.UtxoId>()
const isSufficientFundsAvailable = useMemo(
() => (currentWalletInfo?.balanceSummary.calculatedAvailableBalanceInSats ?? 0) > 0,
[currentWalletInfo],
)
const isOperationDisabled = useMemo(() => {
return !isSufficientFundsAvailable || serviceInfo?.rescanning === true || isWaitingMakerStart || isWaitingMakerStop
}, [isSufficientFundsAvailable, serviceInfo, isWaitingMakerStart, isWaitingMakerStop])
const startMakerService = useCallback(
(values: EarnFormValues) => {
setIsSending(true)
setIsWaitingMakerStart(true)
// There is no response data to check if maker got started:
// Wait for the websocket or session response!
return (
Api.postMakerStart({ ...wallet }, toStartMakerRequest(values))
.then((res) => (res.ok ? true : Api.Helper.throwError(res)))
// show the loader a little longer to avoid flickering
.then((result) => new Promise((r) => setTimeout(() => r(result), 200)))
.catch((e) => {
setIsWaitingMakerStart(false)
throw e
})
.finally(() => setIsSending(false))
)
},
[wallet],
)
const stopMakerService = useCallback(() => {
setIsSending(true)
setIsWaitingMakerStop(true)
// There is no response data to check if maker got stopped:
// Wait for the websocket or session response!
return Api.getMakerStop({ ...wallet })
.then((res) => (res.ok ? true : Api.Helper.throwError(res)))
.then((result) => new Promise((r) => setTimeout(() => r(result), MAKER_STOP_RESPONSE_DELAY_MS)))
.catch((e) => {
setIsWaitingMakerStop(false)
throw e
})
.finally(() => setIsSending(false))
}, [wallet])
useEffect(() => {
if (isSending) return
const abortCtrl = new AbortController()
setIsLoading(true)
const reloadingServiceInfo = reloadServiceInfo({ signal: abortCtrl.signal })
const reloadingCurrentWalletInfo = reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal })
Promise.all([reloadingServiceInfo, reloadingCurrentWalletInfo])
.catch((err) => {
!abortCtrl.signal.aborted && setAlert({ variant: 'danger', message: err.message })
})
.finally(() => !abortCtrl.signal.aborted && setIsLoading(false))
return () => abortCtrl.abort()
}, [isSending, reloadServiceInfo, reloadCurrentWalletInfo])
useEffect(() => {
if (isSending) return
const makerRunning = serviceInfo?.makerRunning === true
const waitingForMakerToStart = isWaitingMakerStart && !makerRunning
setIsWaitingMakerStart(waitingForMakerToStart)
const waitingForMakerToStop = isWaitingMakerStop && makerRunning
setIsWaitingMakerStop(waitingForMakerToStop)
const waiting = waitingForMakerToStart || waitingForMakerToStop
setServiceInfoAlert((current) => {
if (!waiting && makerRunning) {
return { variant: 'success', message: t('earn.alert_running') }
} else if (!waiting) {
return undefined
}
return current
})
}, [isSending, serviceInfo, isWaitingMakerStart, isWaitingMakerStop, t])
const reloadFidelityBonds = useCallback(
({ delay }: { delay: number }) => {
const abortCtrl = new AbortController()
setIsLoading(true)
new Promise((resolve) => {
setTimeout(async () => {
resolve(await reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal }))
}, delay)
})
.catch((err) => {
if (abortCtrl.signal.aborted) return
setAlert({ variant: 'danger', message: err.message || t('global.errors.reason_unknown') })
})
.finally(() => {
if (abortCtrl.signal.aborted) return
setIsLoading(false)
})
},
[reloadCurrentWalletInfo, t],
)
const onSubmitStart = useCallback(
async (values: EarnFormValues) => {
if (isLoading || isSending || isWaitingMakerStart || isWaitingMakerStop) {
return
}
setAlert(undefined)
try {
persistFormValues(values)
setServiceInfoAlert({ variant: 'success', message: t('earn.alert_starting') })
await startMakerService(values)
setInitialValues(initialFormValues())
} catch (e: any) {
setServiceInfoAlert(undefined)
setAlert({ variant: 'danger', message: e.message || t('global.errors.reason_unknown') })
}
},
[startMakerService, isLoading, isSending, isWaitingMakerStart, isWaitingMakerStop, t],
)
const onSubmitStop = useCallback(async () => {
if (isLoading || isSending || isWaitingMakerStart || isWaitingMakerStop) {
return
}
setAlert(undefined)
try {
setServiceInfoAlert({ variant: 'success', message: t('earn.alert_stopping') })
await stopMakerService()
} catch (e: any) {
setServiceInfoAlert(undefined)
setAlert({ variant: 'danger', message: e.message || t('global.errors.reason_unknown') })
}
}, [stopMakerService, isLoading, isSending, isWaitingMakerStart, isWaitingMakerStop, t])
return (
<div className={styles.earn}>
<PageTitle title={t('earn.title')} subtitle={t('earn.subtitle')} />
<rb.Row className="mb-2">
<rb.Col>
<rb.Fade in={serviceInfo?.coinjoinInProgress} mountOnEnter={true} unmountOnExit={true}>
<rb.Alert variant="info" className="mb-4">
{t('earn.alert_coinjoin_in_progress')}
</rb.Alert>
</rb.Fade>
{alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
{serviceInfoAlert && <rb.Alert variant={serviceInfoAlert.variant}>{serviceInfoAlert.message}</rb.Alert>}
{!serviceInfo?.coinjoinInProgress &&
!serviceInfo?.makerRunning &&
!isWaitingMakerStart &&
!isWaitingMakerStop && <p className="text-secondary mb-4">{t('earn.market_explainer')}</p>}
{serviceInfo?.makerRunning &&
(serviceInfo?.offers && serviceInfo?.nickname ? (
<>
{serviceInfo.offers.map((offer, index) => (
<CurrentOffer key={index} offer={offer} nickname={serviceInfo.nickname || '-'} />
))}
</>
) : (
<rb.Placeholder as="div" animation="wave">
<rb.Placeholder xs={12} className={styles.offerLoader} />
</rb.Placeholder>
))}
{!serviceInfo?.coinjoinInProgress && (
<>
<PageTitle
title={t('earn.title_fidelity_bonds', { count: fidelityBonds.length })}
subtitle={t('earn.subtitle_fidelity_bonds')}
/>
<div className="d-flex flex-column gap-3 mb-4">
{currentWalletInfo && moveToJarFidelityBondId && (
<SpendFidelityBondModal
show={true}
fidelityBondId={moveToJarFidelityBondId}
wallet={wallet}
walletInfo={currentWalletInfo}
destinationJarIndex={0}
onClose={({ mustReload }) => {
setMoveToJarFidelityBondId(undefined)
if (mustReload) {
reloadFidelityBonds({ delay: 0 })
}
}}
/>
)}
{currentWalletInfo && renewFidelityBondId && (
<RenewFidelityBondModal
show={true}
fidelityBondId={renewFidelityBondId}
wallet={wallet}
walletInfo={currentWalletInfo}
onClose={({ mustReload }) => {
setRenewFidelityBondId(undefined)
if (mustReload) {
reloadFidelityBonds({ delay: 0 })
}
}}
/>
)}
{fidelityBonds.map((fidelityBond, index) => {
const isExpired = !fb.utxo.isLocked(fidelityBond)
const actionsEnabled =
isExpired &&
serviceInfo &&
!serviceInfo.coinjoinInProgress &&
!serviceInfo.makerRunning &&
!serviceInfo.rescanning &&
!isWaitingMakerStart &&
!isWaitingMakerStop &&
!isLoading
return (
<ExistingFidelityBond key={index} fidelityBond={fidelityBond}>
{actionsEnabled && (
<div className="mt-4 d-flex gap-2">
<rb.Button
variant={settings.theme === 'dark' ? 'light' : 'dark'}
className="w-100 d-flex justify-content-center align-items-center"
disabled={moveToJarFidelityBondId !== undefined}
onClick={() => setMoveToJarFidelityBondId(fidelityBond.utxo)}
>
<Sprite className="me-1 mb-1" symbol="unlock" width="24" height="24" />
{t('earn.fidelity_bond.existing.button_spend')}
</rb.Button>
<rb.Button
variant={settings.theme === 'dark' ? 'light' : 'dark'}
className="w-100 d-flex justify-content-center align-items-center"
disabled={renewFidelityBondId !== undefined}
onClick={() => setRenewFidelityBondId(fidelityBond.utxo)}
>
<Sprite className="me-1" symbol="refresh" width="24" height="24" />
{t('earn.fidelity_bond.existing.button_renew')}
</rb.Button>
</div>
)}
</ExistingFidelityBond>
)
})}
<>
{!serviceInfo?.makerRunning &&
!serviceInfo?.coinjoinInProgress &&
!serviceInfo?.rescanning &&
!isWaitingMakerStart &&
!isWaitingMakerStop &&
(!isLoading && currentWalletInfo ? (
<CreateFidelityBond
otherFidelityBondExists={fidelityBonds.length > 0}
wallet={wallet}
walletInfo={currentWalletInfo}
onDone={() => reloadFidelityBonds({ delay: RELOAD_FIDELITY_BONDS_DELAY_MS })}
/>
) : (
<rb.Placeholder as="div" animation="wave">
<rb.Placeholder xs={12} className={styles.fidelityBondsLoader} />
</rb.Placeholder>
))}
</>
</div>
</>
)}
{!serviceInfo?.coinjoinInProgress && (
<>
{!serviceInfo?.makerRunning && !isWaitingMakerStart && !isWaitingMakerStop ? (
<EarnForm
initialValues={initialValues}
onSubmit={onSubmitStart}
walletInfo={currentWalletInfo}
isLoading={isLoading}
disabled={isOperationDisabled}
submitButtonText={(_) => {
return (
<>
{isWaitingMakerStart || isWaitingMakerStop ? (
<>
<rb.Spinner
as="span"
animation="border"
size="sm"
role="status"
aria-hidden="true"
className="me-2"
/>
{isWaitingMakerStart && t('earn.text_starting')}
{isWaitingMakerStop && t('earn.text_stopping')}
</>
) : (
<>{serviceInfo?.makerRunning === true ? t('earn.button_stop') : t('earn.button_start')}</>
)}
</>
)
}}
/>
) : (
<Formik initialValues={{}} onSubmit={onSubmitStop}>
{({ handleSubmit, isSubmitting }) => (
<rb.Form onSubmit={handleSubmit} noValidate>
<rb.Button
className="w-100 mb-4"
variant="dark"
size="lg"
type="submit"
disabled={
isLoading ||
serviceInfo?.makerRunning !== true ||
serviceInfo?.rescanning === true ||
isSubmitting ||
isWaitingMakerStart ||
isWaitingMakerStop
}
>
<div className="d-flex justify-content-center align-items-center">
{isWaitingMakerStart || isWaitingMakerStop ? (
<>
<rb.Spinner
as="span"
animation="border"
size="sm"
role="status"
aria-hidden="true"
className="me-2"
/>
{isWaitingMakerStart && t('earn.text_starting')}
{isWaitingMakerStop && t('earn.text_stopping')}
</>
) : (
<>{t('earn.button_stop')}</>
)}
</div>
</rb.Button>
</rb.Form>
)}
</Formik>
)}
</>
)}
</rb.Col>
</rb.Row>
<rb.Row className="mb-4">
<rb.Col className="d-flex justify-content-center">
<OrderbookOverlay
show={isShowOrderbook}
onHide={() => setIsShowOrderbook(false)}
nickname={serviceInfo?.nickname ?? undefined}
/>
<rb.Button
variant="outline-dark"
className="border-0 d-inline-flex align-items-center"
onClick={() => setIsShowOrderbook(true)}
>
<Sprite symbol="globe" width="24" height="24" className="me-2" />
{t('earn.button_show_orderbook')}
</rb.Button>
</rb.Col>
<rb.Col className="d-flex justify-content-center">
<EarnReportOverlay show={isShowReport} onHide={() => setIsShowReport(false)} />
<rb.Button
variant="outline-dark"
className="border-0 d-inline-flex align-items-center"
onClick={() => setIsShowReport(true)}
>
<Sprite symbol="show" width="24" height="24" className="me-2" />
{t('earn.button_show_report')}
</rb.Button>
</rb.Col>
</rb.Row>
</div>
)
}

View file

@ -1,58 +0,0 @@
.report-line-placeholder {
height: 2.625rem;
margin: 1px 0;
}
.overlayContainer .earnReportContainer {
display: flex;
flex-direction: column;
gap: 0.5rem;
background-color: var(--bs-body-bg);
}
@media only screen and (min-width: 992px) {
.overlayContainer .earnReportContainer {
gap: 1.5rem;
padding: 2rem;
border-radius: 0.5rem;
}
}
.overlayContainer .earnReportContainer > .titleBar {
min-height: 3.6rem;
display: flex;
justify-content: space-between;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 0 0.5rem 0.8rem 0.5rem;
background-color: var(--bs-gray-100);
}
@media only screen and (min-width: 992px) {
.overlayContainer .earnReportContainer .titleBar {
padding: 0.8rem 1rem;
border-radius: 0.6rem;
}
}
@media only screen and (min-width: 768px) {
.overlayContainer .earnReportContainer .titleBar {
align-items: center;
flex-direction: row;
}
}
:root[data-theme='dark'] .overlayContainer .earnReportContainer .titleBar {
background-color: var(--bs-gray-800);
}
.overlayContainer .earnReportContainer > .titleBar .refreshButton {
display: flex;
justify-content: center;
align-items: center;
width: 2rem;
height: 2rem;
padding: 0.1rem;
border: none;
}

View file

@ -1,89 +0,0 @@
import { yieldgenReportToEarnReportEntries } from './EarnReport'
const EXPECTED_HEADER_LINE =
'timestamp,cj amount/satoshi,my input count,my input value/satoshi,cjfee/satoshi,earned/satoshi,confirm time/min,notes\n'
describe('Earn Report', () => {
it('should parse empty data correctly', () => {
const entries = yieldgenReportToEarnReportEntries([])
expect(entries.length).toBe(0)
})
it('should parse data only containing headers correctly', () => {
const entries = yieldgenReportToEarnReportEntries([EXPECTED_HEADER_LINE])
expect(entries.length).toBe(0)
})
it('should parse expected data structure correctly', () => {
const exampleData = [
EXPECTED_HEADER_LINE,
'2008/10/31 02:42:54,,,,,,,Connected\n',
'2009/01/03 02:54:42,14999989490,4,20000005630,250,250,0.42,\n',
'2009/01/03 03:03:32,10000000000,3,15000016390,250,250,0.8,\n',
'2009/01/03 03:04:47,4999981140,1,5000016640,250,250,0,\n',
'2009/01/03 03:06:07,1132600000,1,2500000000,250,250,13.37,\n',
'2009/01/03 03:07:27,8867393010,2,10000000000,250,250,42,\n',
'2009/01/03 03:08:52,1132595980,1,1367400250,250,250,0.17,\n',
]
const entries = yieldgenReportToEarnReportEntries(exampleData)
expect(entries.length).toBe(7)
const firstEntry = entries[0]
expect(firstEntry.timestamp.toUTCString()).toBe('Fri, 31 Oct 2008 02:42:54 GMT')
expect(firstEntry.cjTotalAmount).toBe(null)
expect(firstEntry.inputCount).toBe(null)
expect(firstEntry.inputAmount).toBe(null)
expect(firstEntry.fee).toBe(null)
expect(firstEntry.earnedAmount).toBe(null)
expect(firstEntry.confirmationDuration).toBe(null)
expect(firstEntry.notes).toBe('Connected\n')
const lastEntry = entries[entries.length - 1]
expect(lastEntry.timestamp.toUTCString()).toBe('Sat, 03 Jan 2009 03:08:52 GMT')
expect(lastEntry.cjTotalAmount).toBe(1132595980)
expect(lastEntry.inputCount).toBe(1)
expect(lastEntry.inputAmount).toBe(1367400250)
expect(lastEntry.fee).toBe(250)
expect(lastEntry.earnedAmount).toBe(250)
expect(lastEntry.confirmationDuration).toBe(0.17)
expect(lastEntry.notes).toBe('\n')
})
it('should handle unexpected/malformed data in a sane way', () => {
const unexpectedHeader = EXPECTED_HEADER_LINE + ',foo,bar'
const emptyLine = '' // should be skipped
const onlyNewLine = '\n' // should be skipped
const shortLine = '2009/01/03 04:04:04,,,' // should be skipped
const longLine = '2009/01/03 05:05:05,,,,,,,,,,,,,,,,,,,,,,,' // should be parsed
const malformedLine = 'this,is,a,malformed,line,with,some,unexpected,data' // should be parsed
const exampleData = [unexpectedHeader, emptyLine, onlyNewLine, shortLine, longLine, malformedLine]
const entries = yieldgenReportToEarnReportEntries(exampleData)
expect(entries.length).toBe(2)
const firstEntry = entries[0]
expect(firstEntry.timestamp.toUTCString()).toBe('Sat, 03 Jan 2009 05:05:05 GMT')
expect(firstEntry.cjTotalAmount).toBe(null)
expect(firstEntry.inputCount).toBe(null)
expect(firstEntry.inputAmount).toBe(null)
expect(firstEntry.fee).toBe(null)
expect(firstEntry.earnedAmount).toBe(null)
expect(firstEntry.confirmationDuration).toBe(null)
expect(firstEntry.notes).toBe(null)
const secondEntry = entries[1]
expect(secondEntry.timestamp.toUTCString()).toBe('Invalid Date')
expect(secondEntry.cjTotalAmount).toBe(NaN)
expect(secondEntry.inputCount).toBe(NaN)
expect(secondEntry.inputAmount).toBe(NaN)
expect(secondEntry.fee).toBe(NaN)
expect(secondEntry.earnedAmount).toBe(NaN)
expect(secondEntry.confirmationDuration).toBe(NaN)
expect(secondEntry.notes).toBe('unexpected')
})
})

View file

@ -1,557 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Table, Header, HeaderRow, HeaderCell, Body, Row, Cell } from '@table-library/react-table-library/table'
import { usePagination } from '@table-library/react-table-library/pagination'
import { useSort, HeaderCellSort, SortToggleType } from '@table-library/react-table-library/sort'
import * as TableTypes from '@table-library/react-table-library/types/table'
import { useTheme } from '@table-library/react-table-library/theme'
import * as rb from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import * as Api from '../libs/JmWalletApi'
import { useSettings } from '../context/SettingsContext'
import Balance from './Balance'
import Sprite from './Sprite'
import TablePagination from './TablePagination'
import styles from './EarnReport.module.css'
import { isDebugFeatureEnabled } from '../constants/debugFeatures'
import { pseudoRandomNumber } from './Send/helpers'
const SORT_KEYS = {
timestamp: 'TIMESTAMP',
cjTotalAmountInSats: 'CJ_TOTAL_AMOUNT_IN_SATS',
inputCount: 'INPUT_COUNT',
inputAmountInSats: 'INPUT_AMOUNT_IN_SATS',
earnedAmountInSats: 'EARNED_AMOUNT_IN_SATS',
}
const TABLE_THEME = {
Table: `
--data-table-library_grid-template-columns: 2fr 2fr 2fr 1fr 2fr 2fr;
font-size: 0.9rem;
`,
BaseCell: `
&:nth-of-type(2) div button {
justify-content: end;
}
&:nth-of-type(3) div button {
justify-content: end;
}
&:nth-of-type(4) div button {
justify-content: end;
}
&:nth-of-type(5) div button {
justify-content: end;
}
`,
Cell: `
&:nth-of-type(2) {
text-align: right;
}
&:nth-of-type(3) {
text-align: right;
}
&:nth-of-type(4) {
text-align: right;
}
&:nth-of-type(5) {
text-align: right;
}
`,
}
type Minutes = number
interface EarnReportEntry {
timestamp: Date
cjTotalAmount: Api.AmountSats | null
inputCount: number | null
inputAmount: Api.AmountSats | null
fee: Api.AmountSats | null
earnedAmount: Api.AmountSats | null
confirmationDuration: Minutes | null
notes: string | null
}
interface EarnReportTableRow extends EarnReportEntry, TableTypes.TableNode {}
// in the form of yyyy/MM/dd HH:mm:ss - e.g 2009/01/03 02:54:42
type RawYielgenTimestamp = string
const parseYieldgenTimestamp = (val: RawYielgenTimestamp) => {
// adding the timezone manually so that the date displays with the users timezone
return new Date(Date.parse(`${val} GMT`))
}
const yieldgenReportLineToEarnReportEntry = (line: string): EarnReportEntry | null => {
if (!line.includes(',')) return null
const values = line.split(',')
// be defensive here - we cannot handle lines with unexpected values
if (values.length < 8) return null
return {
timestamp: parseYieldgenTimestamp(values[0]),
cjTotalAmount: values[1] !== '' ? parseInt(values[1], 10) : null,
inputCount: values[2] !== '' ? parseInt(values[2], 10) : null,
inputAmount: values[3] !== '' ? parseInt(values[3], 10) : null,
fee: values[4] !== '' ? parseInt(values[4], 10) : null,
earnedAmount: values[5] !== '' ? parseInt(values[5], 10) : null,
confirmationDuration: values[6] !== '' ? parseFloat(values[6]) : null,
notes: values[7] !== '' ? values[7] : null,
}
}
type YieldgenReportLinesWithHeader = string[]
// exported for tests only
export const yieldgenReportToEarnReportEntries = (lines: YieldgenReportLinesWithHeader) => {
const empty = lines.length <= 1 // report is "empty" if it just contains the header line
const linesWithoutHeader = empty ? [] : lines.slice(1, lines.length)
return linesWithoutHeader
.map((line) => yieldgenReportLineToEarnReportEntry(line))
.filter((entry) => entry !== null)
.map((entry) => entry!)
}
interface EarnReportTableProps {
data: TableTypes.Data<EarnReportTableRow>
}
const EarnReportTable = ({ data }: EarnReportTableProps) => {
const { t } = useTranslation()
const settings = useSettings()
const tableTheme = useTheme(TABLE_THEME)
const pagination = usePagination(data, {
state: {
page: 0,
size: 25,
},
})
const tableSort = useSort(
data,
{
state: {
sortKey: SORT_KEYS.timestamp,
reverse: true,
},
},
{
sortIcon: {
margin: '4px',
iconDefault: <Sprite symbol="caret-right" width="20" height="20" />,
iconUp: <Sprite symbol="caret-up" width="20" height="20" />,
iconDown: <Sprite symbol="caret-down" width="20" height="20" />,
},
sortToggleType: SortToggleType.AlternateWithReset,
sortFns: {
[SORT_KEYS.timestamp]: (array) => array.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()),
[SORT_KEYS.earnedAmountInSats]: (array) => array.sort((a, b) => +a.earnedAmount - +b.earnedAmount),
[SORT_KEYS.cjTotalAmountInSats]: (array) => array.sort((a, b) => +a.cjTotalAmount - +b.cjTotalAmount),
[SORT_KEYS.inputCount]: (array) => array.sort((a, b) => +a.inputCount - +b.inputCount),
[SORT_KEYS.inputAmountInSats]: (array) => array.sort((a, b) => +a.inputAmount - +b.inputAmount),
},
},
)
return (
<>
<Table
data={data}
theme={tableTheme}
pagination={pagination}
sort={tableSort}
layout={{ custom: true, horizontalScroll: true }}
className="table striped"
>
{(tableList: TableTypes.TableProps<EarnReportTableRow>) => (
<>
<Header>
<HeaderRow>
<HeaderCellSort sortKey={SORT_KEYS.timestamp}>{t('earn.report.heading_timestamp')}</HeaderCellSort>
<HeaderCellSort sortKey={SORT_KEYS.earnedAmountInSats}>
{t('earn.report.heading_earned')}
</HeaderCellSort>
<HeaderCellSort sortKey={SORT_KEYS.cjTotalAmountInSats}>
{t('earn.report.heading_cj_amount')}
</HeaderCellSort>
<HeaderCellSort sortKey={SORT_KEYS.inputCount}>{t('earn.report.heading_input_count')}</HeaderCellSort>
<HeaderCellSort sortKey={SORT_KEYS.inputAmountInSats}>
{t('earn.report.heading_input_value')}
</HeaderCellSort>
<HeaderCell>{t('earn.report.heading_notes')}</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((item: EarnReportTableRow) => {
return (
<Row key={item.id} item={item}>
<Cell>{item.timestamp.toLocaleString()}</Cell>
<Cell>
<Balance
valueString={item.earnedAmount?.toString() || ''}
convertToUnit={settings.unit}
showBalance={true}
/>
</Cell>
<Cell>
<Balance
valueString={item.cjTotalAmount?.toString() || ''}
convertToUnit={settings.unit}
showBalance={true}
/>
</Cell>
<Cell>{item.inputCount}</Cell>
<Cell>
<Balance
valueString={item.inputAmount?.toString() || ''}
convertToUnit={settings.unit}
showBalance={true}
/>
</Cell>
<Cell>{item.notes}</Cell>
</Row>
)
})}
</Body>
</>
)}
</Table>
<div className="mt-4 mb-4 mb-lg-0">
<TablePagination data={data} pagination={pagination} />
</div>
</>
)
}
interface StatsBoxProps {
title: string
value: React.ReactNode
description?: string
}
function StatsBox({ title, value, description }: StatsBoxProps) {
return (
<div className="d-flex flex-1 flex-column border rounded p-3 p-md-4">
<div className="fs-6 text-center">{title}</div>
<div className="fs-4 text-center">{value}</div>
{description ? <div>{description}</div> : null}
</div>
)
}
interface EarnReportProps {
entries: EarnReportEntry[]
refresh: (signal: AbortSignal) => Promise<void>
}
export function EarnReport({ entries, refresh }: EarnReportProps) {
const { t } = useTranslation()
const settings = useSettings()
const [search, setSearch] = useState('')
const [isLoadingRefresh, setIsLoadingRefresh] = useState(false)
const tableData: TableTypes.Data<EarnReportTableRow> = useMemo(() => {
const searchVal = search.replace('.', '').toLowerCase()
const filteredEntries =
searchVal === ''
? entries
: entries.filter((entry) => {
return (
entry.timestamp.toLocaleString().toLowerCase().includes(searchVal) ||
entry.cjTotalAmount?.toString().includes(searchVal) ||
entry.inputCount?.toString().includes(searchVal) ||
entry.inputAmount?.toString().includes(searchVal) ||
entry.earnedAmount?.toString().includes(searchVal) ||
entry.inputCount?.toString().includes(searchVal) ||
entry.notes?.toLowerCase().includes(searchVal)
)
})
const nodes = filteredEntries.map((entry, index) => ({
...entry,
id: `${index}`,
}))
return { nodes }
}, [entries, search])
const earnedTotal: Api.AmountSats = useMemo(() => {
return entries.map((entry) => entry.earnedAmount ?? 0).reduce((previous, current) => previous + current, 0)
}, [entries])
const earned90Days: Api.AmountSats = useMemo(() => {
return entries
.filter((it) => it.timestamp.getTime() > Date.now() - 90 * 24 * 60 * 60 * 1_000)
.map((it) => it.earnedAmount ?? 0)
.reduce((previous, current) => previous + current, 0)
}, [entries])
const earned30Days: Api.AmountSats = useMemo(() => {
return entries
.filter((it) => it.timestamp.getTime() > Date.now() - 30 * 24 * 60 * 60 * 1_000)
.map((it) => it.earnedAmount ?? 0)
.reduce((previous, current) => previous + current, 0)
}, [entries])
const earned24Hours: Api.AmountSats = useMemo(() => {
return entries
.filter((it) => it.timestamp.getTime() > Date.now() - 1 * 24 * 60 * 60 * 1_000)
.map((it) => it.earnedAmount ?? 0)
.reduce((previous, current) => previous + current, 0)
}, [entries])
return (
<div className={styles.earnReportContainer}>
<div className={styles.titleBar}>
<div className="d-flex justify-content-center align-items-center gap-2">
<rb.Button
className={styles.refreshButton}
variant={settings.theme}
onClick={() => {
if (isLoadingRefresh) return
setIsLoadingRefresh(true)
const abortCtrl = new AbortController()
refresh(abortCtrl.signal).finally(() => {
// as refreshing is fast most of the time, add a short delay to avoid flickering
setTimeout(() => setIsLoadingRefresh(false), 250)
})
}}
>
{isLoadingRefresh ? (
<rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" />
) : (
<Sprite symbol="refresh" width="24" height="24" />
)}
</rb.Button>
<div className="small">
{search === '' ? (
<>
{t('earn.report.text_report_summary', {
count: entries.length,
})}
</>
) : (
<>
{t('earn.report.text_report_summary_filtered', {
count: tableData.nodes.length,
})}
</>
)}
</div>
</div>
<div>
<rb.Form.Group controlId="search">
<rb.Form.Label className="m-0 pe-2 d-none">{t('earn.report.label_search')}</rb.Form.Label>
<rb.Form.Control
name="search"
placeholder={t('earn.report.placeholder_search')}
value={search}
disabled={isLoadingRefresh}
onChange={(e) => setSearch(e.target.value)}
/>
</rb.Form.Group>
</div>
</div>
<div className="px-3 py-3 pt-lg-0">
<div className="d-flex flex-wrap justify-content-around align-items-center gap-2">
<StatsBox
title={t('earn.report.stats.earned_total')}
value={
<Balance valueString={earnedTotal.toString() || ''} convertToUnit={settings.unit} showBalance={true} />
}
/>
<StatsBox
title={t('earn.report.stats.earned_90days')}
value={
<Balance valueString={earned90Days.toString() || ''} convertToUnit={settings.unit} showBalance={true} />
}
/>
<StatsBox
title={t('earn.report.stats.earned_30days')}
value={
<Balance valueString={earned30Days.toString() || ''} convertToUnit={settings.unit} showBalance={true} />
}
/>
<StatsBox
title={t('earn.report.stats.earned_24hours')}
value={
<Balance valueString={earned24Hours.toString() || ''} convertToUnit={settings.unit} showBalance={true} />
}
/>
</div>
</div>
{entries.length === 0 ? (
<div className="px-2">
<rb.Alert variant="info">{t('earn.alert_empty_report')}</rb.Alert>
</div>
) : (
<div className="px-md-2">
<EarnReportTable data={tableData} />
</div>
)}
</div>
)
}
export function EarnReportOverlay({ show, onHide }: rb.OffcanvasProps) {
const { t } = useTranslation()
const [alert, setAlert] = useState<SimpleAlert>()
const [isInitialized, setIsInitialized] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [entries, setEntries] = useState<EarnReportEntry[] | null>(null)
const [__dev_showGenerateDemoReportButton] = useState(isDebugFeatureEnabled('enableDemoEarnReport'))
const __dev_generateDemoReportEntryButton = () => {
const randomTimestamp = new Date(Date.now() - Date.now() * Math.random() * Math.pow(10, pseudoRandomNumber(-5, -1)))
setEntries((it) => {
const connectedNote = {
timestamp: randomTimestamp,
cjTotalAmount: null,
inputCount: null,
inputAmount: null,
fee: null,
earnedAmount: null,
confirmationDuration: null,
notes: 'Connected ',
}
if (!it || it.length === 0) {
connectedNote.timestamp = new Date(Date.now() - Date.now() * 0.1)
return [connectedNote]
}
if (it.length > 2 && Math.random() > 0.8) {
return [...it, connectedNote]
}
const randomEntry = {
timestamp: randomTimestamp,
cjTotalAmount: Math.round(Math.random() * Math.pow(10, pseudoRandomNumber(7, 9))),
inputCount: Math.max(1, pseudoRandomNumber(-1, 4)),
inputAmount: Math.round(Math.random() * Math.pow(10, pseudoRandomNumber(3, 6))),
fee: Math.round(Math.random() * 100 + 1),
earnedAmount: Math.round(Math.random() * Math.pow(10, pseudoRandomNumber(1, 3)) + 1),
confirmationDuration: Math.round(Math.random() * 100),
notes: null,
}
return [...it, randomEntry]
})
}
const refresh = useCallback(
(signal: AbortSignal) => {
return Api.getYieldgenReport({ signal })
.then((res) => {
if (res.ok) return res.json()
// 404 is returned till the maker is started at least once
if (res.status === 404) return { yigen_data: [] }
return Api.Helper.throwError(res)
})
.then((data) => data.yigen_data as YieldgenReportLinesWithHeader)
.then((linesWithHeader) => yieldgenReportToEarnReportEntries(linesWithHeader))
.then((earnReportEntries) => {
if (signal.aborted) return
setAlert(undefined)
setEntries(earnReportEntries)
})
.catch((e) => {
if (signal.aborted) return
const message = t('earn.error_loading_report_failed', {
reason: e.message || t('global.errors.reason_unknown'),
})
setAlert({ variant: 'danger', message })
})
},
[t],
)
useEffect(() => {
if (!show) return
const abortCtrl = new AbortController()
setIsLoading(true)
refresh(abortCtrl.signal).finally(() => {
if (abortCtrl.signal.aborted) return
setIsLoading(false)
setIsInitialized(true)
})
return () => {
abortCtrl.abort()
}
}, [show, refresh])
return (
<rb.Offcanvas
className={`offcanvas-fullscreen ${styles.overlayContainer}`}
show={show}
onHide={onHide}
placement="bottom"
>
<rb.Offcanvas.Header>
<rb.Container fluid="lg">
<div className="w-100 d-flex">
<div className="d-flex align-items-center flex-1">
<rb.Offcanvas.Title>{t('earn.report.title')}</rb.Offcanvas.Title>
</div>
<div>
<rb.Button variant="link" className="unstyled pe-0" onClick={onHide}>
<Sprite symbol="cancel" width="32" height="32" />
</rb.Button>
</div>
</div>
</rb.Container>
</rb.Offcanvas.Header>
<rb.Offcanvas.Body>
<rb.Container fluid="lg" className="py-3">
{!isInitialized && isLoading ? (
Array(5)
.fill('')
.map((_, index) => {
return (
<rb.Placeholder key={index} as="div" animation="wave">
<rb.Placeholder xs={12} className={styles['report-line-placeholder']} />
</rb.Placeholder>
)
})
) : (
<>
{__dev_showGenerateDemoReportButton && (
<rb.Row>
<rb.Col className="px-0 mb-2">
<rb.Button
className="position-relative"
variant="outline-dark"
disabled={false}
onClick={() => __dev_generateDemoReportEntryButton()}
>
<div className="d-flex justify-content-center align-items-center">
{t('earn.report.text_button_generate_demo_report')}
<Sprite symbol="plus" width="20" height="20" className="ms-2" />
</div>
<span className="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-warning">
dev
</span>
</rb.Button>
</rb.Col>
</rb.Row>
)}
{alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
{entries && (
<rb.Row>
<rb.Col className="px-0">
<EarnReport entries={entries} refresh={refresh} />
</rb.Col>
</rb.Row>
)}
</>
)}
</rb.Container>
</rb.Offcanvas.Body>
</rb.Offcanvas>
)
}

View file

@ -1,84 +0,0 @@
import { Trans, useTranslation } from 'react-i18next'
import * as rb from 'react-bootstrap'
import { useRouteError } from 'react-router-dom'
import PageTitle from './PageTitle'
import { t } from 'i18next'
interface ErrorViewProps {
title: string
subtitle: string
reason: string
stacktrace?: string
}
function ErrorView({ title, subtitle, reason, stacktrace }: ErrorViewProps) {
return (
<div>
<PageTitle title={title} subtitle={subtitle} />
<p>
<Trans i18nKey="error_page.report_bug">
Please{' '}
<a
href="https://github.com/joinmarket-webui/jam/issues/new?labels=bug&template=bug_report.md"
target="_blank"
rel="noopener noreferrer"
>
open an issue on GitHub
</a>{' '}
for this error to be reviewed and resolved in an upcoming version.
</Trans>
</p>
<div className="my-4">
<h6>{t('error_page.heading_reason')}</h6>
<rb.Alert variant="danger">{reason}</rb.Alert>
</div>
{stacktrace && (
<div className="my-4">
<h6>{t('error_page.heading_stacktrace')}</h6>
<pre className="border p-2">
<code>{stacktrace}</code>
</pre>
</div>
)}
</div>
)
}
function UnknownError({ error }: { error: any }) {
const { t } = useTranslation()
return (
<ErrorView
title={t('error_page.unknown_error.title')}
subtitle={t('error_page.unknown_error.subtitle')}
reason={error.message || t('global.errors.reason_unknown')}
stacktrace={error.stack}
/>
)
}
function ErrorWithDetails({ error }: { error: Error }) {
const { t } = useTranslation()
return (
<ErrorView
title={t('error_page.error_with_details.title')}
subtitle={t('error_page.error_with_details.subtitle')}
reason={error.message || t('global.errors.reason_unknown')}
stacktrace={error.stack}
/>
)
}
export default function ErrorPage() {
const error = useRouteError()
if (error instanceof Error) {
return <ErrorWithDetails error={error} />
} else {
return <UnknownError error={error} />
}
}

View file

@ -1,16 +0,0 @@
import { Link, LinkProps } from 'react-router-dom'
interface Props extends LinkProps {
disabled?: boolean
}
export function ExtendedLink({ disabled, ...props }: Props) {
if (disabled) {
return (
<button disabled className={`${props.className} pe-auto`}>
{props.children}
</button>
)
}
return <Link {...props}>{props.children}</Link>
}

View file

@ -1,202 +1,22 @@
import { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import * as rb from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { useSettings, useSettingsDispatch } from '../context/SettingsContext'
import { useServiceInfo } from '../context/ServiceInfoContext'
import { useWebsocketState } from '../context/WebsocketContext'
import { useCurrentWallet } from '../context/WalletContext'
import Sprite from './Sprite'
import Cheatsheet from './Cheatsheet'
import { InfoModal } from './Modal'
import { isDebugFeatureEnabled, isDevMode } from '../constants/debugFeatures'
import { routes } from '../constants/routes'
import { toSemVer } from '../utils'
import { OrderbookOverlay } from './Orderbook'
import packageInfo from '../../package.json'
const APP_DISPLAY_VERSION = (() => {
const version = toSemVer(packageInfo.version)
return !isDevMode() ? version.raw : `${version.major}.${version.minor}.${version.patch + 1}dev`
})()
export default function Footer() {
const { t } = useTranslation()
const currentWallet = useCurrentWallet()
const settings = useSettings()
const serviceInfo = useServiceInfo()
const settingsDispatch = useSettingsDispatch()
const websocketState = useWebsocketState()
const [showBetaWarning, setShowBetaWarning] = useState(false)
const [showCheatsheet, setShowCheatsheet] = useState(false)
const [isShowOrderbook, setIsShowOrderbook] = useState(false)
const cheatsheetEnabled = useMemo(() => !!currentWallet, [currentWallet])
const orderbookEnabled = useMemo(() => !!currentWallet, [currentWallet])
const websocketConnected = useMemo(() => websocketState === WebSocket.OPEN, [websocketState])
useEffect(() => {
let timer: NodeJS.Timeout
// show the cheatsheet once after the first wallet has been created
if (cheatsheetEnabled && settings.showCheatsheet) {
timer = setTimeout(() => {
setShowCheatsheet(true)
settingsDispatch({ showCheatsheet: false })
}, 1_000)
}
return () => clearTimeout(timer)
}, [cheatsheetEnabled, settings, settingsDispatch])
import { File } from 'lucide-react'
import { Button } from './ui/button'
export function Footer() {
return (
<>
{showBetaWarning && (
<InfoModal
isShown={showBetaWarning}
size="sm"
title={t('footer.warning_alert_title')}
submitButtonText={t('footer.warning_alert_button_ok')}
onCancel={() => setShowBetaWarning(false)}
onSubmit={() => setShowBetaWarning(false)}
>
<p>{t('footer.warning_alert_text')}</p>
<p className="mb-0 text-secondary">
JoinMarket: v{serviceInfo?.server?.version?.raw || '_unknown'}
<br />
Jam: v{APP_DISPLAY_VERSION}
</p>
</InfoModal>
)}
<rb.Nav as="footer" className="border-top py-2">
<rb.Container fluid="xl" className="d-flex justify-content-center py-2 px-4">
<div className="d-none d-md-flex flex-1 order-0 justify-content-start align-items-center">
<div className="text-small text-start text-secondary">
<Trans i18nKey="footer.warning">
This is pre-alpha software.
<rb.Button
variant="link"
className="text-small text-start border-0 p-0 text-secondary"
onClick={() => setShowBetaWarning(true)}
>
Read this before using.
</rb.Button>
</Trans>
</div>
</div>
<div className="d-flex order-1 flex-1 flex-grow-0 justify-content-center align-items-center gap-1">
{cheatsheetEnabled && (
<>
<Cheatsheet show={showCheatsheet} onHide={() => setShowCheatsheet(false)} />
<rb.Nav.Item>
<rb.Button
type="button"
variant="link"
className="nav-link text-start border-0 px-2"
onClick={() => setShowCheatsheet(true)}
>
<div className="d-flex justify-content-center align-items-center">
<Sprite symbol="file-outline" width="24" height="24" />
<div className="ps-1">{t('footer.cheatsheet')}</div>
</div>
</rb.Button>
</rb.Nav.Item>
</>
)}
{orderbookEnabled && (
<>
<OrderbookOverlay
show={isShowOrderbook}
onHide={() => setIsShowOrderbook(false)}
nickname={serviceInfo?.nickname ?? undefined}
/>
<rb.Nav.Item>
<rb.Button
type="button"
variant="link"
className="nav-link text-start border-0 px-2"
onClick={() => setIsShowOrderbook(true)}
>
<div className="d-flex justify-content-center align-items-center">
<Sprite symbol="globe" width="24" height="24" />
<div className="ps-1">{t('footer.orderbook')}</div>
</div>
</rb.Button>
</rb.Nav.Item>
</>
)}
</div>
<div className="d-flex flex-1 order-2 justify-content-end align-items-center gap-1">
{isDebugFeatureEnabled('devSetupPage') && (
<div className="d-none d-md-block text-small text-start">
<Link className="text-warning" to={routes.__devSetup}>
Dev Setup
</Link>
</div>
)}
<div className="text-small text-start text-secondary me-1">
<a
href="https://github.com/joinmarket-webui/jam/tags"
target="_blank"
rel="noopener noreferrer"
className="d-flex align-items-center text-secondary"
>
v{APP_DISPLAY_VERSION}
</a>
</div>
<div className="d-flex gap-2 me-1">
<a
href="https://github.com/joinmarket-webui/jam"
target="_blank"
rel="noopener noreferrer"
className="d-flex align-items-center text-secondary"
>
<Sprite symbol="github" width="18px" height="18px" />
</a>
<a
href="https://matrix.to/#/%23jam:bitcoin.kyoto"
target="_blank"
rel="noopener noreferrer"
className="d-flex align-items-center text-secondary"
>
<Sprite symbol="matrix" width="18px" height="18px" />
</a>
<a
href="https://t.me/JoinMarketWebUI"
target="_blank"
rel="noopener noreferrer"
className="d-flex align-items-center text-secondary"
>
<Sprite symbol="telegram" width="18px" height="18px" />
</a>
</div>
<div className="text-secondary">|</div>
<div className="d-flex">
<rb.OverlayTrigger
delay={{ hide: 300, show: 200 }}
placement="top"
overlay={(props) => (
<rb.Tooltip {...props}>
{websocketConnected ? (
<>{t('footer.websocket_connected')}</>
) : (
<>{t('footer.websocket_disconnected')}</>
)}
</rb.Tooltip>
)}
>
<span
className={`mx-1 ${websocketConnected ? 'text-success' : 'text-secondary'}`}
data-testid="connection-indicator-icon"
>
<Sprite symbol="node" width="24px" height="24px" />
</span>
</rb.OverlayTrigger>
</div>
</div>
</rb.Container>
</rb.Nav>
</>
<footer className="flex justify-between items-center p-4 bg-white text-xs opacity-60 text-black dark:bg-[#181b20] dark:text-white transition-colors duration-300">
<span className="flex-1">
This is beta software. <br />
<a href="#" className="underline">
Read this before using.
</a>
</span>
<div className="flex-1 flex items-center hover:underline justify-center">
<Button variant="ghost" size="sm">
<File />
Cheatsheet
</Button>
</div>
<span className="flex-1 text-right opacity-70">© 2025 Hodlers</span>
</footer>
)
}

View file

@ -1,684 +0,0 @@
import { useState, useMemo, useCallback } from 'react'
import * as rb from 'react-bootstrap'
import { Formik, FormikErrors } from 'formik'
import { Link, useNavigate } from 'react-router-dom'
import { Trans, useTranslation } from 'react-i18next'
import classNames from 'classnames'
import * as Api from '../libs/JmWalletApi'
import { useServiceInfo, useDispatchServiceInfo } from '../context/ServiceInfoContext'
import { useRefreshConfigValues, useUpdateConfigValues } from '../context/ServiceConfigContext'
import PageTitle from './PageTitle'
import Sprite from './Sprite'
import Accordion from './Accordion'
import WalletCreationForm, { CreateWalletFormValues } from './WalletCreationForm'
import MnemonicPhraseInput from './MnemonicPhraseInput'
import PreventLeavingPageByMistake from './PreventLeavingPageByMistake'
import { CreatedWalletInfo, WalletCreationInfoSummary } from './WalletCreationConfirmation'
import { isDevMode, isDebugFeatureEnabled } from '../constants/debugFeatures'
import { routes, Route } from '../constants/routes'
import {
SEGWIT_ACTIVATION_BLOCK,
DUMMY_MNEMONIC_PHRASE,
walletDisplayName,
isValidNumber,
walletDisplayNameToFileName,
} from '../utils'
import { JM_GAPLIMIT_DEFAULT, JM_GAPLIMIT_CONFIGKEY } from '../constants/jm'
type ImportWalletDetailsFormValues = {
mnemonicPhrase: MnemonicPhrase
blockheight: number
gaplimit: number
}
const GAPLIMIT_SUGGESTIONS = {
normal: JM_GAPLIMIT_DEFAULT,
heavy: JM_GAPLIMIT_DEFAULT * 4,
}
const MIN_BLOCKHEIGHT_VALUE = 0
/**
* Maximum blockheight value.
* Value choosen based on estimation of blockheight in tge year 2140 (plus some buffer):
* 365 × 144 × (2140 - 2009) = 6_885_360 = ~7_000_000
* This is necessary because javascript does not handle large values too well,
* and the `/rescanblockchain` errors. Not to mention that a value beyond the current
* height does not make any sense in the first place.
*/
const MAX_BLOCKHEIGHT_VALUE = 10_000_000
const MIN_GAPLIMIT_VALUE = 1
/**
* Maximum gaplimit value for importing an existing wallet.
* This value represents an upper limit based on declining performance of JM when many
* addresses have to be monitored. On network `regtest`, importing 10_000 addresses in
* an empty wallet takes ~10min and requesting the `/display` endpoint takes another
* ~10min. At this point, JM becomes practically unusable. However, goal is to find a
* balance between usability and freedom of users to do what they are trying to do.
*/
const MAX_GAPLIMIT_VALUE = 10_000
/**
* A gaplimit threshold at which a warning is displayed that with the given value a
* decline in performance is to be expected. Importing 500 addresses (per jar!) leads to
* the `/display` endpoint taking more than ~15s.
*/
const GAPLIMIT_WARN_THRESHOLD = 250
const initialImportWalletDetailsFormValues: ImportWalletDetailsFormValues = isDevMode()
? {
mnemonicPhrase: new Array<string>(12).fill(''),
blockheight: MIN_BLOCKHEIGHT_VALUE,
gaplimit: GAPLIMIT_SUGGESTIONS.heavy,
}
: {
mnemonicPhrase: new Array<string>(12).fill(''),
blockheight: SEGWIT_ACTIVATION_BLOCK,
gaplimit: GAPLIMIT_SUGGESTIONS.normal,
}
interface ImportWalletDetailsFormProps {
initialValues?: ImportWalletDetailsFormValues
submitButtonText: (isSubmitting: boolean) => React.ReactNode | string
onCancel: () => void
onSubmit: (values: ImportWalletDetailsFormValues) => Promise<void>
}
type RecoveredWalletWithAuth = Pick<CreatedWalletInfo, 'walletFileName'> & {
auth: Api.ApiAuthContext
}
const ImportWalletDetailsForm = ({
initialValues = initialImportWalletDetailsFormValues,
submitButtonText,
onCancel,
onSubmit,
}: ImportWalletDetailsFormProps) => {
const { t, i18n } = useTranslation()
const [__dev_showFillerButton] = useState(isDebugFeatureEnabled('importDummyMnemonicPhrase'))
const validate = useCallback(
(values: ImportWalletDetailsFormValues) => {
const errors = {} as FormikErrors<ImportWalletDetailsFormValues>
const isMnemonicPhraseValid = values.mnemonicPhrase.every((it) => it.length > 0)
if (!isMnemonicPhraseValid) {
errors.mnemonicPhrase = t('import_wallet.import_details.feedback_invalid_menmonic_phrase')
}
if (
!isValidNumber(values.blockheight) ||
values.blockheight < MIN_BLOCKHEIGHT_VALUE ||
values.blockheight > MAX_BLOCKHEIGHT_VALUE
) {
errors.blockheight = t('import_wallet.import_details.feedback_invalid_blockheight', {
min: MIN_BLOCKHEIGHT_VALUE.toLocaleString(),
})
}
if (
!isValidNumber(values.gaplimit) ||
values.gaplimit < MIN_GAPLIMIT_VALUE ||
values.gaplimit > MAX_GAPLIMIT_VALUE
) {
errors.gaplimit = t('import_wallet.import_details.feedback_invalid_gaplimit', {
min: MIN_GAPLIMIT_VALUE.toLocaleString(),
max: MAX_GAPLIMIT_VALUE.toLocaleString(),
})
}
return errors
},
[t],
)
return (
<Formik initialValues={initialValues} validate={validate} onSubmit={onSubmit}>
{({
handleSubmit,
handleBlur,
handleChange,
setFieldValue,
values,
touched,
errors,
isSubmitting,
submitCount,
}) => {
const hasImportDetailsSectionErrors = !!errors.blockheight || !!errors.gaplimit
const showGaplimitWarning = !errors.gaplimit && values.gaplimit > GAPLIMIT_WARN_THRESHOLD
return (
<rb.Form onSubmit={handleSubmit} noValidate lang={i18n.resolvedLanguage || i18n.language}>
<MnemonicPhraseInput
mnemonicPhrase={values.mnemonicPhrase}
onChange={(val) => setFieldValue('mnemonicPhrase', val, true)}
isDisabled={(_) => isSubmitting}
/>
{!!errors.mnemonicPhrase && (
<>
<div
className={classNames('mb-2', 'text-danger', {
'd-none': submitCount === 0,
})}
>
{errors.mnemonicPhrase}
</div>
</>
)}
{__dev_showFillerButton && (
<rb.Button
variant="outline-dark"
className="w-100 mb-4 position-relative"
onClick={() => setFieldValue('mnemonicPhrase', DUMMY_MNEMONIC_PHRASE, true)}
disabled={isSubmitting}
>
Fill with dummy mnemonic phrase
<span className="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-warning">
dev
</span>
</rb.Button>
)}
<Accordion
title={t('import_wallet.import_details.import_options')}
variant={hasImportDetailsSectionErrors ? 'danger' : showGaplimitWarning ? 'warning' : undefined}
defaultOpen={true}
>
<rb.Form.Group controlId="blockheight" className="mb-4">
<rb.Form.Label>{t('import_wallet.import_details.label_blockheight')}</rb.Form.Label>
<rb.Form.Text className="d-block text-secondary mb-2">
{t('import_wallet.import_details.description_blockheight')}
</rb.Form.Text>
<rb.InputGroup hasValidation>
<rb.InputGroup.Text id="blockheight-addon1">
<Sprite symbol="block" width="24" height="24" name="Block" />
</rb.InputGroup.Text>
<rb.Form.Control
aria-label={t('import_wallet.import_details.label_blockheight')}
className="slashed-zeroes"
name="blockheight"
type="number"
placeholder="0"
size="lg"
value={values.blockheight}
disabled={isSubmitting}
onBlur={handleBlur}
onChange={handleChange}
isValid={touched.blockheight && !errors.blockheight}
isInvalid={touched.blockheight && !!errors.blockheight}
min={MIN_BLOCKHEIGHT_VALUE}
max={MAX_BLOCKHEIGHT_VALUE}
step={1_000}
required
/>
<rb.Form.Control.Feedback type="invalid">{errors.blockheight}</rb.Form.Control.Feedback>
</rb.InputGroup>
</rb.Form.Group>
<rb.Form.Group controlId="gaplimit" className="mb-4">
<rb.Form.Label>{t('import_wallet.import_details.label_gaplimit')}</rb.Form.Label>
<rb.Form.Text className="d-block text-secondary mb-2">
{t('import_wallet.import_details.description_gaplimit')}
</rb.Form.Text>
<rb.InputGroup hasValidation>
<rb.InputGroup.Text id="gaplimit-addon1">
<Sprite symbol="gaplimit" width="24" height="24" name="Gaplimit" />
</rb.InputGroup.Text>
<rb.Form.Control
aria-label={t('import_wallet.import_details.label_gaplimit')}
className="slashed-zeroes"
name="gaplimit"
type="number"
placeholder="1"
size="lg"
value={values.gaplimit}
disabled={isSubmitting}
onBlur={handleBlur}
onChange={handleChange}
isValid={touched.gaplimit && !errors.gaplimit}
isInvalid={touched.gaplimit && !!errors.gaplimit}
min={MIN_GAPLIMIT_VALUE}
max={MAX_GAPLIMIT_VALUE}
step={1}
required
/>
<rb.Form.Control.Feedback type="invalid">{errors.gaplimit}</rb.Form.Control.Feedback>
</rb.InputGroup>
{showGaplimitWarning && (
<rb.Alert variant="warning" className="d-flex align-items-center mt-2">
{t('import_wallet.import_details.alert_high_gaplimit_value')}
</rb.Alert>
)}
</rb.Form.Group>
</Accordion>
<rb.Button className="w-100 mb-4" variant="dark" size="lg" type="submit" disabled={isSubmitting}>
<div className="d-flex justify-content-center align-items-center">
{isSubmitting && (
<rb.Spinner
as="span"
animation="border"
size="sm"
role="status"
aria-hidden="true"
className="me-2"
/>
)}
{submitButtonText(isSubmitting)}
</div>
</rb.Button>
<div className="d-flex mb-4 gap-4">
<rb.Button variant="none" hidden={isSubmitting} disabled={isSubmitting} onClick={() => onCancel()}>
<div className="d-flex justify-content-center align-items-center">
<Sprite symbol="arrow-left" width="20" height="20" className="me-2" />
{t('global.back')}
</div>
</rb.Button>
</div>
</rb.Form>
)
}}
</Formik>
)
}
type ImportWalletConfirmationFormValues = {
walletDetails: CreateWalletFormValues
importDetails: ImportWalletDetailsFormValues
}
interface ImportWalletConfirmationProps {
walletDetails: CreateWalletFormValues
importDetails: ImportWalletDetailsFormValues
submitButtonText: (isSubmitting: boolean) => React.ReactNode | string
onCancel: () => void
onSubmit: (values: ImportWalletConfirmationFormValues) => Promise<void>
}
const ImportWalletConfirmation = ({
walletDetails,
importDetails,
submitButtonText,
onCancel,
onSubmit,
}: ImportWalletConfirmationProps) => {
const { t, i18n } = useTranslation()
const walletInfo = useMemo<CreatedWalletInfo>(
() => ({
walletFileName: walletDisplayNameToFileName(walletDetails.walletName),
password: walletDetails.password,
seedphrase: importDetails.mnemonicPhrase.join(' '),
}),
[walletDetails, importDetails],
)
const showGaplimitWarning = useMemo(() => importDetails.gaplimit > GAPLIMIT_WARN_THRESHOLD, [importDetails])
return (
<Formik
initialValues={{
walletDetails,
importDetails,
}}
onSubmit={onSubmit}
>
{({ handleSubmit, values, isSubmitting, submitCount }) => (
<rb.Form onSubmit={handleSubmit} noValidate lang={i18n.resolvedLanguage || i18n.language}>
<WalletCreationInfoSummary walletInfo={walletInfo} revealSensitiveInfo={!isSubmitting && submitCount === 0} />
<Accordion
title={t('import_wallet.import_details.import_options')}
variant={showGaplimitWarning ? 'warning' : undefined}
>
<div className="mb-4">
<div>{t('import_wallet.import_details.label_blockheight')}</div>
<div className="text-secondary small">{t('import_wallet.import_details.description_blockheight')}</div>
<div className="fs-4">{values.importDetails.blockheight}</div>
</div>
<div className="mb-4">
<div>{t('import_wallet.import_details.label_gaplimit')}</div>
<div className="text-secondary small">{t('import_wallet.import_details.description_gaplimit')}</div>
<div className="fs-4">{values.importDetails.gaplimit}</div>
{showGaplimitWarning && (
<rb.Alert variant="warning" className="d-flex align-items-center mt-2">
{t('import_wallet.import_details.alert_high_gaplimit_value')}
</rb.Alert>
)}
</div>
</Accordion>
<rb.Button className="w-100 mb-4" variant="dark" size="lg" type="submit" disabled={isSubmitting}>
<div className="d-flex justify-content-center align-items-center">
{isSubmitting && (
<rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" className="me-2" />
)}
{submitButtonText(isSubmitting)}
</div>
</rb.Button>
{isSubmitting && (
<div className="text-center text-muted small mb-4">
<p>{t('create_wallet.hint_duration_text')}</p>
</div>
)}
<div className="d-flex mb-4 gap-4">
<rb.Button variant="none" hidden={isSubmitting} disabled={isSubmitting} onClick={() => onCancel()}>
<div className="d-flex justify-content-center align-items-center">
<Sprite symbol="arrow-left" width="20" height="20" className="me-2" />
{t('global.back')}
</div>
</rb.Button>
</div>
</rb.Form>
)}
</Formik>
)
}
enum ImportWalletSteps {
wallet_details,
import_details,
confirm_and_submit,
success,
}
interface ImportWalletProps {
parentRoute: Route
startWallet: (name: Api.WalletFileName, auth: Api.ApiAuthContext) => void
}
export default function ImportWallet({ parentRoute, startWallet }: ImportWalletProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const serviceInfo = useServiceInfo()
const dispatchServiceInfo = useDispatchServiceInfo()
const refreshConfigValues = useRefreshConfigValues()
const updateConfigValues = useUpdateConfigValues()
const [alert, setAlert] = useState<SimpleAlert>()
const [createWalletFormValues, setCreateWalletFormValues] = useState<CreateWalletFormValues>()
const [importDetailsFormValues, setImportDetailsFormValues] = useState<ImportWalletDetailsFormValues>()
const [recoveredWallet, setRecoveredWallet] = useState<RecoveredWalletWithAuth>()
const isRecovered = useMemo(() => !!recoveredWallet?.walletFileName && !!recoveredWallet?.auth, [recoveredWallet])
const canRecover = useMemo(
() => !isRecovered && !serviceInfo?.walletFileName && !serviceInfo?.rescanning,
[isRecovered, serviceInfo],
)
const [step, setStep] = useState<ImportWalletSteps>(ImportWalletSteps.wallet_details)
const nextStep = useCallback(
() =>
setStep((old) => {
switch (old) {
case ImportWalletSteps.wallet_details:
return ImportWalletSteps.import_details
case ImportWalletSteps.import_details:
return ImportWalletSteps.confirm_and_submit
case ImportWalletSteps.confirm_and_submit:
return ImportWalletSteps.success
default:
return old
}
}),
[],
)
const previousStep = useCallback(() => {
setAlert(undefined)
setStep((old) => {
switch (old) {
case ImportWalletSteps.import_details:
return ImportWalletSteps.wallet_details
case ImportWalletSteps.confirm_and_submit:
return ImportWalletSteps.import_details
case ImportWalletSteps.success:
return ImportWalletSteps.success // cannot go back from success page
default:
return old
}
})
}, [])
const recoverWallet = useCallback(
async (
signal: AbortSignal,
{
walletname,
password,
seedphrase,
gaplimit,
blockheight,
}: {
walletname: Api.WalletFileName
password: string
seedphrase: string
gaplimit: number
blockheight: number
},
) => {
setAlert(undefined)
try {
// Step #1: recover wallet
const recoverResponse = await Api.postWalletRecover({ signal }, { walletname, password, seedphrase })
const recoverBody = await (recoverResponse.ok ? recoverResponse.json() : Api.Helper.throwError(recoverResponse))
const { walletname: walletFileName } = recoverBody
let auth: Api.ApiAuthContext = Api.Helper.parseAuthProps(recoverBody)
setRecoveredWallet({ walletFileName, auth })
// Step #2: update the gaplimit config value if necessary
const originalGaplimit = await refreshConfigValues({
signal,
keys: [JM_GAPLIMIT_CONFIGKEY],
wallet: { walletFileName, token: auth.token },
})
.then((it) => it[JM_GAPLIMIT_CONFIGKEY.section] || {})
.then((it) => parseInt(it[JM_GAPLIMIT_CONFIGKEY.field] || String(JM_GAPLIMIT_DEFAULT), 10))
.then((it) => it || JM_GAPLIMIT_DEFAULT)
const gaplimitUpdateNecessary = gaplimit !== originalGaplimit
if (gaplimitUpdateNecessary) {
console.info('Will update gaplimit from %d to %d', originalGaplimit, gaplimit)
await updateConfigValues({
signal,
updates: [
{
key: JM_GAPLIMIT_CONFIGKEY,
value: String(gaplimit),
},
],
wallet: { walletFileName, token: auth.token },
})
}
// Step #3: lock and unlock the wallet (for new addresses to be imported)
const lockResponse = await Api.getWalletLock({ walletFileName, token: auth.token })
if (!lockResponse.ok) await Api.Helper.throwError(lockResponse)
const unlockResponse = await Api.postWalletUnlock({ walletFileName }, { password })
const unlockBody = await (unlockResponse.ok ? unlockResponse.json() : Api.Helper.throwError(unlockResponse))
auth = Api.Helper.parseAuthProps(unlockBody)
// Step #4: reset `gaplimit´ to previous value if necessary
if (gaplimitUpdateNecessary) {
console.info('Will reset gaplimit to previous value %d', originalGaplimit)
await updateConfigValues({
signal,
updates: [
{
key: JM_GAPLIMIT_CONFIGKEY,
value: String(originalGaplimit),
},
],
wallet: { walletFileName, token: auth.token },
})
}
// Step #5: invoke rescanning the timechain
console.info('Will start rescanning timechain from block %d', blockheight)
const rescanResponse = await Api.getRescanBlockchain({
signal,
walletFileName,
token: unlockBody.token,
blockheight,
})
if (!rescanResponse.ok) {
await Api.Helper.throwError(rescanResponse)
} else {
dispatchServiceInfo({
rescanning: true,
})
}
startWallet(walletFileName, auth)
nextStep()
} catch (e: any) {
if (signal.aborted) return
const message = t('import_wallet.error_importing_failed', {
reason: e.message || t('global.errors.reason_unknown'),
})
setAlert({ variant: 'danger', message })
}
},
[
setRecoveredWallet,
startWallet,
nextStep,
setAlert,
refreshConfigValues,
updateConfigValues,
dispatchServiceInfo,
t,
],
)
return (
<div className="import-wallet">
<>
{step === ImportWalletSteps.wallet_details && <PageTitle title={t('import_wallet.wallet_details.title')} />}
{step === ImportWalletSteps.import_details && (
<PageTitle
title={t('import_wallet.import_details.title')}
subtitle={t('import_wallet.import_details.subtitle')}
/>
)}
{step === ImportWalletSteps.confirm_and_submit && <PageTitle title={t('import_wallet.confirmation.title')} />}
{step === ImportWalletSteps.success && (
<PageTitle
title={t('import_wallet.success.title')}
subtitle={t('import_wallet.success.subtitle')}
success={true}
/>
)}
</>
{alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
{!canRecover && !isRecovered ? (
<>
{serviceInfo?.walletFileName && (
<rb.Alert variant="warning">
<Trans
i18nKey="import_wallet.alert_other_wallet_unlocked"
values={{
walletName: walletDisplayName(serviceInfo.walletFileName),
}}
>
Currently <strong>walletName</strong> is active. You need to lock it first.
<Link to={routes.walletList} className="alert-link">
Go back
</Link>
.
</Trans>
</rb.Alert>
)}
{serviceInfo?.rescanning === true && (
<rb.Alert variant="warning" data-testid="alert-rescanning">
<Trans i18nKey="import_wallet.alert_rescan_in_progress">
Rescanning the timechain is currently in progress. Please wait until the process finishes and then try
again.
<Link to={routes.walletList} className="alert-link">
Go back
</Link>
.
</Trans>
</rb.Alert>
)}
</>
) : (
<>
{step !== ImportWalletSteps.success && <PreventLeavingPageByMistake />}
{step === ImportWalletSteps.wallet_details && (
<WalletCreationForm
initialValues={createWalletFormValues}
onCancel={() => navigate(routes[parentRoute])}
onSubmit={async (values) => {
setCreateWalletFormValues(values)
nextStep()
}}
submitButtonText={(isSubmitting) =>
t(
isSubmitting
? 'import_wallet.wallet_details.text_button_submitting'
: 'import_wallet.wallet_details.text_button_submit',
)
}
/>
)}
{step === ImportWalletSteps.import_details && (
<ImportWalletDetailsForm
initialValues={importDetailsFormValues}
submitButtonText={(isSubmitting) =>
t(
isSubmitting
? 'import_wallet.import_details.text_button_submitting'
: 'import_wallet.import_details.text_button_submit',
)
}
onCancel={() => previousStep()}
onSubmit={async (values) => {
setImportDetailsFormValues(values)
nextStep()
}}
/>
)}
{step === ImportWalletSteps.confirm_and_submit && (
<ImportWalletConfirmation
walletDetails={createWalletFormValues!}
importDetails={importDetailsFormValues!}
submitButtonText={(isSubmitting) =>
t(
isSubmitting
? 'import_wallet.confirmation.text_button_submitting'
: 'import_wallet.confirmation.text_button_submit',
)
}
onCancel={() => previousStep()}
onSubmit={(values) => {
const abortCtrl = new AbortController()
return recoverWallet(abortCtrl.signal, {
walletname: walletDisplayNameToFileName(values.walletDetails.walletName),
password: values.walletDetails.password,
seedphrase: values.importDetails.mnemonicPhrase.join(' '),
gaplimit: values.importDetails.gaplimit,
blockheight: values.importDetails.blockheight,
})
}}
/>
)}
{step === ImportWalletSteps.success && (
<div className="d-flex justify-content-center my-4 gap-4">
<Link
className="btn btn-lg btn-dark d-inline-flex justify-content-center align-items-center"
to={routes.wallet}
>
{t('import_wallet.success.text_button_submit')}
<Sprite symbol="arrow-right" width="20" height="20" className="ms-2" />
</Link>
</div>
)}
</>
)}
</div>
)
}

View file

@ -1,15 +0,0 @@
.input {
height: 3.5rem;
width: 100%;
}
.input-loader {
height: 3.5rem;
border-radius: 0.25rem;
}
.walletLink {
cursor: pointer;
text-decoration: none;
color: var(--bs-body-color);
}

View file

@ -1,602 +0,0 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import * as rb from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { Formik, FormikErrors, FormikValues, useFormikContext } from 'formik'
import * as Api from '../libs/JmWalletApi'
import { useSettings } from '../context/SettingsContext'
import { useServiceInfo, useReloadServiceInfo, Schedule, StateFlag } from '../context/ServiceInfoContext'
import { CurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo, WalletInfo } from '../context/WalletContext'
import { isDebugFeatureEnabled } from '../constants/debugFeatures'
import { buildCoinjoinRequirementSummary } from '../hooks/CoinjoinRequirements'
import { CoinjoinPreconditionViolationAlert } from './CoinjoinPreconditionViolationAlert'
import PageTitle from './PageTitle'
import ToggleSwitch from './ToggleSwitch'
import Sprite from './Sprite'
import Balance from './Balance'
import ScheduleProgress from './ScheduleProgress'
import { ConfirmModal, ConfirmModalProps } from './Modal'
import FeeConfigModal from './settings/FeeConfigModal'
import { useFeeConfigValues } from '../hooks/Fees'
import styles from './Jam.module.css'
const DEST_ADDRESS_COUNT_PROD = 3
const DEST_ADDRESS_COUNT_TEST = 1
const getNewAddressesForTesting = (
walletInfo: WalletInfo,
count: number,
mixdepth: number,
): Array<Api.BitcoinAddress> => {
const externalBranch = walletInfo.data.display.walletinfo.accounts[mixdepth].branches.find((branch) => {
return branch.branch.split('\t')[0] === 'external addresses'
})
const newEntries = (externalBranch?.entries || []).filter((entry) => entry.status === 'new').slice(0, count)
if (newEntries.length !== count) {
throw new Error(`Cannot find enough fresh addresses in mixdepth ${mixdepth}`)
}
return newEntries.map((it) => it.address)
}
const getNewAddressesForTestingOrEmpty = (
walletInfo: WalletInfo | undefined,
count: number,
mixdepth = 0,
): Array<Api.BitcoinAddress | ''> => {
if (!walletInfo) {
return Array(count).fill('')
}
try {
return getNewAddressesForTesting(walletInfo, count, mixdepth)
} catch (e) {
console.log('Error while getting test addresses', e)
return Array(count).fill('')
}
}
const addressValueKeys = (addressCount: number) =>
Array(addressCount)
.fill('')
.map((_, index) => `dest${index + 1}`)
const isValidAddress = (candidate: any) => {
return typeof candidate === 'string' && candidate !== ''
}
const isAddressReused = (
walletInfo: WalletInfo,
destination: Api.BitcoinAddress,
inputAddresses: Api.BitcoinAddress[],
) => {
if (!destination) return false
const knownAddress = walletInfo.addressSummary[destination] || false
const alreadyUsed = knownAddress && walletInfo.addressSummary[destination]?.status !== 'new'
const duplicateEntry = inputAddresses.filter((it) => it === destination).length > 1
return alreadyUsed || duplicateEntry
}
interface ValueListenerProps {
handler: (values?: any) => Promise<FormikErrors<any>>
addressCount: number
}
const ValuesListener = ({ handler, addressCount }: ValueListenerProps) => {
const { values } = useFormikContext<any>()
useEffect(() => {
const allValuesPresent = addressValueKeys(addressCount)
.map((key) => values[key])
.every((val) => val !== '')
if (allValuesPresent) {
handler()
}
}, [values, handler, addressCount])
return null
}
function useLatestTruthy<T>(val: T): [T | undefined, () => void] {
const [prev, setPrev] = useState<T | undefined>(undefined)
useEffect(() => {
if (!!val) {
setPrev(val)
}
}, [val])
return [prev, () => setPrev(undefined)]
}
interface SchedulerSuccessMessageProps {
schedule: Schedule
onConfirm: () => void
}
function SchedulerSuccessMessage({ schedule, onConfirm }: SchedulerSuccessMessageProps) {
const { t } = useTranslation()
return (
<>
<PageTitle
success={true}
center={true}
title={t('scheduler.success.title')}
subtitle={t('scheduler.success.subtitle', { count: schedule.length })}
/>
<div className="d-flex justify-content-center">
<rb.Button
variant="outline-dark"
className="border-0 mb-2 d-inline-flex align-items-center"
onClick={() => onConfirm()}
>
{t('scheduler.success.text_button_submit')}
<Sprite symbol="caret-right" width="24" height="24" className="ms-1" />
</rb.Button>
</div>
</>
)
}
type ScheduleConfirmModalProps = Omit<ConfirmModalProps, 'title'>
function ScheduleConfirmModal(props: ScheduleConfirmModalProps) {
const { t } = useTranslation()
return (
<ConfirmModal title={t('scheduler.confirm_modal.title')} {...props}>
{t('scheduler.confirm_modal.body')}
</ConfirmModal>
)
}
interface JamProps {
wallet: CurrentWallet
}
export default function Jam({ wallet }: JamProps) {
const { t } = useTranslation()
const settings = useSettings()
const serviceInfo = useServiceInfo()
const reloadServiceInfo = useReloadServiceInfo()
const walletInfo = useCurrentWalletInfo()
const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
const [alert, setAlert] = useState<SimpleAlert>()
const [isLoading, setIsLoading] = useState(true)
const [showFeeConfigModal, setShowFeeConfigModal] = useState(false)
const [isWaitingSchedulerStart, setIsWaitingSchedulerStart] = useState(false)
const [isWaitingSchedulerStop, setIsWaitingSchedulerStop] = useState(false)
const [currentSchedule, setCurrentSchedule] = useState<Schedule | null>(null)
const [lastKnownSchedule, resetLastKnownSchedule] = useLatestTruthy(currentSchedule ?? undefined)
const [isShowSuccessMessage, setIsShowSuccessMessage] = useState(false)
const [feeConfigValues, reloadFeeConfigValues] = useFeeConfigValues()
const [showScheduleConfirmModal, setShowScheduleConfirmModal] = useState(false)
const maxFeesConfigMissing = useMemo(
() =>
feeConfigValues && (feeConfigValues.max_cj_fee_abs === undefined || feeConfigValues.max_cj_fee_rel === undefined),
[feeConfigValues],
)
const isRescanningInProgress = useMemo(() => serviceInfo?.rescanning === true, [serviceInfo])
const collaborativeOperationRunning = useMemo(
() => serviceInfo?.coinjoinInProgress || serviceInfo?.makerRunning || false,
[serviceInfo],
)
const isOperationDisabled = useMemo(
() => maxFeesConfigMissing || collaborativeOperationRunning || isRescanningInProgress,
[maxFeesConfigMissing, collaborativeOperationRunning, isRescanningInProgress],
)
const schedulerPreconditionSummary = useMemo(
() => buildCoinjoinRequirementSummary(walletInfo?.data.utxos.utxos || []),
[walletInfo],
)
const [useInsecureTestingSettings, setUseInsecureTestingSettings] = useState(false)
const addressCount = useMemo(
() => (useInsecureTestingSettings ? DEST_ADDRESS_COUNT_TEST : DEST_ADDRESS_COUNT_PROD),
[useInsecureTestingSettings],
)
const initialFormValues = useMemo<FormikValues>(() => {
let destinationAddresses: Array<Api.BitcoinAddress | ''> = Array(addressCount).fill('')
if (useInsecureTestingSettings) {
// prefill with addresses marked as "new"
destinationAddresses = getNewAddressesForTestingOrEmpty(walletInfo, addressCount)
}
return destinationAddresses.reduce((obj, addr, index) => ({ ...obj, [`dest${index + 1}`]: addr }), {})
}, [addressCount, useInsecureTestingSettings, walletInfo])
const reloadData = useCallback(
({ signal }: { signal: AbortSignal }) => {
setAlert(undefined)
setIsLoading(true)
return Promise.all([reloadServiceInfo({ signal }), reloadCurrentWalletInfo.reloadUtxos({ signal })])
.catch((err) => {
if (signal.aborted) return
// reusing "wallet failed" message here is okay, as session info also contains wallet information
const message = t('global.errors.error_loading_wallet_failed', {
reason: err.message || t('global.errors.reason_unknown'),
})
setAlert({ variant: 'danger', message })
})
.finally(() => {
if (signal.aborted) return
setIsLoading(false)
})
},
[reloadServiceInfo, reloadCurrentWalletInfo, t],
)
useEffect(() => {
const abortCtrl = new AbortController()
reloadData({ signal: abortCtrl.signal })
return () => {
abortCtrl.abort()
}
}, [collaborativeOperationRunning, reloadData])
useEffect(() => {
if (!serviceInfo) return
const scheduleUpdate = serviceInfo.schedule
setCurrentSchedule(scheduleUpdate)
setIsWaitingSchedulerStart((current) => (current && scheduleUpdate ? false : current))
setIsWaitingSchedulerStop((current) => (current && !scheduleUpdate ? false : current))
if (scheduleUpdate && process.env.NODE_ENV === 'development') {
console.table(scheduleUpdate)
}
}, [serviceInfo])
useEffect(() => {
const stillRunningOrManualAbort =
!walletInfo || isWaitingSchedulerStop || currentSchedule !== null || lastKnownSchedule === undefined
if (stillRunningOrManualAbort) {
setIsShowSuccessMessage(false)
} else {
const isInMempoolOrSuccess = (it: StateFlag) => it === 1 || typeof it === 'string'
const firstEntriesSuccess = lastKnownSchedule
.slice(0, -1)
.map((it) => it[6])
.every((it) => it === 1 || typeof it === 'string')
const lastEntryState = lastKnownSchedule[lastKnownSchedule.length - 1][6]
const lastEntrySuccess = isInMempoolOrSuccess(lastEntryState)
// Workaround to prevent race conditions: Since the schedule info is polled,
// it'll be possible that the latest known state still has the success flag
// of the last entry set to `0`, although the schedule was completed successfully.
// In this case, additionally check that every remaining UTXO is frozen
// (indicating the opteration was successfully completed).
// Hint: In dev mode, this will only work if you send coins to an external wallet.
const allUtxosFrozen = walletInfo.data.utxos.utxos.every((it) => it.frozen)
setIsShowSuccessMessage(firstEntriesSuccess && (lastEntrySuccess || allUtxosFrozen))
}
}, [currentSchedule, lastKnownSchedule, isWaitingSchedulerStop, walletInfo])
const startSchedule = async (values: FormikValues) => {
if (isLoading || collaborativeOperationRunning || isOperationDisabled) {
return
}
setAlert(undefined)
setShowScheduleConfirmModal(false)
setIsWaitingSchedulerStart(true)
const destinations = addressValueKeys(addressCount).map((key) => values[key])
const body: Api.StartSchedulerRequest = {
destination_addresses: destinations,
}
// Make sure schedule testing is really only used in dev mode.
if (isDebugFeatureEnabled('insecureScheduleTesting') && useInsecureTestingSettings) {
// for a proper description of all parameters see
// https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/v0.9.8/jmclient/jmclient/cli_options.py#L268
body.tumbler_options = {
addrcount: addressCount,
minmakercount: 1,
makercountrange: [1, 0],
mixdepthcount: addressCount,
mintxcount: 1,
txcountparams: [1, 0],
timelambda: 0.025, // 0.025 minutes := 1.5 seconds
stage1_timelambda_increase: 1.0,
liquiditywait: 13,
waittime: 0.0,
}
}
const abortCtrl = new AbortController()
return Api.postSchedulerStart({ ...wallet, signal: abortCtrl.signal }, body)
.then((res) => (res.ok ? true : Api.Helper.throwError(res, t('scheduler.error_starting_schedule_failed'))))
.then((_) => reloadServiceInfo({ signal: abortCtrl.signal }))
.catch((err) => {
if (abortCtrl.signal.aborted) return
setAlert({ variant: 'danger', message: err.message })
setIsWaitingSchedulerStart(false)
})
}
const stopSchedule = async () => {
if (isLoading || !collaborativeOperationRunning) {
return
}
setAlert(undefined)
setIsWaitingSchedulerStop(true)
const abortCtrl = new AbortController()
return Api.getTakerStop({ ...wallet, signal: abortCtrl.signal })
.then((res) => (res.ok ? true : Api.Helper.throwError(res, t('scheduler.error_stopping_schedule_failed'))))
.then((_) => reloadServiceInfo({ signal: abortCtrl.signal }))
.catch((err) => {
if (abortCtrl.signal.aborted) return
setAlert({ variant: 'danger', message: err.message })
setIsWaitingSchedulerStop(false)
})
}
return (
<>
<PageTitle title={t('scheduler.title')} subtitle={t('scheduler.subtitle')} />
{alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
{isLoading || !serviceInfo || !walletInfo || isWaitingSchedulerStart || isWaitingSchedulerStop ? (
<rb.Placeholder as="div" animation="wave">
<rb.Placeholder xs={12} className={styles['input-loader']} />
</rb.Placeholder>
) : (
<>
{collaborativeOperationRunning ? (
<>
{!currentSchedule ? (
<rb.Alert variant="info">{t('send.text_coinjoin_already_running')}</rb.Alert>
) : (
<>
<div className="mb-4">
<ScheduleProgress schedule={currentSchedule} />
</div>
<rb.Button
className="w-100 mb-4"
variant="dark"
size="lg"
disabled={isLoading}
onClick={async () => {
await stopSchedule()
}}
>
<div className="d-flex justify-content-center align-items-center">{t('scheduler.button_stop')}</div>
</rb.Button>
</>
)}
</>
) : (
<>
{isShowSuccessMessage && lastKnownSchedule ? (
<div className="py-4">
<SchedulerSuccessMessage
schedule={lastKnownSchedule}
onConfirm={async () => {
setIsShowSuccessMessage(false)
resetLastKnownSchedule()
const abortCtrl = new AbortController()
await reloadData({ signal: abortCtrl.signal })
}}
/>
</div>
) : (
<>
{maxFeesConfigMissing && (
<rb.Alert className="slashed-zeroes" variant="danger">
{t('send.taker_error_message_max_fees_config_missing')}
&nbsp;
<rb.Alert.Link onClick={() => setShowFeeConfigModal(true)}>
{t('settings.show_fee_config')}
</rb.Alert.Link>
</rb.Alert>
)}
<rb.Fade
in={!schedulerPreconditionSummary.isFulfilled}
mountOnEnter={true}
unmountOnExit={true}
className="mb-4"
>
<CoinjoinPreconditionViolationAlert
summary={schedulerPreconditionSummary}
i18nPrefix="scheduler.precondition."
/>
</rb.Fade>
<div className="d-flex align-items-center justify-content-between mb-4">
<div className="d-flex align-items-center gap-2">
<Sprite symbol="checkmark" width="25" height="25" className="text-secondary" />
<div className="d-flex flex-column">
<div>{t('scheduler.complete_wallet_title')}</div>
<div className="text-secondary text-small">{t('scheduler.complete_wallet_subtitle')}</div>
</div>
</div>
<>
<Balance
valueString={`${walletInfo.balanceSummary.calculatedAvailableBalanceInSats}`}
convertToUnit={settings.unit}
showBalance={settings.showBalance}
/>
</>
</div>
<p className="text-secondary mb-4">{t('scheduler.description_destination_addresses')}</p>
<Formik
initialValues={initialFormValues}
validate={(values) => {
const errors = {} as FormikErrors<FormikValues>
const addressDict = addressValueKeys(addressCount).map((key) => {
return {
key,
address: values[key],
}
})
const addresses = addressDict.map((it) => it.address)
addressDict.forEach((addressEntry) => {
if (!isValidAddress(addressEntry.address)) {
errors[addressEntry.key] = t('scheduler.feedback_invalid_destination_address') as string
} else if (isAddressReused(walletInfo, addressEntry.address, addresses)) {
errors[addressEntry.key] = t('scheduler.feedback_reused_destination_address') as string
}
})
return errors
}}
onSubmit={async (values) => {
await startSchedule(values)
}}
>
{({
values,
isSubmitting,
handleSubmit,
handleBlur,
handleChange,
setFieldValue,
validateForm,
isValid,
touched,
errors,
}) => (
<>
<ValuesListener handler={validateForm} addressCount={addressCount} />
<rb.Form onSubmit={handleSubmit} noValidate>
{isDebugFeatureEnabled('insecureScheduleTesting') && (
<rb.Form.Group className="mb-4" controlId="offertype">
<ToggleSwitch
label={
<>
Use insecure testing settings
<span className="ms-2 badge rounded-pill bg-warning">dev</span>
</>
}
subtitle={
"This is completely insecure but makes testing the schedule much faster. This option won't be available in production."
}
toggledOn={useInsecureTestingSettings}
onToggle={async (isToggled) => {
setUseInsecureTestingSettings(isToggled)
if (isToggled) {
try {
const newAddresses = getNewAddressesForTestingOrEmpty(
walletInfo,
DEST_ADDRESS_COUNT_TEST,
)
newAddresses.forEach((newAddress, index) => {
setFieldValue(`dest${index + 1}`, newAddress, true)
})
} catch (e) {
console.error('Could not get internal addresses.', e)
addressValueKeys(DEST_ADDRESS_COUNT_TEST).forEach((key) => {
setFieldValue(key, '', true)
})
}
} else {
addressValueKeys(DEST_ADDRESS_COUNT_PROD).forEach((key) => {
setFieldValue(key, '', false)
})
}
}}
disabled={isOperationDisabled || isSubmitting}
/>
</rb.Form.Group>
)}
{addressValueKeys(addressCount).map((key, index) => {
return (
<rb.Form.Group className="mb-4" key={key} controlId={key}>
<rb.Form.Label>
{t('scheduler.label_destination_input', { destination: index + 1 })}
</rb.Form.Label>
<rb.Form.Control
name={key}
value={values[key]}
placeholder={t('scheduler.placeholder_destination_input')}
onChange={handleChange}
onBlur={handleBlur}
isInvalid={touched[key] && !!errors[key]}
className={`${styles.input} slashed-zeroes`}
disabled={isOperationDisabled || isSubmitting}
/>
<rb.Form.Control.Feedback type="invalid">
<>{errors[key]}</>
</rb.Form.Control.Feedback>
</rb.Form.Group>
)
})}
<p className="text-secondary mb-4">{t('scheduler.description_fees')}</p>
<rb.Button
className="w-100 mb-4"
variant="dark"
size="lg"
disabled={isOperationDisabled || isSubmitting || !isValid}
onClick={() => setShowScheduleConfirmModal(true)}
>
<div className="d-flex justify-content-center align-items-center">
{t('scheduler.button_start')}
<Sprite symbol="caret-right" width="24" height="24" className="ms-1" />
</div>
</rb.Button>
<ScheduleConfirmModal
isShown={showScheduleConfirmModal}
onCancel={() => setShowScheduleConfirmModal(false)}
onConfirm={handleSubmit}
disabled={isOperationDisabled || isSubmitting || !isValid}
/>
</rb.Form>
</>
)}
</Formik>
<rb.Row className="mt-2 mb-4">
<rb.Col className="d-flex justify-content-center">
<rb.Button
variant="outline-dark"
className="border-0 mb-2 d-inline-flex align-items-center"
onClick={() => setShowFeeConfigModal(true)}
>
<Sprite symbol="coins" width="24" height="24" className="me-1" />
{t('settings.show_fee_config')}
</rb.Button>
{showFeeConfigModal && (
<FeeConfigModal
show={showFeeConfigModal}
onSuccess={() => reloadFeeConfigValues()}
onHide={() => setShowFeeConfigModal(false)}
defaultActiveSectionKey={'cj_fee'}
/>
)}
</rb.Col>
</rb.Row>
</>
)}
</>
)}
</>
)}
</>
)
}

View file

@ -0,0 +1,115 @@
import { Card } from './ui/card'
import { Button } from './ui/button'
import { Jar } from './layout/Jar'
import { Info, RefreshCw, Loader2 } from 'lucide-react'
import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip'
import { Alert, AlertDescription } from './ui/alert'
import { useJamDisplayContext } from './layout/display-mode-context'
export default function JamLanding() {
const {
displayMode,
toggleDisplayMode,
formatAmount,
getLogo,
jars,
totalBalance,
isLoading,
error,
refetchWalletData,
} = useJamDisplayContext()
return (
<div className="flex flex-col items-center justify-center py-8">
<div className="text-center mb-8">
<div className="text-lg opacity-80 text-gray-400">{displayMode === 'btc' ? 'Bitcoin' : 'Satoshi'}</div>
<div className="text-4xl font-light tracking-wider mb-2 flex justify-center items-center cursor-pointer select-none min-h-[56px]">
{isLoading ? (
<div className="flex items-center justify-center min-h-[56px]">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : (
<>
<span
onClick={toggleDisplayMode}
title="Click to toggle sats/bitcoin"
className="tabular-nums min-w-[250px] text-center"
>
{formatAmount(totalBalance)}{' '}
</span>
<span className="flex items-center min-h-[48px]">{getLogo('lg')}</span>
</>
)}
</div>
<div className="flex gap-4 justify-center mt-10">
<Button className="px-12 cursor-pointer"> Receive</Button>
<Button className="px-16 cursor-pointer" variant="outline">
Send
</Button>
</div>
</div>
{error && (
<Alert variant="destructive" className="mb-4 max-w-2xl">
<AlertDescription>
Error loading wallet data: {error.message}
<Button variant="outline" size="sm" onClick={() => refetchWalletData()} className="ml-2">
<RefreshCw className="h-4 w-4 mr-2" /> Retry
</Button>
</AlertDescription>
</Alert>
)}
<Card className="w-full max-w-2xl border-0 shadow-none text-black dark:text-white dark:bg-[#181b20] p-6 mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center w-full justify-center">
<span className="opacity-80 font-light">Wallet distribution</span>
<div className="text-black opacity-80 mx-3 dark:text-white">
<Tooltip>
<TooltipTrigger asChild>
<Info size={16} />
</TooltipTrigger>
<TooltipContent>Select a jar to get started</TooltipContent>
</Tooltip>
</div>
</div>
</div>
<div className="flex justify-between gap-4">
{isLoading ? (
<div className="flex-1 flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : jars.length > 0 ? (
jars.map((jar) => (
<Tooltip key={jar.name}>
<TooltipTrigger asChild>
<div className="flex flex-col cursor-pointer hover:scale-105 transition-all duration-300 items-center">
<Jar
name={jar.name}
amount={jar.balance}
color={jar.color}
displayMode={displayMode}
totalBalance={totalBalance}
/>
</div>
</TooltipTrigger>
<TooltipContent>Open {jar.name} Jar</TooltipContent>
</Tooltip>
))
) : (
<div className="flex-1 text-center py-4 text-gray-500">No accounts found in wallet</div>
)}
</div>
</Card>
<div className="flex justify-end w-full max-w-2xl">
<Button
variant="ghost"
size="sm"
onClick={() => refetchWalletData()}
className="flex gap-2 items-center text-gray-500"
>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
</div>
)
}

View file

@ -1,67 +0,0 @@
:global .modal-backdrop {
background-color: rgba(0, 0, 0, 0.5) !important;
}
.modal :global .modal-content {
background-color: var(--bs-body-bg) !important;
border-radius: 1rem !important;
box-shadow: 0px 0px 24px rgba(0, 0, 0, 0.25) !important;
}
.modalHeader {
display: flex !important;
justify-content: flex-start !important;
background-color: transparent !important;
padding: 1.25rem !important;
}
.modalTitle {
width: 100%;
font-size: 1rem !important;
font-weight: 400 !important;
color: var(--bs-body-color) !important;
}
.modalTitle > div:first-child {
display: flex;
justify-content: space-between;
align-items: center;
}
.modalTitle .cancelButton {
padding: 0 0 0 1rem;
color: var(--bs-body-color);
background-color: transparent !important;
border: none;
}
.modalFooter {
display: flex !important;
justify-content: center !important;
gap: 1rem;
background-color: transparent !important;
padding: 1rem 1.25rem 1.25rem 1.25rem !important;
}
.modalFooter :global .btn {
flex-grow: 1;
min-height: 2.8rem;
font-weight: 500;
border-color: none !important;
}
.jarsContainer {
display: flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 2rem;
color: var(--bs-body-color);
}
@media only screen and (min-width: 768px) {
.jarsContainer {
gap: 1.5rem;
}
}

View file

@ -1,114 +0,0 @@
import { useState, useMemo } from 'react'
import * as rb from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { jarFillLevel, SelectableJar } from './jars/Jar'
import { AccountBalances } from '../context/BalanceSummary'
import { AmountSats } from '../libs/JmWalletApi'
import Sprite from './Sprite'
import styles from './JarSelectorModal.module.css'
interface JarSelectorModalProps {
isShown: boolean
title: string
accountBalances: AccountBalances
totalBalance: AmountSats
disabledJar?: JarIndex
onCancel: () => void
onConfirm: (jarIndex: JarIndex) => Promise<void>
}
export default function JarSelectorModal({
isShown,
title,
accountBalances,
totalBalance,
disabledJar,
onCancel,
onConfirm,
}: JarSelectorModalProps) {
const { t } = useTranslation()
const [isConfirming, setIsConfirming] = useState(false)
const [selectedJar, setSelectedJar] = useState<JarIndex>()
const sortedAccountBalances = useMemo(() => {
if (!accountBalances) return []
return Object.values(accountBalances).sort((lhs, rhs) => lhs.accountIndex - rhs.accountIndex)
}, [accountBalances])
const cancel = () => {
setSelectedJar(undefined)
onCancel()
}
const confirm = () => {
if (selectedJar === undefined) return
setIsConfirming(true)
onConfirm(selectedJar)
.then(() => setSelectedJar(undefined))
.finally(() => setIsConfirming(false))
}
return (
<rb.Modal
show={isShown}
keyboard={true}
onEscapeKeyDown={onCancel}
onHide={onCancel}
centered={true}
animation={true}
className={styles.modal}
size="lg"
>
<rb.Modal.Header className={styles.modalHeader}>
<rb.Modal.Title className={styles.modalTitle}>
<div>
<div>{title}</div>
<rb.Button onClick={cancel} className={styles.cancelButton}>
<Sprite symbol="cancel" width="26" height="26" />
</rb.Button>
</div>
</rb.Modal.Title>
</rb.Modal.Header>
<rb.Modal.Body className={styles.modalBody}>
<div className={styles.jarsContainer}>
{sortedAccountBalances.map((account) => {
return (
<SelectableJar
key={account.accountIndex}
index={account.accountIndex}
balance={account.calculatedAvailableBalanceInSats}
frozenBalance={account.calculatedFrozenOrLockedBalanceInSats}
isSelectable={account.accountIndex !== disabledJar}
isSelected={account.accountIndex === selectedJar}
fillLevel={jarFillLevel(account.calculatedTotalBalanceInSats, totalBalance)}
onClick={(jarIndex) => setSelectedJar(jarIndex)}
/>
)
})}
</div>
</rb.Modal.Body>
<rb.Modal.Footer className={styles.modalFooter}>
<rb.Button variant="light" onClick={cancel} className="d-flex flex-1 justify-content-center align-items-center">
<Sprite symbol="cancel" width="26" height="26" />
<div>{t('modal.confirm_button_reject')}</div>
</rb.Button>
<rb.Button
disabled={isConfirming || selectedJar === undefined}
variant="dark"
onClick={confirm}
className="d-flex flex-1 justify-content-center align-items-center"
>
{isConfirming ? (
<>
<rb.Spinner as="span" animation="border" size="sm" role="status" />
</>
) : (
<>{t('modal.confirm_button_accept')}</>
)}
</rb.Button>
</rb.Modal.Footer>
</rb.Modal>
)
}

View file

@ -1,57 +0,0 @@
.jarsTitle {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
cursor: pointer;
padding: 0 0.5rem 0 0;
}
.jarsTitle .infoIcon {
color: var(--bs-gray-500);
border: 1px solid var(--bs-gray-500);
border-radius: 50%;
cursor: help;
}
.jarsContainer {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
width: 100%;
gap: 2rem;
color: var(--bs-body-color);
}
.jarsContainer :global .jar-container-hook {
flex-direction: row;
gap: 1rem;
}
.jarsContainer :global .jar-info-container-hook {
align-items: flex-start;
}
.jarsContainer :global .jar-balance-container-hook {
justify-content: start !important;
}
@media only screen and (min-width: 768px) {
.jarsContainer {
flex-direction: row;
align-items: flex-start;
gap: 1.5rem;
}
.jarsContainer :global .jar-container-hook {
flex-direction: column;
gap: 0;
min-width: inherit;
}
.jarsContainer :global .jar-info-container-hook {
align-items: center !important;
}
.jarsContainer :global .jar-balance-container-hook {
justify-content: center !important;
}
}

View file

@ -1,65 +0,0 @@
import { useMemo } from 'react'
import * as rb from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { AccountBalances } from '../context/BalanceSummary'
import { AmountSats } from '../libs/JmWalletApi'
import { JarProps, OpenableJar, jarFillLevel } from './jars/Jar'
import Sprite from './Sprite'
import styles from './Jars.module.css'
type JarsProps = Pick<JarProps, 'size'> & {
accountBalances: AccountBalances
totalBalance: AmountSats
onClick: (jarIndex: JarIndex) => void
}
const Jars = ({ size, accountBalances, totalBalance, onClick }: JarsProps) => {
const { t } = useTranslation()
const sortedAccountBalances = useMemo(() => {
if (!accountBalances) return []
return Object.values(accountBalances).sort((lhs, rhs) => lhs.accountIndex - rhs.accountIndex)
}, [accountBalances])
return (
<div className="d-flex flex-column align-items-center gap-5">
<rb.OverlayTrigger
placement="bottom"
overlay={
<rb.Popover>
<rb.Popover.Body>{t('current_wallet.jars_title_popover')}</rb.Popover.Body>
</rb.Popover>
}
>
<div className={styles.jarsTitle}>
<div>{t('current_wallet.jars_title')}</div>
<Sprite className={styles.infoIcon} symbol="info" width="18" height="18" />
</div>
</rb.OverlayTrigger>
<div className={styles.jarsContainer}>
{sortedAccountBalances.map((account) => {
const jarIsEmpty = account.calculatedTotalBalanceInSats === 0
return (
<OpenableJar
key={account.accountIndex}
size={size}
index={account.accountIndex}
balance={account.calculatedAvailableBalanceInSats}
frozenBalance={account.calculatedFrozenOrLockedBalanceInSats}
fillLevel={jarFillLevel(account.calculatedTotalBalanceInSats, totalBalance)}
tooltipText={
account.accountIndex === 0 && jarIsEmpty
? t('current_wallet.jar_tooltip_empty_jar_0')
: t('current_wallet.jar_tooltip')
}
onClick={() => onClick(account.accountIndex)}
/>
)
})}
</div>
</div>
)
}
export { Jars }

View file

@ -1,34 +0,0 @@
import { PropsWithChildren } from 'react'
import * as rb from 'react-bootstrap'
type LayoutVariant = 'wide' | ''
interface ColProps {
variant?: LayoutVariant
}
const Col = ({ variant, children }: PropsWithChildren<ColProps>) => {
if (variant === 'wide') {
return <rb.Col>{children}</rb.Col>
}
return (
<rb.Col lg={10} xl={10} xxl={8}>
{children}
</rb.Col>
)
}
interface LayoutProps {
variant?: LayoutVariant
}
const Layout = ({ variant, children }: PropsWithChildren<LayoutProps>) => {
return (
<rb.Row className="justify-content-center">
<Col variant={variant}>{children}</Col>
</rb.Row>
)
}
export default Layout

View file

@ -1,64 +0,0 @@
.logContentPlaceholder {
height: 2.625rem;
margin: 1px 0;
}
.overlayContainer .logContentContainer {
display: flex;
flex-direction: column;
gap: 0.5rem;
background-color: var(--bs-body-bg);
}
@media only screen and (min-width: 992px) {
.overlayContainer .logContentContainer {
gap: 1.5rem;
padding: 2rem;
border-radius: 0.5rem;
}
}
.overlayContainer .logContentContainer .titleBar {
min-height: 3.6rem;
display: flex;
justify-content: space-between;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 0 0.5rem 0.8rem 0.5rem;
background-color: var(--bs-gray-100);
}
@media only screen and (min-width: 992px) {
.overlayContainer .logContentContainer .titleBar {
padding: 0.8rem 1rem;
border-radius: 0.6rem;
}
}
@media only screen and (min-width: 768px) {
.overlayContainer .logContentContainer > .titleBar {
align-items: center;
flex-direction: row;
}
}
:root[data-theme='dark'] .overlayContainer .logContentContainer .titleBar {
background-color: var(--bs-gray-800);
}
.overlayContainer .logContentContainer .titleBar .refreshButton {
display: flex;
justify-content: center;
align-items: center;
width: 2rem;
height: 2rem;
padding: 0.1rem;
border: none;
}
.logContent {
min-height: 300px;
max-height: 60vh;
overflow: scroll;
}

View file

@ -1,179 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import * as rb from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { Helper as ApiHelper } from '../libs/JmWalletApi'
import { fetchLog } from '../libs/JamApi'
import { useSettings } from '../context/SettingsContext'
import { CurrentWallet } from '../context/WalletContext'
import Sprite from './Sprite'
import styles from './LogOverlay.module.css'
import { isDevMode } from '../constants/debugFeatures'
const JMWALLETD_LOG_FILE_NAME = 'jmwalletd_stdout.log'
interface LogContentProps {
content: string
refresh: (signal: AbortSignal) => Promise<void>
}
export function LogContent({ content, refresh }: LogContentProps) {
const logContentRef = useRef<HTMLPreElement>(null)
const settings = useSettings()
const [isLoadingRefresh, setIsLoadingRefresh] = useState(false)
useEffect(() => {
if (!content || !logContentRef.current) return
logContentRef.current.scroll({
top: logContentRef.current.scrollHeight,
behavior: 'smooth',
})
}, [content, logContentRef])
return (
<div className={styles.logContentContainer}>
<div className={styles.titleBar}>
<div className="d-flex justify-content-center align-items-center gap-2">
<rb.Button
className={styles.refreshButton}
variant={settings.theme}
onClick={() => {
if (isLoadingRefresh) return
setIsLoadingRefresh(true)
const abortCtrl = new AbortController()
refresh(abortCtrl.signal).finally(() => {
// as refreshing is fast most of the time, add a short delay to avoid flickering
setTimeout(() => setIsLoadingRefresh(false), 250)
})
}}
>
{isLoadingRefresh ? (
<rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" />
) : (
<Sprite symbol="refresh" width="24" height="24" />
)}
</rb.Button>
</div>
</div>
<div className="py-2 px-2">
<pre ref={logContentRef} className={styles.logContent}>
{content}
</pre>
</div>
</div>
)
}
type LogOverlayProps = rb.OffcanvasProps & {
currentWallet: CurrentWallet
}
export function LogOverlay({ currentWallet, show, onHide }: LogOverlayProps) {
const { t } = useTranslation()
const [alert, setAlert] = useState<SimpleAlert>()
const [isInitialized, setIsInitialized] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [content, setContent] = useState<string>()
const refresh = useCallback(
(signal: AbortSignal) => {
return fetchLog({ token: currentWallet.token, signal, fileName: JMWALLETD_LOG_FILE_NAME })
.then((res) => (res.ok ? res.text() : ApiHelper.throwError(res)))
.then((data) => {
if (signal.aborted) return
setAlert(undefined)
setContent(data)
})
.catch((e) => {
if (signal.aborted) return
setAlert({
variant: 'danger',
message: t('logs.error_loading_logs_failed', {
reason: e.message || t('global.errors.reason_unknown'),
}),
})
})
.finally(() => {
if (signal.aborted) return
setIsLoading(false)
})
},
[currentWallet, t],
)
useEffect(() => {
if (!show) return
const abortCtrl = new AbortController()
setIsLoading(true)
refresh(abortCtrl.signal).finally(() => {
if (abortCtrl.signal.aborted) return
setIsLoading(false)
setIsInitialized(true)
})
return () => {
abortCtrl.abort()
}
}, [show, refresh])
return (
<rb.Offcanvas
className={`offcanvas-fullscreen ${styles.overlayContainer}`}
show={show}
onHide={onHide}
placement="bottom"
>
<rb.Offcanvas.Header>
<rb.Container fluid="lg">
<div className="w-100 d-flex">
<div className="d-flex align-items-center flex-1">
<rb.Offcanvas.Title>{t('logs.title')}</rb.Offcanvas.Title>
</div>
<div>
<rb.Button variant="link" className="unstyled pe-0" onClick={onHide}>
<Sprite symbol="cancel" width="32" height="32" />
</rb.Button>
</div>
</div>
</rb.Container>
</rb.Offcanvas.Header>
<rb.Offcanvas.Body>
<rb.Container fluid="lg" className="py-3">
{!isInitialized && isLoading ? (
Array(5)
.fill('')
.map((_, index) => {
return (
<rb.Placeholder key={index} as="div" animation="wave">
<rb.Placeholder xs={12} className={styles.logContentPlaceholder} />
</rb.Placeholder>
)
})
) : (
<>
{alert && !content && isDevMode() && (
<div className="my-4">
<span className="badge rounded-pill bg-warning me-2">dev</span>
In order to test the log file feature, start the application with
<code className="mx-2">npm run dev:secondary</code>.
</div>
)}
{alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
{content && (
<rb.Row>
<rb.Col className="px-0">
<LogContent content={content} refresh={refresh} />
</rb.Col>
</rb.Row>
)}
</>
)}
</rb.Container>
</rb.Offcanvas.Body>
</rb.Offcanvas>
)
}

286
src/components/Login.tsx Normal file
View file

@ -0,0 +1,286 @@
import React, { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useNavigate } from 'react-router-dom'
import { setSession } from '@/lib/session'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { AlertCircle, Wallet, Lock, Loader2, Eye, EyeOff, RefreshCwIcon } from 'lucide-react'
import { formatWalletName } from '@/lib/utils'
import { useMutation, useQuery } from '@tanstack/react-query'
import { listwalletsOptions, unlockwalletMutation } from '@/lib/jm-api/generated/client/@tanstack/react-query.gen'
import { useApiClient } from '@/hooks/useApiClient'
import { Skeleton } from './ui/skeleton'
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'
const LoginFormSkeleton = () => {
return (
<>
<div className="flex flex-col space-y-6">
<Skeleton className="h-4 w-full" />
<div className="space-y-3">
<div className="space-y-1">
<Skeleton className="h-4 w-[75px]" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-1">
<Skeleton className="h-4 w-[75px]" />
<Skeleton className="h-10 w-full" />
</div>
<Skeleton className="h-12 w-full" />
</div>
</div>
<div>&nbsp;</div>
<div>&nbsp;</div>
</>
)
}
interface LoginFormProps {
wallets: string[]
isSubmitting: boolean
onSubmit: (val: { walletFileName: string; password: string }) => Promise<void>
}
const LoginForm = ({ wallets, isSubmitting, onSubmit }: LoginFormProps) => {
const [selectedWallet, setSelectedWallet] = useState<string | undefined>(
wallets.length !== 1 ? undefined : wallets[0],
)
const [password, setPassword] = useState<string>('')
const [showPassword, setShowPassword] = useState<boolean>(false)
useEffect(
function preselectWalletIfOnlyOneExists() {
if (wallets.length !== 1) return
setSelectedWallet(wallets[0])
},
[wallets],
)
return (
<>
<form
onSubmit={(e: React.FormEvent) => {
e.preventDefault()
if (selectedWallet === undefined) return
onSubmit({ walletFileName: selectedWallet, password })
}}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="wallet-select">Wallet</Label>
<Select
value={selectedWallet ?? ''}
onValueChange={setSelectedWallet}
disabled={isSubmitting || wallets.length === 0}
required
>
<SelectTrigger className="w-full">
<SelectValue placeholder={wallets.length > 0 ? 'Select a wallet' : 'No wallets found.'} />
</SelectTrigger>
<SelectContent>
{wallets?.map((wallet, index) => (
<SelectItem key={index} value={wallet}>
{formatWalletName(wallet)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isSubmitting}
placeholder="Enter your password"
className="pl-10 pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting || !selectedWallet} size="lg">
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Unlocking...
</>
) : (
'Unlock'
)}
</Button>
</form>
</>
)
}
const LoginPage = () => {
const navigate = useNavigate()
const client = useApiClient()
const listwalletsQuery = useQuery({
...listwalletsOptions({ client }),
retry: false,
})
const isLoadingWallets = useMemo(() => listwalletsQuery.isFetching, [listwalletsQuery.isFetching])
const listwalletsError = useMemo(() => {
if (!listwalletsQuery.error) return undefined
return {
message: `Failed to load wallets`,
error_description: listwalletsQuery.error.message || 'Unknown reason.',
}
}, [listwalletsQuery.error])
const wallets = useMemo(() => listwalletsQuery.data?.wallets, [listwalletsQuery.data])
const unlockWallet = useMutation({
...unlockwalletMutation({ client }),
retry: false,
onSuccess: () => {
toast.success('Successfully unlocked wallet.')
},
onError: (error) => {
toast.error(`Failed to unlock wallet: ${error.message || 'Unknown reason.'}`)
},
})
const isUnlockingWallet = useMemo(() => unlockWallet.isPending, [unlockWallet.isPending])
const handleSubmit = async (data: { walletFileName: string; password: string }) => {
try {
const response = await unlockWallet.mutateAsync({
path: {
walletname: encodeURIComponent(data.walletFileName),
},
body: {
password: data.password,
},
})
setSession({
walletFileName: response.walletname,
auth: { token: response.token, refresh_token: response.refresh_token },
})
await navigate('/')
} catch (error: unknown) {
console.error('Error unlocking wallet', error)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted p-4">
<div className="w-full max-w-md">
<Card className="shadow-lg">
<CardHeader className="text-center space-y-2">
<div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-4">
{isLoadingWallets ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<Wallet className="h-6 w-6 text-primary" onClick={async () => await listwalletsQuery.refetch()} />
)}
</div>
<CardTitle className="text-2xl font-bold">Welcome to Jam</CardTitle>
{!isLoadingWallets && wallets !== undefined && wallets.length > 0 && (
<CardDescription>Select a wallet and enter your password to continue.</CardDescription>
)}
</CardHeader>
{isLoadingWallets ? (
<CardContent className="space-y-6">
<LoginFormSkeleton />
</CardContent>
) : (
<>
{listwalletsError ? (
<CardContent className="space-y-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{listwalletsError.message}</AlertTitle>
<AlertDescription>{listwalletsError.error_description}</AlertDescription>
</Alert>
<Button variant="ghost" size="sm" onClick={async () => await listwalletsQuery.refetch()}>
<RefreshCwIcon className="h-4 w-4" /> Retry
</Button>
</CardContent>
) : (
<CardContent className="space-y-6">
{wallets!.length === 0 ? (
<>
<div className="text-center">
<p className="text-sm text-muted-foreground">It looks like you do not have a wallet, yet.</p>
</div>
<div className="space-y-4">
<Button className="w-full" size="lg" onClick={async () => await navigate('/create-wallet')}>
Create new wallet
</Button>
<Tooltip>
<TooltipTrigger className="w-full">
<Button variant="secondary" className="w-full" size="lg" disabled>
Import existing wallet
</Button>
</TooltipTrigger>
<TooltipContent>Not yet implemented.</TooltipContent>
</Tooltip>
</div>
</>
) : (
<>
<LoginForm wallets={wallets || []} isSubmitting={isUnlockingWallet} onSubmit={handleSubmit} />
<div className="flex flex-col gap-2">
<Button
variant="link"
size="sm"
onClick={async () => await navigate('/create-wallet')}
className="cursor-pointer"
>
Create a new wallet
</Button>
<Tooltip>
<TooltipTrigger className="w-full">
<Button
variant="link"
size="sm"
onClick={async () => await navigate('/create-wallet')}
disabled
>
Import an existing wallet
</Button>
</TooltipTrigger>
<TooltipContent>Not yet implemented.</TooltipContent>
</Tooltip>
</div>
</>
)}
</CardContent>
)}
</>
)}
</Card>
</div>
</div>
)
}
export default LoginPage

View file

@ -1,29 +0,0 @@
.walletHeader {
display: flex;
flex-direction: column;
align-items: center;
}
.walletHeader .titlePlaceholder {
width: 5rem;
margin-bottom: 0.5rem;
}
.walletHeader .subtitlePlaceholder {
width: 12rem;
height: 1.8rem;
margin-bottom: 0.45rem;
}
:global(.jm-rescan-in-progress) .walletHeader {
cursor: wait;
}
:global(.jm-rescan-in-progress) .walletBody {
filter: blur(2px);
}
.jarsPlaceholder {
width: 100%;
height: 3.5rem;
}

View file

@ -1,254 +0,0 @@
import { useEffect, useState, useMemo } from 'react'
import * as rb from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { useServiceInfo } from '../context/ServiceInfoContext'
import { useSettings, useSettingsDispatch } from '../context/SettingsContext'
import { CurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext'
import * as Api from '../libs/JmWalletApi'
import { routes } from '../constants/routes'
import Balance from './Balance'
import Sprite from './Sprite'
import { ExtendedLink } from './ExtendedLink'
import { JarDetailsOverlay } from './jar_details/JarDetailsOverlay'
import { Jars } from './Jars'
import styles from './MainWalletView.module.css'
import Divider from './Divider'
interface WalletHeaderProps {
walletName: string
balance: Api.AmountSats
unit: Unit
showBalance: boolean
}
const WalletHeader = ({ walletName, balance, unit, showBalance }: WalletHeaderProps) => {
return (
<div className={styles.walletHeader}>
<h1 className="text-secondary fs-6">{walletName}</h1>
<h2>
<Balance
valueString={balance.toString()}
convertToUnit={unit}
showBalance={showBalance}
enableVisibilityToggle={false}
/>
</h2>
</div>
)
}
const WalletHeaderPlaceholder = () => {
return (
<div className={styles.walletHeader}>
<rb.Placeholder as="div" animation="wave">
<rb.Placeholder className={styles.titlePlaceholder} />
</rb.Placeholder>
<rb.Placeholder as="div" animation="wave">
<rb.Placeholder className={styles.subtitlePlaceholder} />
</rb.Placeholder>
</div>
)
}
const WalletHeaderRescanning = ({
walletName,
isLoading,
serviceInfo,
}: {
walletName: string
isLoading: boolean
serviceInfo: any
}) => {
const { t } = useTranslation()
return (
<div className={styles.walletHeader}>
<h1 className="text-secondary fs-6">{walletName}</h1>
{isLoading ? (
<rb.Placeholder as="div" animation="wave">
<rb.Placeholder className={styles.subtitlePlaceholder} />
</rb.Placeholder>
) : (
<h2>
{serviceInfo?.rescanProgress !== undefined
? t('current_wallet.text_rescan_in_progress_with_progress', {
progress: Math.floor(serviceInfo.rescanProgress * 100),
})
: t('current_wallet.text_rescan_in_progress')}
</h2>
)}
</div>
)
}
interface MainWalletViewProps {
wallet: CurrentWallet
}
export default function MainWalletView({ wallet }: MainWalletViewProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const serviceInfo = useServiceInfo()
const settings = useSettings()
const settingsDispatch = useSettingsDispatch()
const currentWalletInfo = useCurrentWalletInfo()
const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
const [alert, setAlert] = useState<SimpleAlert>()
const [isLoading, setIsLoading] = useState(true)
const [showJars, setShowJars] = useState(false)
const jars = useMemo(() => currentWalletInfo?.data.display.walletinfo.accounts, [currentWalletInfo])
const [selectedJarIndex, setSelectedJarIndex] = useState(0)
const [isAccountOverlayShown, setIsAccountOverlayShown] = useState(false)
const onJarClicked = (jarIndex: JarIndex) => {
if (jarIndex === 0) {
const isEmpty = currentWalletInfo?.balanceSummary.accountBalances[jarIndex]?.calculatedTotalBalanceInSats === 0
if (isEmpty) {
navigate(routes.receive, { state: { account: jarIndex } })
return
}
}
setSelectedJarIndex(jarIndex)
setIsAccountOverlayShown(true)
}
useEffect(() => {
const abortCtrl = new AbortController()
setAlert(undefined)
setIsLoading(true)
reloadCurrentWalletInfo
.reloadUtxos({ signal: abortCtrl.signal })
.catch((err) => {
if (abortCtrl.signal.aborted) return
const message = err.message || t('current_wallet.error_loading_failed')
setAlert({ variant: 'danger', message })
})
.finally(() => {
if (abortCtrl.signal.aborted) return
setIsLoading(false)
})
return () => abortCtrl.abort()
}, [reloadCurrentWalletInfo, t])
return (
<div>
{alert && (
<rb.Row>
<rb.Col>
<rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>
</rb.Col>
</rb.Row>
)}
{currentWalletInfo && jars && (
<JarDetailsOverlay
jars={jars}
initialJarIndex={selectedJarIndex}
walletInfo={currentWalletInfo}
wallet={wallet}
isShown={isAccountOverlayShown}
onHide={() => setIsAccountOverlayShown(false)}
/>
)}
{serviceInfo?.rescanning === true ? (
<rb.Row>
<WalletHeaderRescanning walletName={wallet.displayName} isLoading={isLoading} serviceInfo={serviceInfo} />
</rb.Row>
) : !currentWalletInfo || isLoading ? (
<rb.Row>
<WalletHeaderPlaceholder />
</rb.Row>
) : (
<rb.Row
className="cursor-pointer"
onClick={() => {
if (!settings.showBalance) {
settingsDispatch({ unit: 'BTC', showBalance: true })
} else if (settings.unit === 'BTC') {
settingsDispatch({ unit: 'sats', showBalance: true })
} else {
settingsDispatch({ unit: 'BTC', showBalance: false })
}
}}
>
<WalletHeader
walletName={wallet.displayName}
balance={currentWalletInfo.balanceSummary.calculatedTotalBalanceInSats}
unit={settings.unit}
showBalance={settings.showBalance}
/>
</rb.Row>
)}
<div className={styles.walletBody}>
<rb.Row className="mt-4 mb-5 d-flex justify-content-center">
<rb.Col xs={10} md={8}>
<rb.Row>
<rb.Col>
{/* Always receive to first jar. */}
<ExtendedLink
to={routes.receive}
state={{ account: 0 }}
className={`${styles.sendReceiveButton} btn btn-lg btn-outline-dark w-100`}
disabled={isLoading || serviceInfo?.rescanning}
>
<div className="d-flex justify-content-center align-items-center">
<Sprite symbol="receive" width="24" height="24" className="me-1" />
{t('current_wallet.button_deposit')}
</div>
</ExtendedLink>
</rb.Col>
<rb.Col>
<ExtendedLink
to={routes.send}
className={`${styles.sendReceiveButton} btn btn-lg btn-outline-dark w-100`}
disabled={isLoading || serviceInfo?.rescanning}
>
<div className="d-flex justify-content-center align-items-center">
<Sprite symbol="send" width="24" height="24" className="me-1" />
{t('current_wallet.button_withdraw')}
</div>
</ExtendedLink>
</rb.Col>
</rb.Row>
</rb.Col>
</rb.Row>
<rb.Collapse in={!serviceInfo?.rescanning && showJars}>
<rb.Row>
<div className="mb-5">
<div>
{!currentWalletInfo || isLoading ? (
<rb.Placeholder as="div" animation="wave">
<rb.Placeholder className={styles.jarsPlaceholder} />
</rb.Placeholder>
) : (
<Jars
size="lg"
accountBalances={currentWalletInfo.balanceSummary.accountBalances}
totalBalance={currentWalletInfo.balanceSummary.calculatedTotalBalanceInSats}
onClick={onJarClicked}
/>
)}
</div>
</div>
</rb.Row>
</rb.Collapse>
<Divider
toggled={showJars}
onToggle={() => setShowJars((current) => !current)}
disabled={serviceInfo?.rescanning}
xs={showJars ? 12 : 10}
md={showJars ? 12 : 8}
/>
</div>
</div>
)
}

View file

@ -1,68 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { Bip39MnemonicWordInput } from './MnemonicWordInput'
interface MnemonicPhraseInputProps {
columns?: number
mnemonicPhrase: MnemonicPhrase
isDisabled?: (index: number) => boolean
isValid?: (index: number) => boolean
onChange: (value: MnemonicPhrase) => void
}
export default function MnemonicPhraseInput({
columns = 3,
mnemonicPhrase,
isDisabled,
isValid,
onChange,
}: MnemonicPhraseInputProps) {
const [activeIndex, setActiveIndex] = useState(0)
const inputRefs = useRef<HTMLInputElement[]>([])
useEffect(() => {
if (activeIndex < mnemonicPhrase.length && isValid && isValid(activeIndex)) {
const nextIndex = activeIndex + 1
setActiveIndex(nextIndex)
if (inputRefs.current[nextIndex]) {
inputRefs.current[nextIndex].focus()
}
}
}, [mnemonicPhrase, activeIndex, isValid])
return (
<div className="container slashed-zeroes p-0">
{mnemonicPhrase.map((_, outerIndex) => {
if (outerIndex % columns !== 0) return null
const wordGroup = mnemonicPhrase.slice(outerIndex, Math.min(outerIndex + columns, mnemonicPhrase.length))
return (
<div className="row mb-4" key={outerIndex}>
{wordGroup.map((givenWord, innerIndex) => {
const wordIndex = outerIndex + innerIndex
const isCurrentActive = wordIndex === activeIndex
return (
<div className="col" key={wordIndex}>
<Bip39MnemonicWordInput
forwardRef={(el: HTMLInputElement) => (inputRefs.current[wordIndex] = el)}
index={wordIndex}
value={givenWord}
setValue={(value, i) => {
const newPhrase = mnemonicPhrase.map((old, index) => (index === i ? value : old))
onChange(newPhrase)
}}
isValid={isValid ? isValid(wordIndex) : undefined}
disabled={isDisabled ? isDisabled(wordIndex) : undefined}
onFocus={() => setActiveIndex(wordIndex)}
autoFocus={isCurrentActive}
/>
</div>
)
})}
</div>
)
})}
</div>
)
}

View file

@ -1,9 +0,0 @@
.input {
height: 3.5rem;
width: 100%;
}
.seedwordIndexBackup {
width: 5ch;
justify-content: right;
}

View file

@ -1,37 +0,0 @@
import { render, screen } from '../testUtils'
import { Bip39MnemonicWordInput, MnemonicWordInputProps } from './MnemonicWordInput'
const NOOP = () => {}
describe('<Bip39MnemonicWordInput />', () => {
const validBip39MnemonicWord = 'abandon'
const invalidBip39MnemonicWord = 'not a bip39 word!'
const setup = (props: MnemonicWordInputProps) => {
render(<Bip39MnemonicWordInput {...props} />)
}
it('should render without errors', async () => {
setup({ index: 0, value: '', setValue: NOOP })
expect(await screen.findByTestId('mnemonic-word-input')).toBeVisible()
})
it('should report if input is NOT included in the BIP-39 wordlist', async () => {
setup({ index: 0, value: invalidBip39MnemonicWord, setValue: NOOP })
const input = await screen.findByTestId('mnemonic-word-input')
expect(input).toBeVisible()
expect(input).toHaveClass('is-invalid')
expect(input).not.toHaveClass('is-valid')
})
it('should report if input IS INCLUDED in the BIP-39 wordlist', async () => {
setup({ index: 0, value: validBip39MnemonicWord, setValue: NOOP })
const input = await screen.findByTestId('mnemonic-word-input')
expect(input).toBeVisible()
expect(input).toHaveClass('is-valid')
expect(input).not.toHaveClass('is-invalid')
})
})

View file

@ -1,55 +0,0 @@
import { useMemo } from 'react'
import * as rb from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { MNEMONIC_WORDS } from '../constants/bip39words'
import styles from './MnemonicWordInput.module.css'
export interface MnemonicWordInputProps {
forwardRef?: (el: HTMLInputElement) => void
index: number
value: string
setValue: (value: string, index: number) => void
isValid?: boolean
disabled?: boolean
onFocus?: () => void
autoFocus?: boolean
}
const MnemonicWordInput = ({
forwardRef,
index,
value,
setValue,
isValid,
disabled,
onFocus,
autoFocus,
}: MnemonicWordInputProps) => {
const { t } = useTranslation()
return (
<rb.InputGroup>
<rb.InputGroup.Text className={styles.seedwordIndexBackup}>{index + 1}.</rb.InputGroup.Text>
<rb.Form.Control
data-testid="mnemonic-word-input"
ref={forwardRef}
type="text"
placeholder={`${t('create_wallet.placeholder_seed_word_input')} ${index + 1}`}
value={value}
onChange={(e) => setValue(e.target.value.trim(), index)}
className={styles.input}
disabled={disabled}
isInvalid={isValid === false && value.length > 0}
isValid={isValid === true}
onFocus={onFocus}
autoFocus={autoFocus}
required
/>
</rb.InputGroup>
)
}
export const Bip39MnemonicWordInput = ({ value, ...props }: MnemonicWordInputProps) => {
const isBip39Value = useMemo(() => MNEMONIC_WORDS.includes(value), [value])
return <MnemonicWordInput {...props} value={value} isValid={isBip39Value && (props.isValid ?? true)} />
}

View file

@ -1,45 +0,0 @@
:global .modal-backdrop {
background-color: rgba(0, 0, 0, 0.5) !important;
}
.modal :global .modal-content {
background-color: var(--bs-body-bg) !important;
border-radius: 1rem !important;
box-shadow: 0px 0px 24px rgba(0, 0, 0, 0.25) !important;
}
.modalHeader {
display: flex !important;
justify-content: center !important;
background-color: transparent !important;
border: none !important;
padding: 1.25rem 1.25rem 0 1.25rem !important;
}
.modalTitle {
font-size: 1.3rem !important;
font-weight: 600 !important;
color: var(--bs-body-color) !important;
}
.modalBody {
text-align: center !important;
font-size: 1rem !important;
font-weight: 400 !important;
padding: 0.25rem 1.25rem 1rem 1.25rem !important;
}
.modalFooter {
display: flex !important;
justify-content: center !important;
gap: 1rem;
background-color: transparent !important;
padding: 1rem 1.25rem 1.25rem 1.25rem !important;
}
.modalFooter :global .btn {
--bs-btn-border-color: var(--bs-border-color);
flex-grow: 1;
min-height: 2.8rem;
font-weight: 500;
}

View file

@ -1,106 +0,0 @@
import { ReactNode, PropsWithChildren } from 'react'
import * as rb from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import Sprite from './Sprite'
import styles from './Modal.module.css'
type BaseModalProps = Pick<rb.ModalProps, 'className' | 'backdrop' | 'size'> &
Pick<rb.ModalHeaderProps, 'closeButton'> & {
isShown: boolean
title: ReactNode | string
onCancel: () => void
headerClassName?: rb.ModalHeaderProps['className']
titleClassName?: rb.ModalTitleProps['className']
}
const BaseModal = ({
isShown,
title,
children,
onCancel,
size,
backdrop = 'static',
closeButton = false,
className = styles.modal,
headerClassName = styles.modalHeader,
titleClassName = styles.modalTitle,
}: PropsWithChildren<BaseModalProps>) => {
return (
<rb.Modal
show={isShown}
keyboard={true}
onEscapeKeyDown={() => onCancel()}
onHide={() => onCancel()}
centered={true}
animation={true}
backdrop={backdrop}
size={size}
className={className}
>
<rb.Modal.Header className={headerClassName} closeButton={closeButton}>
<rb.Modal.Title className={titleClassName}>{title}</rb.Modal.Title>
</rb.Modal.Header>
{children}
</rb.Modal>
)
}
export type InfoModalProps = Omit<BaseModalProps, 'backdrop'> & {
onSubmit: () => void
submitButtonText: React.ReactNode | string
}
const InfoModal = ({
children,
onCancel,
onSubmit,
submitButtonText,
...baseModalProps
}: PropsWithChildren<InfoModalProps>) => {
return (
<BaseModal {...baseModalProps} onCancel={onCancel} backdrop={true}>
<rb.Modal.Body className={styles.modalBody}>{children}</rb.Modal.Body>
<rb.Modal.Footer className={styles.modalFooter}>
<rb.Button variant="outline-dark" onClick={() => onSubmit()}>
{submitButtonText}
</rb.Button>
</rb.Modal.Footer>
</BaseModal>
)
}
export type ConfirmModalProps = Omit<BaseModalProps, 'closeButton'> & {
onConfirm: () => void
disabled?: boolean
}
const ConfirmModal = ({
children,
onCancel,
onConfirm,
disabled = false,
...baseModalProps
}: PropsWithChildren<ConfirmModalProps>) => {
const { t } = useTranslation()
return (
<BaseModal {...baseModalProps} onCancel={onCancel}>
<rb.Modal.Body className={styles.modalBody}>{children}</rb.Modal.Body>
<rb.Modal.Footer className={styles.modalFooter}>
<rb.Button
variant="outline-dark"
onClick={() => onCancel()}
className="d-flex justify-content-center align-items-center"
>
<Sprite symbol="cancel" width="26" height="26" />
<div>{t('modal.confirm_button_reject')}</div>
</rb.Button>
<rb.Button variant="outline-dark" onClick={() => onConfirm()} disabled={disabled}>
{t('modal.confirm_button_accept')}
</rb.Button>
</rb.Modal.Footer>
</BaseModal>
)
}
export { BaseModal, InfoModal, ConfirmModal }

View file

@ -1,28 +0,0 @@
:global(.jm-rescan-in-progress) :global(.center-nav-link),
:global(.jm-rescan-in-progress) :global(.center-nav-link-divider) {
filter: blur(2px);
}
.balancePlaceholder {
width: 7.5rem;
}
.loadingIndicator {
display: none !important;
}
:global(.jam-reload-wallet-info-in-progress) .walletSprite {
display: none !important;
}
:global(.jam-reload-wallet-info-in-progress) .loadingIndicator {
display: inline-block !important;
}
.offcanvasBody {
font-size: calc(var(--bs-body-font-size) * 1.5);
}
.offcanvasBody :global(.nav-link) {
width: 100%;
}

View file

@ -1,402 +1,95 @@
import { useCallback, useMemo, useState } from 'react'
import { Link, NavLink, To } from 'react-router-dom'
import * as rb from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import Sprite from './Sprite'
import Balance from './Balance'
import { TabActivityIndicator, JoiningIndicator } from './ActivityIndicators'
import { useSettings, useSettingsDispatch } from '../context/SettingsContext'
import { CurrentWallet, useCurrentWallet, useCurrentWalletInfo } from '../context/WalletContext'
import { useServiceInfo, useSessionConnectionError } from '../context/ServiceInfoContext'
import { routes } from '../constants/routes'
import { AmountSats } from '../libs/JmWalletApi'
import { Wallet, Sun, Moon, Settings, LogOut, Loader2 } from 'lucide-react'
import { Button } from './ui/button'
import { Badge } from './ui/badge'
import { clearSession } from '@/lib/session'
import type { Jar } from './layout/display-mode-context'
import styles from './Navbar.module.css'
const BalanceLoadingIndicator = () => {
return (
<rb.Placeholder as="div" animation="wave">
<rb.Placeholder className={styles.balancePlaceholder} />
</rb.Placeholder>
)
interface NavbarProps {
theme: string
toggleTheme: () => void
toggleDisplayMode: () => void
formatAmount: (amount: number) => string
getLogo: (size: 'sm' | 'lg') => React.ReactNode
jars: Jar[]
isLoading?: boolean
}
interface WalletPreviewProps {
rescanProgress?: number
wallet: CurrentWallet
rescanInProgress: boolean
totalBalance?: AmountSats
unit: Unit
showBalance?: boolean
}
export function Navbar({
theme,
toggleTheme,
toggleDisplayMode,
formatAmount,
getLogo,
jars,
isLoading = false,
}: NavbarProps) {
const handleLogout = () => {
clearSession()
window.location.href = '/login'
}
const WalletPreview = ({
wallet,
rescanInProgress,
rescanProgress,
totalBalance,
unit,
showBalance = false,
}: WalletPreviewProps) => {
const { t } = useTranslation()
const totalBalance = jars.reduce((acc, jar) => acc + jar.balance, 0)
return (
<div className="d-flex align-items-center">
<div className="d-flex align-items-center justify-content-center text-body" style={{ minWidth: '2rem' }}>
<Sprite className={styles.walletSprite} symbol="wallet" width="30" height="30" />
<rb.Spinner
className={styles.loadingIndicator}
as="span"
animation="border"
size="sm"
role="status"
aria-hidden="true"
/>
</div>
<div className="d-flex flex-column ms-2 fs-6">
{wallet && <div className="fw-normal">{wallet.displayName}</div>}
<div className="text-body">
{rescanInProgress ? (
<div className="cursor-wait">
{rescanProgress !== undefined
? t('navbar.text_rescan_in_progress_with_progress', { progress: Math.floor(rescanProgress * 100) })
: t('navbar.text_rescan_in_progress')}
</div>
) : (
<>
{totalBalance === undefined ? (
<BalanceLoadingIndicator />
) : (
<Balance
valueString={`${totalBalance}`}
convertToUnit={unit}
showBalance={showBalance}
enableVisibilityToggle={false}
/>
)}
</>
)}
</div>
</div>
</div>
)
}
interface CenterNavProps {
makerRunning: boolean
schedulerRunning: boolean
singleCoinJoinRunning: boolean
rescanInProgress: boolean
onClick?: () => void
}
const CenterNav = ({
makerRunning,
schedulerRunning,
singleCoinJoinRunning,
rescanInProgress,
onClick,
}: CenterNavProps) => {
const { t } = useTranslation()
return (
<rb.Nav className="justify-content-center align-items-stretch">
<rb.Nav.Item className="d-flex align-items-stretch">
<NavLink
to={routes.receive}
onClick={onClick}
className={({ isActive }) =>
classNames('center-nav-link nav-link d-flex align-items-center justify-content-center', {
active: isActive,
disabled: rescanInProgress,
})
}
>
{t('navbar.tab_receive')}
</NavLink>
</rb.Nav.Item>
<div className="d-none d-md-flex align-items-center center-nav-link-divider">»</div>
<rb.Nav.Item className="d-flex align-items-stretch">
<NavLink
to={routes.send}
onClick={onClick}
className={({ isActive }) =>
classNames('center-nav-link nav-link d-flex align-items-center justify-content-center', {
active: isActive,
disabled: rescanInProgress,
})
}
>
<div className="d-flex align-items-start">
{t('navbar.tab_send')}
<TabActivityIndicator isOn={singleCoinJoinRunning} className="ms-1" />
</div>
</NavLink>
</rb.Nav.Item>
<div className="d-none d-md-flex align-items-center center-nav-link-divider">»</div>
<rb.Nav.Item className="d-flex align-items-stretch">
<NavLink
to={routes.earn}
onClick={onClick}
className={({ isActive }) =>
classNames('center-nav-link nav-link d-flex align-items-center justify-content-center', {
active: isActive,
disabled: rescanInProgress,
})
}
>
<div className="d-flex align-items-start">
{t('navbar.tab_earn')}
<TabActivityIndicator isOn={makerRunning} />
</div>
</NavLink>
</rb.Nav.Item>
<div className="d-none d-md-flex align-items-center center-nav-link-divider">|</div>
<rb.Nav.Item className="d-flex align-items-stretch">
<NavLink
to={routes.jam}
onClick={onClick}
className={({ isActive }) =>
classNames('center-nav-link nav-link d-flex align-items-center justify-content-center', {
active: isActive,
disabled: rescanInProgress,
})
}
>
<div className="d-flex align-items-start">
{t('navbar.tab_sweep')}
<TabActivityIndicator isOn={schedulerRunning} />
</div>
</NavLink>
</rb.Nav.Item>
</rb.Nav>
)
}
interface TrailingNavProps {
joiningRoute?: To
onClick?: () => void
}
const TrailingNav = ({ joiningRoute, onClick }: TrailingNavProps) => {
const { t } = useTranslation()
return (
<rb.Nav className="justify-content-center align-items-stretch">
{joiningRoute && (
<rb.Nav.Item className="d-flex align-items-stretch">
<NavLink
to={joiningRoute}
onClick={onClick}
className="nav-link d-flex justify-content-center align-items-center"
>
<rb.Navbar.Text className="d-md-none">{t('navbar.joining_in_progress')}</rb.Navbar.Text>
<JoiningIndicator isOn={true} title={t('navbar.joining_in_progress')} />
</NavLink>
</rb.Nav.Item>
)}
<rb.Nav.Item className="d-none d-md-flex align-items-stretch">
<FastThemeToggle />
</rb.Nav.Item>
<rb.Nav.Item className="d-flex align-items-stretch">
<NavLink
to={routes.settings}
onClick={onClick}
className={({ isActive }) =>
'nav-link d-flex align-items-center justify-content-center' + (isActive ? ' active' : '')
}
>
<Sprite symbol="gear" width="30" height="30" className="d-none d-md-inline-block" />
<span className="d-inline-block d-md-none">{t('navbar.menu_mobile_settings')}</span>
</NavLink>
</rb.Nav.Item>
</rb.Nav>
)
}
function FastThemeToggle() {
const settings = useSettings()
const settingsDispatch = useSettingsDispatch()
const isLightTheme = useMemo(() => settings.theme === window.JM.THEMES[0], [settings])
const setTheme = useCallback(
(theme: string) => {
if (window.JM.THEMES.includes(theme)) {
document.documentElement.setAttribute(window.JM.THEME_ROOT_ATTR, theme)
settingsDispatch({ theme })
}
},
[settingsDispatch],
)
return (
<rb.Button
variant="link"
className="unstyled"
onClick={() => setTheme(isLightTheme ? window.JM.THEMES[1] : window.JM.THEMES[0])}
>
<Sprite symbol={isLightTheme ? window.JM.THEMES[0] : window.JM.THEMES[1]} width="30" height="30" />
</rb.Button>
)
}
export default function Navbar() {
const { t } = useTranslation()
const settings = useSettings()
const currentWallet = useCurrentWallet()
const currentWalletInfo = useCurrentWalletInfo()
const serviceInfo = useServiceInfo()
const sessionConnectionError = useSessionConnectionError()
const [isExpanded, setIsExpanded] = useState(false)
const makerRunning = useMemo(() => serviceInfo?.makerRunning === true, [serviceInfo])
const rescanInProgress = useMemo(() => serviceInfo?.rescanning === true, [serviceInfo])
const schedulerRunning = useMemo(
() => (serviceInfo?.coinjoinInProgress && serviceInfo?.schedule !== null) || false,
[serviceInfo],
)
const singleCoinJoinRunning = useMemo(
() => (serviceInfo?.coinjoinInProgress && serviceInfo?.schedule === null) || false,
[serviceInfo],
)
const joiningRoute = useMemo(() => {
if (schedulerRunning) return routes.jam
if (singleCoinJoinRunning) return routes.send
if (makerRunning) return routes.earn
return undefined
}, [makerRunning, schedulerRunning, singleCoinJoinRunning])
const height = '75px'
return (
<rb.Navbar
id="mainNav"
bg={settings.theme === 'light' ? 'white' : 'dark'}
sticky="top"
expand="md"
variant={settings.theme}
expanded={isExpanded}
onToggle={(expanded) => setIsExpanded(expanded)}
className="border-bottom py-0"
>
<rb.Container fluid="xl" className="align-items-stretch">
{sessionConnectionError ? (
<rb.Navbar.Text className="d-flex align-items-center" style={{ height: height }}>
No Connection
</rb.Navbar.Text>
) : (
<>
{!currentWallet ? (
<>
<Link
to={routes.home}
className="navbar-brand nav-link d-flex align-items-center ps-0 ps-sm-2 ps-xl-0"
style={{ height: height }}
>
<Sprite symbol="logo" width="30" height="30" className="d-inline-block align-top" />
<span className="ms-2">{t('navbar.title')}</span>
</Link>
<div className="d-flex d-md-none align-items-center">
<rb.Navbar.Toggle id="mainNavToggle">
<span>{t('navbar.menu')}</span>
</rb.Navbar.Toggle>
</div>
<rb.Nav.Item className="d-none d-md-flex align-items-center pe-2">
<FastThemeToggle />
</rb.Nav.Item>
<rb.Navbar.Offcanvas className={`navbar-offcanvas navbar-${settings.theme}`} placement="end">
<rb.Offcanvas.Header>
<rb.Offcanvas.Title>{t('navbar.title')}</rb.Offcanvas.Title>
</rb.Offcanvas.Header>
<rb.Offcanvas.Body className={styles.offcanvasBody}>
<rb.Nav className="ms-auto">
<rb.Nav.Item>
<NavLink
to={routes.createWallet}
onClick={() => isExpanded && setIsExpanded(false)}
className="nav-link d-flex align-items-center justify-content-center"
>
{t('navbar.button_create_wallet')}
</NavLink>
</rb.Nav.Item>
<rb.Nav.Item>
<NavLink
to={routes.importWallet}
onClick={() => isExpanded && setIsExpanded(false)}
className="nav-link d-flex align-items-center justify-content-center"
>
{t('navbar.button_import_wallet')}
</NavLink>
</rb.Nav.Item>
</rb.Nav>
</rb.Offcanvas.Body>
</rb.Navbar.Offcanvas>
</>
<header className="flex items-center justify-between px-6 py-4 bg-gray-100 text-black dark:bg-[#23262b] dark:text-white transition-colors duration-300">
<div className="flex items-center flex-1 min-w-0">
<Wallet className="mr-3" strokeWidth={1} />
<div className="flex flex-col relative">
<span className="flex font-thin -mb-1">Satoshi</span>
<Badge className="absolute right-3 -translate-x-1/2 -top-1 z-10" variant="dev">
dev
</Badge>
<div className="text-lg font-light tracking-wider flex items-center min-h-[40px]">
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
) : (
<>
<rb.Nav className="d-flex flex-1 align-items-stretch">
<rb.Nav.Item className="d-flex align-items-stretch">
<NavLink
to={routes.wallet}
style={{ height: height }}
className={({ isActive }) =>
'leading-nav-link nav-link d-flex align-items-center' + (isActive ? ' active' : '')
}
>
<WalletPreview
wallet={currentWallet}
rescanInProgress={rescanInProgress}
rescanProgress={serviceInfo?.rescanProgress}
totalBalance={currentWalletInfo?.balanceSummary.calculatedTotalBalanceInSats}
showBalance={settings.showBalance}
unit={settings.unit}
/>
</NavLink>
</rb.Nav.Item>
</rb.Nav>
<div className="d-flex d-md-none align-items-center">
<rb.Navbar.Toggle id="mainNavToggle">
<span>{t('navbar.menu_mobile')}</span>
</rb.Navbar.Toggle>
</div>
<rb.Navbar.Offcanvas className={`navbar-offcanvas navbar-${settings.theme}`} placement="end">
<rb.Offcanvas.Header>
<rb.Offcanvas.Title>{t('navbar.title')}</rb.Offcanvas.Title>
</rb.Offcanvas.Header>
<rb.Offcanvas.Body className={styles.offcanvasBody}>
<CenterNav
makerRunning={makerRunning}
schedulerRunning={schedulerRunning}
singleCoinJoinRunning={singleCoinJoinRunning}
rescanInProgress={rescanInProgress}
onClick={() => setIsExpanded(!isExpanded)}
/>
<TrailingNav joiningRoute={joiningRoute} onClick={() => setIsExpanded(!isExpanded)} />
</rb.Offcanvas.Body>
</rb.Navbar.Offcanvas>
<rb.Container className="d-none d-md-flex flex-1 flex-grow-0 align-items-stretch">
<CenterNav
makerRunning={makerRunning}
schedulerRunning={schedulerRunning}
singleCoinJoinRunning={singleCoinJoinRunning}
rescanInProgress={rescanInProgress}
/>
</rb.Container>
<rb.Container className="d-none d-md-flex flex-1 align-items-stretch">
<div className="ms-auto d-flex align-items-stretch">
<TrailingNav joiningRoute={joiningRoute} />
</div>
</rb.Container>
<span
className="tabular-nums text-center select-none cursor-pointer"
onClick={toggleDisplayMode}
title="Click to toggle sats/bitcoin"
>
{formatAmount(totalBalance)}
</span>
<span className="flex items-center min-h-[32px]">{getLogo('sm')}</span>
</>
)}
</>
)}
</rb.Container>
</rb.Navbar>
</div>
</div>
</div>
<div className="flex gap-8 text-sm justify-center items-center flex-1 min-w-0">
<span className="opacity-70 cursor-pointer hover:underline">Receive</span>
<span className="opacity-70 hover:underline cursor-pointer relative">
<span>Earn</span>
<span className="text-[#6ee7b7] text-[8px] absolute -top-1 -right-2"></span>
</span>
<span className="opacity-70 cursor-pointer hover:underline">Send</span>
<span className=" text-gray-400 dark:text-gray-600">|</span>
<span className="opacity-70 cursor-pointer hover:underline">Sweep</span>
</div>
<div className="flex items-center gap-2 justify-end flex-1 min-w-0">
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
aria-label="Toggle dark/light mode"
className="text-black dark:text-white dark:hover:bg-zinc-700 hover:bg-zinc-200"
>
{theme === 'dark' ? <Sun /> : <Moon />}
</Button>
<Button variant="ghost" size="icon" className="text-black dark:text-white">
<Settings />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleLogout}
aria-label="Logout"
className="text-black dark:text-white dark:hover:bg-zinc-700 hover:bg-zinc-200"
>
<LogOut />
</Button>
</div>
</header>
)
}

View file

@ -1,9 +0,0 @@
.icon {
min-height: 19ch;
}
.title {
min-height: 4ch;
}
.description {
min-height: 17ch;
}

View file

@ -1,122 +0,0 @@
import { useCallback, useState, useMemo } from 'react'
import * as rb from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import Sprite from './Sprite'
import { useSettingsDispatch } from '../context/SettingsContext'
import styles from './Onboarding.module.css'
export default function Onboarding() {
const { t } = useTranslation()
const [step, setStep] = useState(0)
const settingsDispatch = useSettingsDispatch()
const steps = useMemo(
() => [
{
title: t('onboarding.screen_1_title'),
description: t('onboarding.screen_1_description'),
icon: <Sprite symbol="welcome" width="11rem" height="11rem" />,
},
{
title: t('onboarding.screen_2_title'),
description: t('onboarding.screen_2_description'),
icon: <Sprite symbol="collab" width="10rem" height="10rem" />,
},
{
title: t('onboarding.screen_3_title'),
description: t('onboarding.screen_3_description'),
icon: <Sprite symbol="key" width="11rem" height="11rem" />,
},
{
title: t('onboarding.screen_4_title'),
description: t('onboarding.screen_4_description'),
icon: <Sprite symbol="handshake" width="11rem" height="11rem" />,
},
{
title: t('onboarding.screen_5_title'),
description: t('onboarding.screen_5_description'),
icon: <Sprite symbol="shield-outline" width="11rem" height="11rem" />,
},
],
[t],
)
const next = useCallback(() => {
if (step < steps.length) {
setStep(step + 1)
} else {
settingsDispatch({ showOnboarding: false })
}
}, [step, steps.length, settingsDispatch])
const back = () => setStep((current) => Math.max(0, current - 1))
if (step === 0) {
return (
<>
<div className="text-center mt-3 mb-4">
<Sprite symbol="logo" width="128px" height="128px" className="mb-4" />
<h1>{t('onboarding.splashscreen_title')}</h1>
<h2 className="fw-normal text-secondary mb-5">{t('onboarding.splashscreen_subtitle')}</h2>
<rb.Button className="w-100 mb-2" size="lg" variant="dark" onClick={next}>
{t('onboarding.splashscreen_button_get_started')}
</rb.Button>
<rb.Button
className="w-100 mb-2"
size="lg"
variant="outline-dark"
onClick={() => settingsDispatch({ showOnboarding: false })}
>
{t('onboarding.splashscreen_button_skip_intro')}
</rb.Button>
</div>
<div className="text-secondary mb-2">
<p className="text-center mb-4">
{t('onboarding.splashscreen_description_line1')}
<br />
{t('onboarding.splashscreen_description_line2')}
</p>
<div className="text-center fw-bolder">{t('onboarding.splashscreen_warning_title')}</div>
<p className="text-justify">
<Trans i18nKey="onboarding.splashscreen_warning_text">
While JoinMarket is tried and tested, Jam is not. It is in a beta stage, so use with caution.
<a
href="https://github.com/joinmarket-webui/jam/issues"
target="_blank"
rel="noopener noreferrer"
className="link-secondary"
>
Help us improve the project on GitHub.
</a>
<a href="https://jamdocs.org" target="_blank" rel="noopener noreferrer" className="link-secondary">
read the documentation
</a>
</Trans>
</p>
</div>
</>
)
} else {
const content = steps[step - 1]
return (
<>
<div className="text-center mt-3">
<div className={`${styles.icon} d-flex justify-content-center align-items-center mb-4`}>{content.icon}</div>
<h2 className={`${styles.title} d-flex justify-content-center align-items-center mb-2`}>{content.title}</h2>
<div className={`${styles.description} d-flex justify-content-center align-items-center text-secondary mb-4`}>
{content.description}
</div>
</div>
<div className="d-flex flex-column align-items-center gap-2">
<rb.Button className="w-50" variant="dark" size="lg" onClick={next}>
{step === steps.length ? t('onboarding.button_complete') : t('onboarding.button_next')}
</rb.Button>
<rb.Button className="w-50" variant="none" size="sm" onClick={back}>
{t('global.back')}
</rb.Button>
</div>
</>
)
}
}

View file

@ -1,65 +0,0 @@
.orderbookContentPlaceholder {
height: 2.625rem;
margin: 1px 0;
}
.overlayContainer .orderbookContainer {
display: flex;
flex-direction: column;
gap: 0.5rem;
background-color: var(--bs-body-bg);
}
@media only screen and (min-width: 992px) {
.overlayContainer .orderbookContainer {
gap: 1.5rem;
padding: 2rem;
border-radius: 0.5rem;
}
}
.overlayContainer .orderbookContainer .titleBar {
min-height: 3.6rem;
display: flex;
justify-content: space-between;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 0 0.5rem 0.8rem 0.5rem;
background-color: var(--bs-gray-100);
}
@media only screen and (min-width: 992px) {
.overlayContainer .orderbookContainer .titleBar {
padding: 0.8rem 1rem;
border-radius: 0.6rem;
}
}
@media only screen and (min-width: 768px) {
.overlayContainer .orderbookContainer .titleBar {
align-items: center;
flex-direction: row;
}
}
:root[data-theme='dark'] .overlayContainer .orderbookContainer .titleBar {
background-color: var(--bs-gray-800);
}
.overlayContainer .orderbookContainer .titleBar .refreshButton {
display: flex;
justify-content: center;
align-items: center;
width: 2rem;
height: 2rem;
padding: 0.1rem;
border: none;
}
.orderbookContainer tr.highlighted td {
background-color: rgba(var(--bs-success-rgb), 0.33) !important;
}
.orderbookContainer tr:hover.highlighted td {
background-color: rgba(var(--bs-success-rgb), 0.66) !important;
}

View file

@ -1,742 +0,0 @@
import { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'
import { Table, Header, HeaderRow, HeaderCell, Body, Row, Cell } from '@table-library/react-table-library/table'
import { usePagination } from '@table-library/react-table-library/pagination'
import { useSort, HeaderCellSort, SortToggleType } from '@table-library/react-table-library/sort'
import * as TableTypes from '@table-library/react-table-library/types/table'
import { useTheme } from '@table-library/react-table-library/theme'
import * as rb from 'react-bootstrap'
import { TFunction, i18n } from 'i18next'
import { useTranslation } from 'react-i18next'
import { AmountSats, Helper as ApiHelper } from '../libs/JmWalletApi'
import * as ObwatchApi from '../libs/JmObwatchApi'
import { useSettings } from '../context/SettingsContext'
import Balance from './Balance'
import Sprite from './Sprite'
import TablePagination from './TablePagination'
import { BTC, factorToPercentage, isAbsoluteOffer, isRelativeOffer } from '../utils'
import { isDebugFeatureEnabled, isDevMode } from '../constants/debugFeatures'
import ToggleSwitch from './ToggleSwitch'
import { pseudoRandomNumber } from './Send/helpers'
import { JM_DUST_THRESHOLD } from '../constants/jm'
import * as fb from './fb/utils'
import styles from './Orderbook.module.css'
const TABLE_THEME = {
Table: `
--data-table-library_grid-template-columns: 1fr 5rem 1fr 2fr 2fr 2fr 2fr;
font-size: 0.9rem;
`,
BaseCell: `
&:nth-of-type(3) div button {
justify-content: center;
}
&:nth-of-type(4) div button {
justify-content: end;
}
&:nth-of-type(5) div button {
justify-content: end;
}
&:nth-of-type(6) div button {
justify-content: end;
}
&:nth-of-type(7) div button {
justify-content: end;
}
&:nth-of-type(8) div button {
justify-content: end;
}
`,
Cell: `
&:nth-of-type(3) {
text-align: center;
}
&:nth-of-type(4) {
text-align: right;
}
&:nth-of-type(5) {
text-align: right;
}
&:nth-of-type(6) {
text-align: right;
}
&:nth-of-type(7) {
text-align: right;
}
&:nth-of-type(8) {
text-align: right;
}
`,
}
const withTooltip = (node: ReactElement, tooltip: string, overlayProps?: Partial<rb.OverlayTriggerProps>) => {
return (
<rb.OverlayTrigger {...overlayProps} overlay={(props) => <rb.Tooltip {...props}>{tooltip}</rb.Tooltip>}>
{node}
</rb.OverlayTrigger>
)
}
const renderOrderType = (type: OrderTypeProps) => {
const elem = <rb.Badge bg={type.badgeColor}>{type.displayValue}</rb.Badge>
return type.tooltip ? withTooltip(elem, type.tooltip) : elem
}
type OrderTypeProps = {
value: string // original value, example: 'sw0reloffer', 'swreloffer', 'reloffer', 'sw0absoffer', 'swabsoffer', 'absoffer'
displayValue: string // example: "absolute" or "relative" (respecting i18n)
badgeColor: 'info' | 'primary' | 'secondary'
tooltip?: 'Native SW Absolute Fee' | 'Native SW Relative Fee' | string
isAbsolute?: boolean
isRelative?: boolean
}
interface OrderTableEntry {
type: OrderTypeProps
counterparty: string // example: "J5Bv3JSxPFWm2Yjb"
orderId: string // example: "0" (not unique!)
fee: {
value: number
displayValue: string // example: "250" (abs offers) or "0.000100%" (rel offers)
}
minerFeeContribution: string // example: "0"
minimumSize: string // example: "27300"
maximumSize: string // example: "237499972700"
bondValue: {
value: number
displayValue: string // example: "0" (no fb) or "114557102085.28133"
locktime?: number
displayLocktime?: string
displayExpiresIn?: string
amount?: AmountSats
}
}
interface OrderTableRow extends OrderTableEntry, TableTypes.TableNode {}
// `TableNode` is known to have same properties as `OrderTableEntry`, hence prefer casting over object destructuring
const asOrderTableEntry = (tableNode: TableTypes.TableNode) => tableNode as unknown as OrderTableRow
const SORT_KEYS = {
type: 'TYPE',
counterparty: 'COUNTERPARTY',
fee: 'FEE',
minimumSize: 'MINIMUM_SIZE',
maximumSize: 'MAXIMUM_SIZE',
minerFeeContribution: 'MINER_FEE_CONTRIBUTION',
bondValue: 'BOND_VALUE',
}
const orderTypeProps = (offer: ObwatchApi.Offer, t: TFunction): OrderTypeProps => {
if (isAbsoluteOffer(offer.ordertype)) {
return {
value: offer.ordertype,
displayValue: t('orderbook.text_offer_type_absolute'),
badgeColor: 'info',
tooltip: offer.ordertype === 'sw0absoffer' ? 'Native SW Absolute Fee' : offer.ordertype,
isAbsolute: true,
}
}
if (isRelativeOffer(offer.ordertype)) {
return {
value: offer.ordertype,
displayValue: t('orderbook.text_offer_type_relative'),
badgeColor: 'primary',
tooltip: offer.ordertype === 'sw0reloffer' ? 'Native SW Relative Fee' : offer.ordertype,
isRelative: true,
}
}
return {
value: offer.ordertype,
displayValue: offer.ordertype,
badgeColor: 'secondary',
}
}
const renderOrderFee = (val: string, settings: any) => {
return val.includes('%') ? (
<span className="font-monospace">{val}</span>
) : (
<Balance valueString={val} convertToUnit={settings.unit} showBalance={true} />
)
}
const renderOrderAsRow = (item: OrderTableRow, settings: any) => {
return (
<Row key={item.id} item={item} className={item.__highlighted ? styles.highlighted : ''}>
<Cell className="font-monospace">{item.counterparty}</Cell>
<Cell>{item.orderId}</Cell>
<Cell>{renderOrderType(item.type)}</Cell>
<Cell>{renderOrderFee(item.fee.displayValue, settings)}</Cell>
<Cell>
<Balance valueString={item.minimumSize} convertToUnit={settings.unit} showBalance={true} />
</Cell>
<Cell>
<Balance valueString={item.maximumSize} convertToUnit={settings.unit} showBalance={true} />
</Cell>
<Cell hide={true}>
<Balance valueString={item.minerFeeContribution} convertToUnit={settings.unit} showBalance={true} />
</Cell>
<Cell className="font-monospace">
{item.bondValue.value > 0 ? (
<rb.OverlayTrigger
popperConfig={{
modifiers: [
{
name: 'offset',
options: {
offset: [0, 10],
},
},
],
}}
overlay={(props) => (
<rb.Tooltip {...props}>
<Balance
valueString={String(item.bondValue.amount)}
colored={false}
convertToUnit={BTC}
showBalance={true}
/>
<div className="small">
{item.bondValue.displayLocktime} ({item.bondValue.displayExpiresIn})
</div>
</rb.Tooltip>
)}
>
<span>{item.bondValue.displayValue}</span>
</rb.OverlayTrigger>
) : (
<>{item.bondValue.displayValue}</>
)}
</Cell>
</Row>
)
}
interface OrderbookTableProps {
data: TableTypes.Data<OrderTableRow>
}
const OrderbookTable = ({ data }: OrderbookTableProps) => {
const { t } = useTranslation()
const settings = useSettings()
const tableTheme = useTheme(TABLE_THEME)
const pagination = usePagination(data, {
state: {
page: 0,
size: 25,
},
})
const tableSort = useSort(
data,
{
state: {
sortKey: SORT_KEYS.minimumSize,
reverse: false,
},
},
{
sortIcon: {
margin: '4px',
iconDefault: <Sprite symbol="caret-right" width="20" height="20" />,
iconUp: <Sprite symbol="caret-up" width="20" height="20" />,
iconDown: <Sprite symbol="caret-down" width="20" height="20" />,
},
sortToggleType: SortToggleType.AlternateWithReset,
sortFns: {
[SORT_KEYS.type]: (array) => array.sort((a, b) => a.type.displayValue.localeCompare(b.type.displayValue)),
[SORT_KEYS.fee]: (array) =>
array.sort((a, b) => {
const aOrder = asOrderTableEntry(a)
const bOrder = asOrderTableEntry(b)
if (aOrder.type.isAbsolute !== bOrder.type.isAbsolute) {
return aOrder.type.isAbsolute === true ? 1 : -1
}
return aOrder.fee.value - bOrder.fee.value
}),
[SORT_KEYS.minimumSize]: (array) => array.sort((a, b) => a.minimumSize - b.minimumSize),
[SORT_KEYS.maximumSize]: (array) => array.sort((a, b) => a.maximumSize - b.maximumSize),
[SORT_KEYS.minerFeeContribution]: (array) =>
array.sort((a, b) => a.minerFeeContribution - b.minerFeeContribution),
[SORT_KEYS.counterparty]: (array) =>
array.sort((a, b) => {
const val = a.counterparty.localeCompare(b.counterparty)
return val !== 0 ? val : +a.orderId - +b.orderId
}),
[SORT_KEYS.bondValue]: (array) => array.sort((a, b) => a.bondValue.value - b.bondValue.value),
},
},
)
const pinnedOfferRows = useMemo(
() =>
data.nodes
.filter((item: OrderTableRow) => item.__pinned === true)
.map((item: OrderTableRow) => renderOrderAsRow(item, settings)),
[data, settings],
)
return (
<>
<Table
data={data}
theme={tableTheme}
pagination={pagination}
sort={tableSort}
layout={{ custom: true, horizontalScroll: true }}
className="table striped"
>
{(tableList: TableTypes.TableProps<OrderTableRow>) => (
<>
<Header>
<HeaderRow>
<HeaderCellSort sortKey={SORT_KEYS.counterparty}>
{t('orderbook.table.heading_counterparty')}
</HeaderCellSort>
<HeaderCell>{t('orderbook.table.heading_order_id')}</HeaderCell>
<HeaderCellSort sortKey={SORT_KEYS.type}>{t('orderbook.table.heading_type')}</HeaderCellSort>
<HeaderCellSort sortKey={SORT_KEYS.fee}>{t('orderbook.table.heading_fee')}</HeaderCellSort>
<HeaderCellSort sortKey={SORT_KEYS.minimumSize}>
{t('orderbook.table.heading_minimum_size')}
</HeaderCellSort>
<HeaderCellSort sortKey={SORT_KEYS.maximumSize}>
{t('orderbook.table.heading_maximum_size')}
</HeaderCellSort>
<HeaderCellSort hide={true} sortKey={SORT_KEYS.minerFeeContribution}>
{t('orderbook.table.heading_miner_fee_contribution')}
</HeaderCellSort>
<HeaderCellSort sortKey={SORT_KEYS.bondValue}>{t('orderbook.table.heading_bond_value')}</HeaderCellSort>
</HeaderRow>
</Header>
<Body>
{pinnedOfferRows}
{tableList
.filter((item: OrderTableRow) => item.__pinned !== true)
.map((item: OrderTableRow) => renderOrderAsRow(item, settings))}
</Body>
</>
)}
</Table>
<div className="mt-4 mb-4 mb-lg-0">
<TablePagination data={data} pagination={pagination} />
</div>
</>
)
}
const offerToTableEntry = (
offer: ObwatchApi.Offer,
fidelityBond: ObwatchApi.FidelityBond | undefined,
i18n: i18n,
): OrderTableEntry => {
return {
type: orderTypeProps(offer, i18n.t),
counterparty: offer.counterparty,
orderId: String(offer.oid),
fee:
typeof offer.cjfee === 'number'
? {
value: offer.cjfee,
displayValue: String(offer.cjfee),
}
: (() => {
const value = parseFloat(offer.cjfee)
return {
value,
displayValue: factorToPercentage(value).toFixed(4) + '%',
}
})(),
minerFeeContribution: String(offer.txfee),
minimumSize: String(offer.minsize),
maximumSize: String(offer.maxsize),
bondValue: {
value: offer.fidelity_bond_value,
displayValue: String(offer.fidelity_bond_value.toFixed(0)),
locktime: fidelityBond?.locktime,
displayLocktime:
fidelityBond?.locktime !== undefined ? new Date(fidelityBond.locktime * 1_000).toDateString() : undefined,
displayExpiresIn:
fidelityBond?.locktime !== undefined
? fb.time.humanReadableDuration({
to: fidelityBond.locktime * 1_000,
locale: i18n.resolvedLanguage || i18n.language,
})
: undefined,
amount: fidelityBond?.amount,
},
}
}
interface OrderbookProps {
entries: OrderTableEntry[]
reload: (signal: AbortSignal) => Promise<void>
isReloading: boolean
refresh: (signal: AbortSignal) => Promise<void>
isRefreshing: boolean
nickname?: string
}
export function Orderbook({ entries, reload, isReloading, refresh, isRefreshing, nickname }: OrderbookProps) {
const { t } = useTranslation()
const settings = useSettings()
const [search, setSearch] = useState('')
const [isHighlightOwnOffers, setIsHighlightOwnOffers] = useState(false)
const [isPinToTopOwnOffers, setIsPinToTopOwnOffers] = useState(false)
const [highlightedOrders, setHighlightedOrders] = useState<OrderTableEntry[]>([])
const [pinToTopOrders, setPinToTopOrders] = useState<OrderTableEntry[]>([])
const isLoading = useMemo(() => isReloading || isRefreshing, [isReloading, isRefreshing])
const tableData: TableTypes.Data<OrderTableRow> = useMemo(() => {
const searchVal = search.replace('.', '').toLowerCase()
const filteredOrders =
searchVal === ''
? entries
: entries.filter((entry) => {
return (
entry.type.displayValue.toLowerCase().includes(searchVal) ||
entry.counterparty.toLowerCase().includes(searchVal) ||
entry.fee.displayValue.replace('.', '').toLowerCase().includes(searchVal) ||
entry.minimumSize.replace('.', '').toLowerCase().includes(searchVal) ||
entry.maximumSize.replace('.', '').toLowerCase().includes(searchVal) ||
entry.minerFeeContribution.replace('.', '').toLowerCase().includes(searchVal) ||
entry.bondValue.displayValue.replace('.', '').toLowerCase().includes(searchVal) ||
entry.orderId.toLowerCase().includes(searchVal)
)
})
const nodes = filteredOrders.map((order) => ({
...order,
id: `${order.counterparty}_${order.orderId}`,
__highlighted: highlightedOrders.includes(order),
__pinned: pinToTopOrders.includes(order),
}))
return { nodes }
}, [entries, search, highlightedOrders, pinToTopOrders])
const counterpartyCount = useMemo(() => new Set(entries.map((it) => it.counterparty)).size, [entries])
const counterpartyCountFiltered = useMemo(
() => new Set(tableData.nodes.map((it) => it.counterparty)).size,
[tableData],
)
const ownOffers = useMemo(() => {
return nickname ? entries.filter((it) => it.counterparty === nickname) : []
}, [nickname, entries])
useEffect(() => {
setHighlightedOrders(isHighlightOwnOffers ? ownOffers : [])
}, [ownOffers, isHighlightOwnOffers])
useEffect(() => {
setPinToTopOrders(isPinToTopOwnOffers ? ownOffers : [])
}, [ownOffers, isPinToTopOwnOffers])
return (
<div className={styles.orderbookContainer}>
<div className={styles.titleBar}>
<div className="d-flex justify-content-center align-items-center gap-3">
<rb.SplitButton
size="sm"
variant={`${settings.theme === 'dark' ? 'outline-dark' : 'outline-dark'}`}
title={
<div className={styles.refreshButton} title={t('orderbook.button_reload_title')}>
{isLoading ? (
<rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" />
) : (
<Sprite symbol="refresh" width="24" height="24" />
)}
</div>
}
disabled={isLoading}
onClick={() => {
if (isLoading) return
const abortCtrl = new AbortController()
console.log('Reloading orderbook...')
reload(abortCtrl.signal).finally(() => {
console.log('Finished reloading orderbook.')
})
}}
>
<rb.Dropdown.Item
eventKey="1"
active={false}
title={t('orderbook.button_refresh_title')}
onClick={() => {
if (isLoading) return
const abortCtrl = new AbortController()
console.log('Refreshing orderbook...')
refresh(abortCtrl.signal).finally(() => {
console.log('Finished refreshing orderbook.')
})
}}
>
{t('orderbook.button_refresh_text')}
</rb.Dropdown.Item>
</rb.SplitButton>
<div className="small">
{search === '' ? (
<>
{t('orderbook.text_orderbook_summary', {
count: entries.length,
counterpartyCount,
})}
</>
) : (
<>
{t('orderbook.text_orderbook_summary_filtered', {
count: tableData.nodes.length,
counterpartyCount: counterpartyCountFiltered,
})}
</>
)}
</div>
</div>
<div>
<rb.Form.Group controlId="search">
<rb.Form.Label className="m-0 pe-2 d-none">{t('orderbook.label_search')}</rb.Form.Label>
<rb.Form.Control
name="search"
placeholder={t('orderbook.placeholder_search')}
value={search}
disabled={isLoading}
onChange={(e) => setSearch(e.target.value)}
/>
</rb.Form.Group>
</div>
</div>
<div className="px-md-3 pb-2">
{entries.length === 0 ? (
<rb.Alert variant="info">{t('orderbook.alert_empty_orderbook')}</rb.Alert>
) : (
<>
{nickname && (
<>
<div className="d-flex flex-column gap-2 mb-3 ps-3 ps-md-0 pt-3 pt-lg-0">
<ToggleSwitch
label={t('orderbook.label_highlight_own_orders')}
subtitle={ownOffers.length === 0 ? t('orderbook.text_highlight_own_orders_subtitle') : undefined}
toggledOn={isHighlightOwnOffers}
onToggle={(isToggled) => setIsHighlightOwnOffers(isToggled)}
disabled={isLoading || ownOffers.length === 0}
/>
{ownOffers.length > 0 && (
<ToggleSwitch
label={t('orderbook.label_pin_to_top_own_orders')}
subtitle={t('orderbook.text_pin_to_top_own_orders_subtitle')}
toggledOn={isPinToTopOwnOffers}
onToggle={(isToggled) => {
setIsPinToTopOwnOffers(isToggled)
if (isToggled) {
setIsHighlightOwnOffers(true)
}
}}
disabled={isLoading}
/>
)}
</div>
<div className="mb-3 ps-3 ps-md-0 pt-3 pt-lg-0"></div>
</>
)}
<OrderbookTable data={tableData} />
</>
)}
</div>
</div>
)
}
type OrderbookOverlayProps = rb.OffcanvasProps & {
nickname?: string
}
export function OrderbookOverlay({ nickname, show, onHide }: OrderbookOverlayProps) {
const { t, i18n } = useTranslation()
const [alert, setAlert] = useState<SimpleAlert>()
const [isInitialized, setIsInitialized] = useState(false)
const [isReloading, setIsReloading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [offers, setOffers] = useState<ObwatchApi.Offer[]>()
const [fidelityBonds, setFidelityBonds] = useState<Map<string, ObwatchApi.FidelityBond>>()
const [__dev_showGenerateDemoOfferButton] = useState(isDebugFeatureEnabled('enableDemoOrderbook'))
const tableEntries = useMemo(() => {
return (
offers &&
offers.map((offer) => offerToTableEntry(offer, fidelityBonds && fidelityBonds.get(offer.counterparty), i18n))
)
}, [offers, fidelityBonds, i18n])
const __dev_generateDemoReportEntryButton = () => {
const randomMinsize = pseudoRandomNumber(JM_DUST_THRESHOLD, JM_DUST_THRESHOLD + 100_000)
const randomOrdertype = Math.random() > 0.5 ? 'sw0absoffer' : 'sw0reloffer'
const randomCounterparty = `demo_` + pseudoRandomNumber(0, 10)
setOffers((it) => {
const randomOffer = {
counterparty: randomCounterparty,
oid: (it || []).filter((e) => e.counterparty === randomCounterparty).length,
ordertype: randomOrdertype,
minsize: randomMinsize,
maxsize: randomMinsize + pseudoRandomNumber(21_000, 21_000_000),
txfee: 0,
cjfee: randomOrdertype === 'sw0absoffer' ? pseudoRandomNumber(0, 10_000) : Math.random().toFixed(5),
fidelity_bond_value: Math.random() > 0.25 ? 0 : pseudoRandomNumber(1_000, 21_000_000),
}
return [...(it || []), randomOffer]
})
}
const reload = useCallback(
(signal: AbortSignal, delay: number = 200) => {
setIsReloading(true)
return (
ObwatchApi.fetchOrderbook({ signal })
// show the loader a little longer to avoid flickering
.then((it) => new Promise<ObwatchApi.OrderbookJson>((resolve) => setTimeout(() => resolve(it), delay)))
.then((orderbook) => {
if (signal.aborted) return
setIsReloading(false)
setAlert(undefined)
setOffers(orderbook.offers || [])
setFidelityBonds(new Map((orderbook.fidelitybonds || []).map((it) => [it.counterparty, it])))
if (isDevMode()) {
console.table(orderbook.offers)
}
})
.catch((e) => {
if (signal.aborted) return
const message = t('orderbook.error_loading_orderbook_failed', {
reason: e.message || t('global.errors.reason_unknown'),
})
setAlert({ variant: 'danger', message })
})
.finally(() => {
setIsReloading(false)
})
)
},
[t],
)
const refresh = useCallback(
(signal: AbortSignal) => {
setIsRefreshing(true)
return ObwatchApi.refreshOrderbook({ signal, redirect: 'manual' })
.then((res) => {
if (!res.ok && res.type !== 'opaqueredirect') {
// e.g. error is raised if ob-watcher is not running
return ApiHelper.throwError(res)
}
return reload(signal, 0)
})
.catch((e) => {
if (signal.aborted) return
const message = t('orderbook.error_loading_orderbook_failed', {
reason: e.message || t('global.errors.reason_unknown'),
})
setAlert({ variant: 'danger', message })
})
.finally(() => {
setIsRefreshing(false)
})
},
[reload, t],
)
useEffect(() => {
if (!show) return
const abortCtrl = new AbortController()
reload(abortCtrl.signal).finally(() => {
if (abortCtrl.signal.aborted) return
setIsInitialized(true)
})
return () => {
abortCtrl.abort()
}
}, [show, reload])
return (
<rb.Offcanvas
className={`offcanvas-fullscreen ${styles.overlayContainer}`}
show={show}
onHide={onHide}
placement="bottom"
>
<rb.Offcanvas.Header>
<rb.Container fluid="lg">
<div className="w-100 d-flex">
<div className="d-flex align-items-center flex-1">
<rb.Offcanvas.Title>{t('orderbook.title')}</rb.Offcanvas.Title>
</div>
<div>
<rb.Button variant="link" className="unstyled pe-0" onClick={onHide}>
<Sprite symbol="cancel" width="32" height="32" />
</rb.Button>
</div>
</div>
</rb.Container>
</rb.Offcanvas.Header>
<rb.Offcanvas.Body>
<rb.Container fluid="lg" className="py-3">
{!isInitialized && isReloading ? (
Array(5)
.fill('')
.map((_, index) => {
return (
<rb.Placeholder key={index} as="div" animation="wave">
<rb.Placeholder xs={12} className={styles.orderbookContentPlaceholder} />
</rb.Placeholder>
)
})
) : (
<>
{__dev_showGenerateDemoOfferButton && (
<rb.Row>
<rb.Col className="px-0 mb-2">
<rb.Button
className="position-relative"
variant="outline-dark"
disabled={false}
onClick={() => __dev_generateDemoReportEntryButton()}
>
<div className="d-flex justify-content-center align-items-center">
Generate demo entry
<Sprite symbol="plus" width="20" height="20" className="ms-2" />
</div>
<span className="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-warning">
dev
</span>
</rb.Button>
</rb.Col>
</rb.Row>
)}
{alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
{isInitialized && (
<rb.Row>
<rb.Col className="px-0">
<Orderbook
nickname={nickname}
entries={tableEntries || []}
reload={reload}
isReloading={isReloading}
refresh={refresh}
isRefreshing={isRefreshing}
/>
</rb.Col>
</rb.Row>
)}
</>
)}
</rb.Container>
</rb.Offcanvas.Body>
</rb.Offcanvas>
)
}

View file

@ -1,42 +0,0 @@
import Sprite from './Sprite'
import classNames from 'classnames'
interface PageTitleProps {
title: string
subtitle?: string
success?: boolean
center?: boolean
}
export default function PageTitle({ title, subtitle, success = false, center = false }: PageTitleProps) {
return (
<div
className={classNames('mb-4', {
'text-center': center,
})}
>
{success && (
<div
className={classNames('mb-2', {
'd-flex align-items-center justify-content-center': center,
})}
>
<div
className="d-flex align-items-center justify-content-center"
style={{
width: '3rem',
height: '3rem',
backgroundColor: 'rgba(39, 174, 96, 1)',
color: 'white',
borderRadius: '50%',
}}
>
<Sprite symbol="checkmark" width="24" height="30" />
</div>
</div>
)}
<div style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem' }}>{title}</div>
{subtitle && <p className="text-secondary">{subtitle}</p>}
</div>
)
}

View file

@ -1,7 +0,0 @@
.infoIcon {
margin: 2px 0 0 0.25rem;
color: var(--bs-gray-500);
border: 1px solid var(--bs-gray-500);
border-radius: 50%;
cursor: help;
}

View file

@ -1,267 +0,0 @@
import { PropsWithChildren, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import * as rb from 'react-bootstrap'
import Sprite from './Sprite'
import Balance from './Balance'
import { Settings, useSettings } from '../context/SettingsContext'
import { FeeValues, TxFee, useEstimatedMaxCollaboratorFee } from '../hooks/Fees'
import { ConfirmModal, ConfirmModalProps } from './Modal'
import { AmountSats, BitcoinAddress } from '../libs/JmWalletApi'
import { jarInitial } from './jars/Jar'
import { isValidNumber } from '../utils'
import styles from './PaymentConfirmModal.module.css'
import { Utxos } from '../context/WalletContext'
import { UtxoListDisplay } from './Send/ShowUtxos'
import Divider from './Divider'
const feeRange: (txFee: TxFee, txFeeFactor: number) => [number, number] = (txFee, txFeeFactor) => {
if (txFee.unit !== 'sats/kilo-vbyte') {
throw new Error('This function can only be used with unit `sats/kilo-vbyte`')
}
const feeTargetInSatsPerVByte = txFee.value! / 1_000
const minFeeSatsPerVByte = Math.max(1, feeTargetInSatsPerVByte)
const maxFeeSatsPerVByte = feeTargetInSatsPerVByte * (1 + txFeeFactor)
return [minFeeSatsPerVByte, maxFeeSatsPerVByte]
}
const useMiningFeeText = ({ tx_fees, tx_fees_factor }: Pick<FeeValues, 'tx_fees' | 'tx_fees_factor'>) => {
const { t } = useTranslation()
return useMemo(() => {
if (!isValidNumber(tx_fees?.value) || !isValidNumber(tx_fees_factor)) return null
if (!tx_fees?.unit) {
return null
} else if (tx_fees.unit === 'blocks') {
return t('send.confirm_send_modal.text_miner_fee_in_targeted_blocks', { count: tx_fees.value })
} else {
const [minFeeSatsPerVByte, maxFeeSatsPerVByte] = feeRange(tx_fees, tx_fees_factor!)
const fractionDigits = 2
if (minFeeSatsPerVByte.toFixed(fractionDigits) === maxFeeSatsPerVByte.toFixed(fractionDigits)) {
return t('send.confirm_send_modal.text_miner_fee_in_satspervbyte_exact', {
value: minFeeSatsPerVByte.toLocaleString(undefined, {
maximumFractionDigits: Math.log10(1_000),
}),
})
}
return t('send.confirm_send_modal.text_miner_fee_in_satspervbyte_randomized', {
min: minFeeSatsPerVByte.toLocaleString(undefined, {
maximumFractionDigits: fractionDigits,
}),
max: maxFeeSatsPerVByte.toLocaleString(undefined, {
maximumFractionDigits: fractionDigits,
}),
})
}
}, [t, tx_fees, tx_fees_factor])
}
type ReviewUtxosProps = Required<Pick<PaymentDisplayInfo, 'isSweep' | 'availableUtxos'>> & {
settings: Settings
}
const ReviewUtxos = ({ settings, availableUtxos, isSweep }: ReviewUtxosProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState<boolean>(availableUtxos.length === 1)
const allUtxosAreUsed = isSweep || availableUtxos.length === 1
return (
<rb.Row className="mt-2">
<rb.Col xs={4} md={3} className="d-flex align-items-center justify-content-end text-end">
<strong>
{allUtxosAreUsed
? t('send.confirm_send_modal.label_selected_utxos', { count: availableUtxos.length })
: t('send.confirm_send_modal.label_eligible_utxos')}
</strong>
</rb.Col>
<rb.Col xs={8} md={9}>
<Divider toggled={isOpen} onToggle={() => setIsOpen((current) => !current)} />
</rb.Col>
<rb.Collapse in={isOpen}>
<rb.Col xs={12}>
<div className="my-2 text-start text-secondary">
{allUtxosAreUsed
? t('send.confirm_send_modal.description_selected_utxos', { count: availableUtxos.length })
: t('send.confirm_send_modal.description_eligible_utxos')}
</div>
<UtxoListDisplay
utxos={availableUtxos.map((it) => ({ ...it, checked: false, selectable: false }))}
settings={settings}
onToggle={() => {
// No-op since these UTXOs are only for review and are not selectable
}}
/>
</rb.Col>
</rb.Collapse>
</rb.Row>
)
}
interface PaymentDisplayInfo {
sourceJarIndex?: JarIndex
destination: BitcoinAddress | string
amount: AmountSats
isSweep: boolean
isCoinjoin: boolean
numCollaborators?: number
feeConfigValues?: FeeValues
showPrivacyInfo?: boolean
availableUtxos?: Utxos
}
interface PaymentConfirmModalProps extends ConfirmModalProps {
data: PaymentDisplayInfo
}
export function PaymentConfirmModal({
data: {
sourceJarIndex,
destination,
amount,
isSweep,
isCoinjoin,
numCollaborators,
feeConfigValues,
showPrivacyInfo = true,
availableUtxos = [],
},
children,
...confirmModalProps
}: PropsWithChildren<PaymentConfirmModalProps>) {
const { t } = useTranslation()
const settings = useSettings()
const miningFeeText = useMiningFeeText({ ...feeConfigValues })
const estimatedMaxCollaboratorFee = useEstimatedMaxCollaboratorFee({
isCoinjoin,
feeConfigValues,
amount,
numCollaborators: numCollaborators || null,
})
return (
<ConfirmModal {...confirmModalProps}>
<rb.Container className="mt-2" fluid>
{showPrivacyInfo && (
<rb.Row className="mt-2 mb-3">
<rb.Col xs={12} className="text-center">
{isCoinjoin ? (
<strong className="text-success">{t('send.confirm_send_modal.text_collaborative_tx_enabled')}</strong>
) : (
<strong className="text-danger">{t('send.confirm_send_modal.text_collaborative_tx_disabled')}</strong>
)}
</rb.Col>
</rb.Row>
)}
{sourceJarIndex !== undefined && (
<rb.Row>
<rb.Col xs={4} md={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_source_jar')}</strong>
</rb.Col>
<rb.Col xs={8} md={9} className="text-start">
{t('send.confirm_send_modal.text_source_jar', { jarId: jarInitial(sourceJarIndex) })}
</rb.Col>
</rb.Row>
)}
<rb.Row>
<rb.Col xs={4} md={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_recipient')}</strong>
</rb.Col>
<rb.Col xs={8} md={9} className="text-start text-break slashed-zeroes">
{destination}
</rb.Col>
</rb.Row>
<rb.Row>
<rb.Col xs={4} md={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_amount')}</strong>
</rb.Col>
<rb.Col xs={8} md={9} className="text-start">
{isSweep ? (
<>
<Trans i18nKey="send.confirm_send_modal.text_sweep_balance">
Sweep
<Balance valueString={String(amount)} convertToUnit={settings.unit} showBalance={true} />
</Trans>
<rb.OverlayTrigger
placement="right"
overlay={
<rb.Popover>
<rb.Popover.Body>{t('send.confirm_send_modal.text_sweep_info_popover')}</rb.Popover.Body>
</rb.Popover>
}
>
<div className="d-inline-flex align-items-center">
<Sprite className={styles.infoIcon} symbol="info" width="13" height="13" />
</div>
</rb.OverlayTrigger>
</>
) : (
<Balance valueString={String(amount)} convertToUnit={settings.unit} showBalance={true} />
)}
</rb.Col>
</rb.Row>
{isCoinjoin && (
<rb.Row>
<rb.Col xs={4} md={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_num_collaborators')}</strong>
</rb.Col>
<rb.Col xs={8} md={9} className="text-start">
{numCollaborators}
</rb.Col>
</rb.Row>
)}
{estimatedMaxCollaboratorFee && (
<rb.Row>
<rb.Col xs={4} md={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_estimated_max_collaborator_fee')}</strong>
</rb.Col>
<rb.Col xs={8} md={9} className="d-inline-flex align-items-center text-start">
<div>
&le;
<Balance
valueString={`${estimatedMaxCollaboratorFee}`}
convertToUnit={settings.unit}
showBalance={true}
/>
<rb.OverlayTrigger
placement="right"
overlay={
<rb.Popover>
<rb.Popover.Body>
{t('send.confirm_send_modal.text_estimated_max_collaborator_fee_info_popover')}
</rb.Popover.Body>
</rb.Popover>
}
>
<div className="d-inline-flex align-items-center">
<Sprite className={styles.infoIcon} symbol="info" width="13" height="13" />
</div>
</rb.OverlayTrigger>
</div>
</rb.Col>
</rb.Row>
)}
{miningFeeText && (
<rb.Row>
<rb.Col xs={4} md={3} className="text-end">
<strong>{t('send.confirm_send_modal.label_miner_fee')}</strong>
</rb.Col>
<rb.Col xs={8} md={9} className="text-start">
{miningFeeText}
</rb.Col>
</rb.Row>
)}
{availableUtxos.length > 0 && (
<ReviewUtxos settings={settings} availableUtxos={availableUtxos} isSweep={isSweep} />
)}
{children && (
<rb.Row>
<rb.Col xs={12}>{children}</rb.Col>
</rb.Row>
)}
</rb.Container>
</ConfirmModal>
)
}

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