├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── build.yml │ └── codeql-analysis.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .npmrc ├── .nvmrc ├── .tx └── transifex.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docker └── regtest │ ├── .env.example │ ├── .gitignore │ ├── common.sh │ ├── docker-compose-common.yml │ ├── docker-compose.yml │ ├── dockerfile-deps │ ├── bitcoin │ │ └── regtest-initializer │ │ │ ├── Dockerfile │ │ │ ├── entrypoint.sh │ │ │ ├── mine-blocks.sh │ │ │ ├── wait-for-bitcoind.sh │ │ │ └── wait-for-blocks.sh │ ├── joinmarket │ │ ├── directory_node │ │ │ ├── Dockerfile │ │ │ ├── README.md │ │ │ ├── autostart │ │ │ ├── default.cfg │ │ │ ├── docker-entrypoint.sh │ │ │ ├── start-dn.py │ │ │ └── supervisor-conf │ │ │ │ ├── directory-node.conf │ │ │ │ └── supervisord.conf │ │ ├── latest │ │ │ ├── Dockerfile │ │ │ ├── autostart │ │ │ ├── default.cfg │ │ │ ├── jam-entrypoint.sh │ │ │ ├── supervisor-conf │ │ │ │ ├── jmwalletd.conf │ │ │ │ ├── ob-watcher.conf │ │ │ │ ├── supervisord.conf │ │ │ │ └── tor.conf │ │ │ └── torrc │ │ └── webui-standalone │ │ │ ├── Dockerfile │ │ │ └── default.cfg │ └── nginx │ │ └── default.conf.template │ ├── fund-wallet.sh │ ├── generate-onion-address.sh │ ├── init-setup.sh │ ├── mine-block.sh │ ├── prepare-setup.sh │ └── readme.md ├── docs ├── assets │ ├── citadel.png │ ├── mynode.png │ ├── raspiblitz-dark.svg │ ├── raspiblitz-light.svg │ ├── raspibolt.png │ ├── readme-header-dark.svg │ ├── readme-header-light.svg │ ├── screenshot-dark.png │ ├── screenshot-light.png │ ├── start9.png │ └── umbrel.svg ├── developing.md └── releasing.md ├── package-lock.json ├── package.json ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── browserconfig.xml ├── favicon.ico ├── favicon.svg ├── index.html ├── mstile-144x144.png ├── mstile-150x150.png ├── mstile-310x150.png ├── mstile-310x310.png ├── mstile-70x70.png ├── robots.txt ├── safari-pinned-tab.svg ├── site.webmanifest └── sprite.svg ├── scripts └── changelog.mjs ├── src ├── components │ ├── Accordion.tsx │ ├── ActivityIndicators.tsx │ ├── Alert.tsx │ ├── App.test.tsx │ ├── App.tsx │ ├── Balance.module.css │ ├── Balance.test.tsx │ ├── Balance.tsx │ ├── BitcoinAmountInput.test.tsx │ ├── BitcoinAmountInput.tsx │ ├── BitcoinQR.tsx │ ├── Cheatsheet.module.css │ ├── Cheatsheet.tsx │ ├── CoinjoinPreconditionViolationAlert.tsx │ ├── CopyButton.tsx │ ├── CreateWallet.test.tsx │ ├── CreateWallet.tsx │ ├── DevSetupPage.tsx │ ├── Divider.module.css │ ├── Divider.tsx │ ├── Earn.module.css │ ├── Earn.tsx │ ├── EarnReport.module.css │ ├── EarnReport.test.tsx │ ├── EarnReport.tsx │ ├── ErrorPage.tsx │ ├── ExtendedLink.tsx │ ├── Footer.tsx │ ├── ImportWallet.tsx │ ├── Jam.module.css │ ├── Jam.tsx │ ├── JarSelectorModal.module.css │ ├── JarSelectorModal.tsx │ ├── Jars.module.css │ ├── Jars.tsx │ ├── Layout.tsx │ ├── LogOverlay.module.css │ ├── LogOverlay.tsx │ ├── MainWalletView.module.css │ ├── MainWalletView.tsx │ ├── MnemonicPhraseInput.tsx │ ├── MnemonicWordInput.module.css │ ├── MnemonicWordInput.test.tsx │ ├── MnemonicWordInput.tsx │ ├── Modal.module.css │ ├── Modal.tsx │ ├── Navbar.module.css │ ├── Navbar.tsx │ ├── Onboarding.module.css │ ├── Onboarding.tsx │ ├── Orderbook.module.css │ ├── Orderbook.tsx │ ├── PageTitle.tsx │ ├── PaymentConfirmModal.module.css │ ├── PaymentConfirmModal.tsx │ ├── PreventLeavingPageByMistake.tsx │ ├── Receive.module.css │ ├── Receive.tsx │ ├── RescanChain.tsx │ ├── ScheduleProgress.module.css │ ├── ScheduleProgress.tsx │ ├── Seedphrase.module.css │ ├── Seedphrase.tsx │ ├── SegmentedTabs.module.css │ ├── SegmentedTabs.tsx │ ├── Send │ │ ├── AmountInputField.module.css │ │ ├── AmountInputField.tsx │ │ ├── CollaboratorsSelector.module.css │ │ ├── CollaboratorsSelector.tsx │ │ ├── DestinationInputField.tsx │ │ ├── FeeBreakdown.tsx │ │ ├── SendForm.module.css │ │ ├── SendForm.tsx │ │ ├── ShowUtxos.module.css │ │ ├── ShowUtxos.tsx │ │ ├── SourceJarSelector.module.css │ │ ├── SourceJarSelector.tsx │ │ ├── SweepBreakdown.module.css │ │ ├── SweepBreakdown.tsx │ │ ├── helpers.ts │ │ └── index.tsx │ ├── Settings.module.css │ ├── Settings.test.tsx │ ├── Settings.tsx │ ├── ShareButton.tsx │ ├── Sprite.tsx │ ├── TablePagination.module.css │ ├── TablePagination.tsx │ ├── ToggleSwitch.module.css │ ├── ToggleSwitch.tsx │ ├── Wallet.module.css │ ├── Wallet.test.tsx │ ├── Wallet.tsx │ ├── WalletCreationConfirmation.tsx │ ├── WalletCreationForm.module.css │ ├── WalletCreationForm.tsx │ ├── Wallets.test.tsx │ ├── Wallets.tsx │ ├── fb │ │ ├── CreateFidelityBond.module.css │ │ ├── CreateFidelityBond.tsx │ │ ├── ExistingFidelityBond.module.css │ │ ├── ExistingFidelityBond.tsx │ │ ├── FidelityBondSteps.module.css │ │ ├── FidelityBondSteps.tsx │ │ ├── LockdateForm.test.tsx │ │ ├── LockdateForm.tsx │ │ ├── SpendFidelityBondModal.module.css │ │ ├── SpendFidelityBondModal.tsx │ │ ├── utils.test.ts │ │ └── utils.ts │ ├── jar_details │ │ ├── DisplayBranch.module.css │ │ ├── DisplayBranch.tsx │ │ ├── JarDetailsOverlay.module.css │ │ ├── JarDetailsOverlay.tsx │ │ ├── UtxoDetailModal.module.css │ │ ├── UtxoDetailModal.tsx │ │ ├── UtxoList.module.css │ │ └── UtxoList.tsx │ ├── jars │ │ ├── Jar.module.css │ │ └── Jar.tsx │ ├── settings │ │ ├── FeeConfigModal.module.css │ │ ├── FeeConfigModal.tsx │ │ ├── SeedModal.tsx │ │ └── TxFeeInputField.tsx │ └── utxo │ │ ├── Confirmations.module.css │ │ ├── Confirmations.tsx │ │ ├── UtxoIcon.module.css │ │ ├── UtxoIcon.tsx │ │ ├── UtxoTags.module.css │ │ ├── UtxoTags.tsx │ │ └── utils.ts ├── constants │ ├── bip39words.ts │ ├── debugFeatures.ts │ ├── features.ts │ ├── jam.ts │ ├── jm.ts │ └── routes.ts ├── context │ ├── BalanceSummary.test.tsx │ ├── BalanceSummary.ts │ ├── ServiceConfigContext.tsx │ ├── ServiceInfoContext.tsx │ ├── SettingsContext.tsx │ ├── WalletContext.tsx │ └── WebsocketContext.tsx ├── fonts │ ├── Inter-Black.woff │ ├── Inter-Black.woff2 │ ├── Inter-BlackItalic.woff │ ├── Inter-BlackItalic.woff2 │ ├── Inter-Bold.woff │ ├── Inter-Bold.woff2 │ ├── Inter-BoldItalic.woff │ ├── Inter-BoldItalic.woff2 │ ├── Inter-ExtraBold.woff │ ├── Inter-ExtraBold.woff2 │ ├── Inter-ExtraBoldItalic.woff │ ├── Inter-ExtraBoldItalic.woff2 │ ├── Inter-ExtraLight.woff │ ├── Inter-ExtraLight.woff2 │ ├── Inter-ExtraLightItalic.woff │ ├── Inter-ExtraLightItalic.woff2 │ ├── Inter-Italic.woff │ ├── Inter-Italic.woff2 │ ├── Inter-Light.woff │ ├── Inter-Light.woff2 │ ├── Inter-LightItalic.woff │ ├── Inter-LightItalic.woff2 │ ├── Inter-Medium.woff │ ├── Inter-Medium.woff2 │ ├── Inter-MediumItalic.woff │ ├── Inter-MediumItalic.woff2 │ ├── Inter-Regular.woff │ ├── Inter-Regular.woff2 │ ├── Inter-SemiBold.woff │ ├── Inter-SemiBold.woff2 │ ├── Inter-SemiBoldItalic.woff │ ├── Inter-SemiBoldItalic.woff2 │ ├── Inter-Thin.woff │ ├── Inter-Thin.woff2 │ ├── Inter-ThinItalic.woff │ ├── Inter-ThinItalic.woff2 │ ├── Inter-italic.var.woff2 │ ├── Inter-roman.var.woff2 │ ├── Inter.var.woff2 │ └── SatoshiSymbol.otf ├── globals.d.ts ├── hooks │ ├── CoinjoinRequirements.test.ts │ ├── CoinjoinRequirements.ts │ ├── Fees.test.ts │ ├── Fees.ts │ └── WaitForUtxosToBeSpent.ts ├── i18n │ ├── README.md │ ├── config.ts │ ├── languages.ts │ ├── locales │ │ ├── de │ │ │ └── translation.json │ │ ├── en │ │ │ └── translation.json │ │ ├── fr │ │ │ └── translation.json │ │ ├── it │ │ │ └── translation.json │ │ ├── pt_BR │ │ │ └── translation.json │ │ ├── ru │ │ │ └── translation.json │ │ ├── zh_Hans │ │ │ └── translation.json │ │ └── zh_Hant │ │ │ └── translation.json │ └── testConfig.ts ├── index.css ├── index.tsx ├── libs │ ├── JamApi.ts │ ├── JmObwatchApi.ts │ └── JmWalletApi.ts ├── session.ts ├── setupProxy.js ├── setupTests.ts ├── testUtils.tsx ├── utils.test.ts └── utils.ts ├── tsconfig.json └── typings.d.ts /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Expected behavior** 11 | 12 | A clear and concise description of what you expected to happen. 13 | 14 | **Actual behavior** 15 | 16 | A clear and concise description of what the bug is and what actually happens. 17 | 18 | **Steps to reproduce the problem** 19 | 1. 20 | 2. 21 | 3. 22 | 23 | **Specifications** 24 | - Version: 25 | - Platform: 26 | - Browser: 27 | 28 | **Additional context** 29 | 30 | Add any other context about the problem here to help explain your problem. 31 | e.g. error logs or screenshots if applicable. Make sure to remove any confidential information before sharing screenshots or logs. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | A clear and concise description of what the problem is. Example: I'm always frustrated when [...] 13 | 14 | **Describe the solution you'd like** 15 | 16 | A clear and concise description of what you want to happen. 17 | 18 | **Describe alternatives you've considered** 19 | 20 | A clear and concise description of any alternative solutions or features you've considered. 21 | 22 | **Additional context** 23 | 24 | Add any other context or screenshots about the feature request here. 25 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | # Run the build for pushes and pull requests targeting master and devel 5 | push: 6 | branches: 7 | - master 8 | - devel 9 | pull_request: 10 | branches: 11 | - master 12 | - devel 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | node-version: ['v22.11.0'] 20 | 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | # Setup Node 25 | - name: Setup (Node.js ${{ matrix.node-version }}) 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | # Install 30 | - name: Install 31 | run: npm ci 32 | # Checks 33 | - name: Lint 34 | run: npm run lint 35 | # Test 36 | - name: Test 37 | run: npm test 38 | # Build 39 | - name: Build 40 | run: npm run build 41 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '32 10 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v3 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v3 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | 23 | .idea/ 24 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | 2 | npx lint-staged 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | git-tag-version = false 2 | engine-strict = true 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.11.0 2 | -------------------------------------------------------------------------------- /.tx/transifex.yml: -------------------------------------------------------------------------------- 1 | git: 2 | filters: 3 | - filter_type: file 4 | file_format: KEYVALUEJSON 5 | source_file: src/i18n/locales/en/translation.json 6 | source_language: en 7 | translation_files_expression: src/i18n/locales//translation.json 8 | settings: 9 | pr_branch_name: tx_translations_ 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 2 | 3 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 6 | -------------------------------------------------------------------------------- /docker/regtest/.env.example: -------------------------------------------------------------------------------- 1 | JM_DIRECTORY_NODES=replaceme 2 | 3 | -------------------------------------------------------------------------------- /docker/regtest/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | out/ 3 | .tmp/ 4 | .env.generated 5 | -------------------------------------------------------------------------------- /docker/regtest/docker-compose-common.yml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | 4 | joinmarket_native: 5 | build: 6 | context: ./dockerfile-deps/joinmarket/latest 7 | dockerfile: Dockerfile 8 | restart: unless-stopped 9 | environment: 10 | ENSURE_WALLET: "true" 11 | REMOVE_LOCK_FILES: "true" 12 | jm_blockchain_source: regtest 13 | jm_network: testnet 14 | jm_rpc_host: bitcoind 15 | jm_rpc_port: 43782 16 | jm_directory_nodes: ${JM_DIRECTORY_NODES:?You must set the onion address generated in prepare step to your .env file} 17 | jm_minimum_makers: 1 # necessary to do coinjoins with this regtest setup; default is 4 18 | jm_taker_utxo_age: 1 # faster testing of scheduler runs; default is 5 19 | jm_maker_timeout_sec: 30 # easier testing of maker timeouts on regtest (and "Stall Monitor" retries); default is 60 20 | expose: 21 | - 62601 # obwatch 22 | - 28183 # jmwalletd api 23 | - 28283 # jmwalletd websocket 24 | healthcheck: 25 | test: ["CMD", "supervisorctl", "status"] 26 | interval: 10s 27 | timeout: 10s 28 | retries: 20 29 | start_period: 60s 30 | start_interval: 3s 31 | 32 | joinmarket_jam_standalone: 33 | build: 34 | context: ./dockerfile-deps/joinmarket/webui-standalone 35 | dockerfile: Dockerfile 36 | restart: unless-stopped 37 | environment: 38 | ENSURE_WALLET: "true" 39 | REMOVE_LOCK_FILES: "true" 40 | jm_blockchain_source: regtest 41 | jm_network: testnet 42 | jm_rpc_host: bitcoind 43 | jm_rpc_port: 43782 44 | jm_directory_nodes: ${JM_DIRECTORY_NODES:?You must set the onion address generated in prepare step to your .env file} 45 | jm_minimum_makers: 1 # necessary to do coinjoins with this regtest setup; default is 4 46 | jm_taker_utxo_age: 1 # faster testing of scheduler runs; default is 5 47 | expose: 48 | - 80 # nginx 49 | - 28183 # jmwalletd api 50 | healthcheck: 51 | test: [ "CMD", "dinitctl", "status", "jmwalletd" ] 52 | interval: 10s 53 | timeout: 10s 54 | retries: 20 55 | start_period: 60s 56 | start_interval: 3s 57 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/bitcoin/regtest-initializer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c 2 | 3 | # install build dependencies 4 | RUN apk add --no-cache --update curl jq 5 | 6 | COPY wait-for-bitcoind.sh /usr/local/bin/ 7 | RUN chmod +x /usr/local/bin/wait-for-bitcoind.sh 8 | 9 | COPY wait-for-blocks.sh /usr/local/bin/ 10 | RUN chmod +x /usr/local/bin/wait-for-blocks.sh 11 | 12 | COPY mine-blocks.sh /usr/local/bin/ 13 | RUN chmod +x /usr/local/bin/mine-blocks.sh 14 | 15 | COPY entrypoint.sh / 16 | RUN chmod +x /entrypoint.sh 17 | 18 | ENTRYPOINT [ "/entrypoint.sh" ] 19 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/bitcoin/regtest-initializer/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -Eeuo pipefail 3 | 4 | export _BTC_USER="${RPC_USER}:${RPC_PASSWORD}" 5 | export _BTC_URL="http://${RPC_HOST}:${RPC_PORT}" 6 | 7 | if [ -f "${READY_FILE}" ]; then 8 | echo "Removing $READY_FILE..." 9 | rm -f "${READY_FILE}" 10 | echo "Removed $READY_FILE." 11 | fi 12 | 13 | MINE_BLOCKS=101 14 | 15 | source /usr/local/bin/wait-for-bitcoind.sh 16 | source /usr/local/bin/mine-blocks.sh "${MINE_BLOCKS}" 17 | source /usr/local/bin/wait-for-blocks.sh "${MINE_BLOCKS}" 18 | 19 | if [ "${READY_FILE}" ] && [ ! -f "${READY_FILE}" ]; then 20 | echo "Creating $READY_FILE..." 21 | echo "1" > "${READY_FILE}" 22 | echo "Created $READY_FILE." 23 | fi 24 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/bitcoin/regtest-initializer/mine-blocks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -Eeuo pipefail 3 | 4 | BLOCKS=${1:-101} # default to mine a single block 5 | ADDRESS=${2:-bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx} # default to a "random" address 6 | 7 | echo "Mining ${BLOCKS} blocks to address ${ADDRESS}..." 8 | payload="{\ 9 | \"jsonrpc\":\"1.0\",\ 10 | \"id\":\"curl\",\ 11 | \"method\":\"generatetoaddress\",\ 12 | \"params\":[${BLOCKS},\"${ADDRESS}\"]\ 13 | }" 14 | curl --silent --user "${_BTC_USER}" --data-binary "${payload}" "${_BTC_URL}" > /dev/null 2>&1 15 | 16 | echo "Successfully mined ${BLOCKS} blocks to address ${ADDRESS}." 17 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/bitcoin/regtest-initializer/wait-for-bitcoind.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -Eeuo pipefail 3 | 4 | source /usr/local/bin/wait-for-blocks.sh 0 5 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/bitcoin/regtest-initializer/wait-for-blocks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -Eeuo pipefail 3 | 4 | BLOCKS=${1:-0} # wait for x amount of blocks 5 | 6 | echo "Waiting for bitcoind to report at least ${BLOCKS} blocks..." 7 | payload="{\ 8 | \"jsonrpc\":\"1.0\",\ 9 | \"id\":\"curl\",\ 10 | \"method\":\"getblockchaininfo\",\ 11 | \"params\":[]\ 12 | }" 13 | until curl --silent --user "${_BTC_USER}" --data-binary "${payload}" "${_BTC_URL}" | jq -e ".result.blocks >= ${BLOCKS}" > /dev/null 2>&1 14 | do 15 | echo -n "." 16 | sleep 1 17 | done 18 | echo "Successfully waited for ${BLOCKS} blocks to be reported." 19 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/joinmarket/directory_node/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim-bookworm@sha256:614c8691ab74150465ec9123378cd4dde7a6e57be9e558c3108df40664667a4c 2 | 3 | RUN apt-get update \ 4 | && apt-get install -qq --no-install-recommends gnupg tini procps vim git iproute2 supervisor \ 5 | # joinmarket dependencies 6 | curl build-essential automake pkg-config libtool libltdl-dev \ 7 | tor \ 8 | && rm -rf /var/lib/apt/lists/* 9 | 10 | ENV REPO=https://github.com/JoinMarket-Org/joinmarket-clientserver 11 | ENV REPO_BRANCH=master 12 | ENV REPO_REF=master 13 | 14 | WORKDIR /src 15 | RUN git clone "$REPO" . --depth=10 --branch "$REPO_BRANCH" && git checkout "$REPO_REF" 16 | 17 | RUN ./install.sh --docker-install --disable-os-deps-check --disable-secp-check --without-qt 18 | 19 | ENV DATADIR=/root/.joinmarket 20 | ENV CONFIG=${DATADIR}/joinmarket.cfg 21 | ENV DEFAULT_CONFIG=/root/default.cfg 22 | ENV DEFAULT_AUTO_START=/root/autostart 23 | ENV AUTO_START=${DATADIR}/autostart 24 | ENV PATH=/src/scripts:$PATH 25 | 26 | WORKDIR /src/scripts 27 | 28 | COPY start-dn.py . 29 | 30 | COPY autostart ${DEFAULT_AUTO_START} 31 | COPY default.cfg ${DEFAULT_CONFIG} 32 | COPY supervisor-conf/*.conf /etc/supervisor/conf.d/ 33 | 34 | COPY docker-entrypoint.sh / 35 | RUN chmod +x /docker-entrypoint.sh 36 | 37 | # payjoin server 38 | EXPOSE 8082 39 | # obwatch 40 | EXPOSE 62601 41 | # joinmarketd daemon 42 | EXPOSE 27183 43 | # jmwalletd api 44 | EXPOSE 28183 45 | # jmwalletd websocket 46 | EXPOSE 28283 47 | 48 | ENTRYPOINT [ "tini", "-g", "--", "/docker-entrypoint.sh" ] 49 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/joinmarket/directory_node/README.md: -------------------------------------------------------------------------------- 1 | Starts a staging joinmarket directory node that you can point to in your regtest configurations. 2 | 3 | - start-dn.py taken from https://github.com/JoinMarket-Org/custom-scripts/blob/e3c5fb548c704fc56cdaa869705797955a9821dd/start-dn.py 4 | (might already be in master - last check on 2022-05-11) 5 | 6 | You must mount the directory specified in `hidden_service_dir`, which contains hostname, public and private key, 7 | and provide the correct onion hostname via `directory_nodes` yourself! 8 | 9 | e.g. in a docker-compose setup: 10 | ```yml 11 | joinmarket_directory_node: 12 | [...] 13 | environment: 14 | jm_hidden_service_dir: \/root\/.joinmarket\/hidden_service_dir 15 | jm_directory_nodes: jamobvon3jdgjnvuxur7vpdr6hzwrxhu5li3pnannfrrrupg5sb6ouyd.onion:5222 16 | volumes: 17 | - "./my-hidden_service_dir:/root/.joinmarket/hidden_service_dir:z" 18 | ``` 19 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/joinmarket/directory_node/autostart: -------------------------------------------------------------------------------- 1 | # Remove comments in front of the services you want to automatically restart 2 | # when the container restarts. 3 | 4 | directory-node 5 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/joinmarket/directory_node/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # ensure 'log' directory exists 5 | mkdir --parents /var/log/jam 6 | 7 | # First we restore the default cfg as created by wallet-tool.py generate 8 | if [ ! -f "$CONFIG" ]; then 9 | cp "$DEFAULT_CONFIG" "$CONFIG" 10 | fi 11 | 12 | if [ ! -f "$AUTO_START" ]; then 13 | cp "$DEFAULT_AUTO_START" "$AUTO_START" 14 | fi 15 | 16 | # generate ssl certificates for jmwalletd 17 | if [ ! -f "${DATADIR}/ssl/key.pem" ]; then 18 | subj="/C=US/ST=Utah/L=Lehi/O=Your Company, Inc./OU=IT/CN=example.com" 19 | mkdir --parents "${DATADIR}/ssl/" \ 20 | && pushd "$_" \ 21 | && openssl req -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem -subj "$subj" \ 22 | && popd 23 | fi 24 | 25 | # auto start services 26 | while read -r p; do 27 | [[ "$p" == "" ]] && continue 28 | [[ "$p" == "#"* ]] && continue 29 | echo "Auto start: $p" 30 | file_path="/etc/supervisor/conf.d/$p.conf" 31 | if [ -f "$file_path" ]; then 32 | sed -i 's/autostart=false/autostart=true/g' "$file_path" 33 | else 34 | echo "$file_path not found" 35 | fi 36 | done < "$AUTO_START" 37 | 38 | declare -A jmenv 39 | while IFS='=' read -r -d '' envkey parsedval; do 40 | n="${envkey,,}" # lowercase 41 | if [[ "$n" = jm_* ]]; then 42 | n="${n:3}" # drop jm_ 43 | jmenv[$n]=${!envkey} # reread environment variable - characters might have been dropped (e.g 'ending in =') 44 | fi 45 | done < <(env -0) 46 | 47 | # adapt 'blockchain_source' if missing and we're in regtest mode 48 | if [ "${jmenv['network']}" = "regtest" ] && [ "${jmenv['blockchain_source']}" = "" ]; then 49 | jmenv['blockchain_source']='regtest' 50 | fi 51 | 52 | # there is no 'regtest' value for config 'network': make sure to use "testnet" in regtest mode 53 | if [ "${jmenv['network']}" = "regtest" ]; then 54 | jmenv['network']='testnet' 55 | fi 56 | 57 | # ---------- Hidden Service Directory 58 | # Avoid preventing user provided files: Copy the contents of the mounted directory to an own directory 59 | jmenv['hidden_service_dir']=${jmenv['hidden_service_dir']:-'\/root\/.joinmarket\/hidden_service_dir'} 60 | hsdirEscapedUnsafe="${jmenv['hidden_service_dir']}" 61 | hsdirEscapedSafe="${hsdirEscapedUnsafe}__copy" 62 | jmenv['hidden_service_dir']="${hsdirEscapedSafe}" 63 | 64 | hsdirUnsafe=$(sed "s/^.*/${hsdirEscapedUnsafe}/g" <<< '') 65 | hsdirSafe=$(sed "s/^.*/${hsdirEscapedSafe}/g" <<< '') 66 | 67 | mkdir --parents "${hsdirUnsafe}" 68 | rm --force --recursive "${hsdirSafe}" 69 | cp --recursive "${hsdirUnsafe}" "${hsdirSafe}" 70 | chown root:root --recursive "${hsdirSafe}" 71 | # this is important because tor is very finicky about permissions 72 | # see https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/68c64e135dabafca8ed78202ace1ced1884684be/docs/onion-message-channels.md#joinmarket-specific-configuration 73 | chmod 600 --recursive "${hsdirSafe}" 74 | # ---------- Hidden Service Directory - End 75 | 76 | # For every env variable JM_FOO=BAR, replace the default configuration value of 'foo' by 'BAR' 77 | for key in "${!jmenv[@]}"; do 78 | val=${jmenv[${key}]} 79 | sed -i "s/^$key =.*/$key = $val/g" "$CONFIG" || echo "Couldn't set : $key = $val, please modify $CONFIG manually" 80 | done 81 | 82 | exec supervisord 83 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/joinmarket/directory_node/supervisor-conf/directory-node.conf: -------------------------------------------------------------------------------- 1 | [program:directory-node] 2 | directory=/src/scripts 3 | command=python3 start-dn.py datadir=/root/.joinmarket 4 | autostart=false 5 | stdout_logfile=/var/log/jam/directory_node_stdout.log 6 | stdout_logfile_maxbytes=1MB 7 | stdout_logfile_backups=5 8 | stderr_logfile=/var/log/jam/directory_node_stderr.log 9 | stderr_logfile_maxbytes=1MB 10 | stderr_logfile_backups=5 11 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/joinmarket/directory_node/supervisor-conf/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/joinmarket/latest/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim-bookworm@sha256:614c8691ab74150465ec9123378cd4dde7a6e57be9e558c3108df40664667a4c 2 | 3 | RUN apt-get update \ 4 | && apt-get install -qq --no-install-recommends gnupg tini procps vim git iproute2 supervisor \ 5 | # joinmarket dependencies 6 | curl build-essential automake pkg-config libtool libltdl-dev \ 7 | tor \ 8 | && rm -rf /var/lib/apt/lists/* 9 | 10 | ENV REPO=https://github.com/JoinMarket-Org/joinmarket-clientserver 11 | ENV REPO_BRANCH=master 12 | ENV REPO_REF=master 13 | 14 | WORKDIR /src 15 | RUN git clone "$REPO" . --depth=10 --branch "$REPO_BRANCH" && git checkout "$REPO_REF" 16 | 17 | RUN ./install.sh --docker-install --disable-os-deps-check --disable-secp-check --without-qt 18 | 19 | ENV DATADIR=/root/.joinmarket 20 | ENV CONFIG=${DATADIR}/joinmarket.cfg 21 | ENV DEFAULT_CONFIG=/root/default.cfg 22 | ENV DEFAULT_AUTO_START=/root/autostart 23 | ENV AUTO_START=${DATADIR}/autostart 24 | ENV PATH=/src/scripts:$PATH 25 | 26 | WORKDIR /src/scripts 27 | 28 | COPY torrc /etc/tor/torrc 29 | COPY autostart ${DEFAULT_AUTO_START} 30 | COPY default.cfg ${DEFAULT_CONFIG} 31 | COPY supervisor-conf/*.conf /etc/supervisor/conf.d/ 32 | 33 | COPY jam-entrypoint.sh / 34 | RUN chmod +x /jam-entrypoint.sh 35 | 36 | # payjoin server 37 | EXPOSE 8082 38 | # obwatch 39 | EXPOSE 62601 40 | # joinmarketd daemon 41 | EXPOSE 27183 42 | # jmwalletd api 43 | EXPOSE 28183 44 | # jmwalletd websocket 45 | EXPOSE 28283 46 | 47 | ENTRYPOINT [ "tini", "-g", "--", "/jam-entrypoint.sh" ] 48 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/joinmarket/latest/autostart: -------------------------------------------------------------------------------- 1 | # Remove comments in front of the services you want to automatically restart 2 | # when the container restarts. 3 | 4 | tor 5 | ob-watcher 6 | jmwalletd 7 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/joinmarket/latest/supervisor-conf/jmwalletd.conf: -------------------------------------------------------------------------------- 1 | [program:jmwalletd] 2 | directory=/src/scripts 3 | command=python3 jmwalletd.py 4 | autostart=false 5 | stdout_logfile=/var/log/jam/jmwalletd_stdout.log 6 | stdout_logfile_maxbytes=1MB 7 | stdout_logfile_backups=5 8 | stderr_logfile=/var/log/jam/jmwalletd_stderr.log 9 | stderr_logfile_maxbytes=1MB 10 | stderr_logfile_backups=5 11 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/joinmarket/latest/supervisor-conf/ob-watcher.conf: -------------------------------------------------------------------------------- 1 | [program:ob-watcher] 2 | directory=/src/scripts/obwatch 3 | command=python3 ob-watcher.py --host 0.0.0.0 4 | autostart=false 5 | stdout_logfile=/var/log/jam/obwatch_stdout.log 6 | stdout_logfile_maxbytes=1MB 7 | stdout_logfile_backups=5 8 | stderr_logfile=/var/log/jam/obwatch_stderr.log 9 | stderr_logfile_maxbytes=1MB 10 | stderr_logfile_backups=5 11 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/joinmarket/latest/supervisor-conf/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | user=root 4 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/joinmarket/latest/supervisor-conf/tor.conf: -------------------------------------------------------------------------------- 1 | [program:tor] 2 | command=/usr/sbin/tor -f /etc/tor/torrc 3 | autostart=false 4 | stdout_logfile=/var/log/jam/tor_stdout.log 5 | stdout_logfile_maxbytes=1MB 6 | stdout_logfile_backups=5 7 | stderr_logfile=/var/log/jam/tor_stderr.log 8 | stderr_logfile_maxbytes=1MB 9 | stderr_logfile_backups=5 10 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/joinmarket/latest/torrc: -------------------------------------------------------------------------------- 1 | # Default JoinMarket Tor configuration 2 | # taken from v0.9.6 on 2022-07-18: 3 | # https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/v0.9.6/install.sh#L388-L393 4 | Log warn stderr 5 | SOCKSPort 9050 IsolateDestAddr IsolateDestPort 6 | ControlPort 9051 7 | CookieAuthentication 1 8 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/joinmarket/webui-standalone/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/joinmarket-webui/jam-dev-standalone:master 2 | 3 | COPY default.cfg ${DEFAULT_CONFIG} 4 | 5 | ENTRYPOINT [ "tini", "-g", "--", "/jam-entrypoint.sh" ] 6 | -------------------------------------------------------------------------------- /docker/regtest/dockerfile-deps/nginx/default.conf.template: -------------------------------------------------------------------------------- 1 | # To test the reverse proxy setup, run the dev server using: 2 | # 3 | # PUBLIC_URL=/joinmarket npm run dev 4 | # 5 | # And visit http://localhost:8000/joinmarket/extrapath/ 6 | # Note: Keep the trailing slash! 7 | 8 | server { 9 | listen 80; 10 | 11 | location /joinmarket/extrapath/jmws { 12 | proxy_http_version 1.1; 13 | 14 | proxy_set_header Upgrade $http_upgrade; 15 | proxy_set_header Connection "upgrade"; 16 | proxy_set_header Authorization ""; 17 | 18 | # allow 10m without socket activity (default is 60 sec) 19 | proxy_read_timeout 600s; 20 | proxy_send_timeout 600s; 21 | 22 | proxy_pass http://host.docker.internal:3000/joinmarket/jmws; 23 | } 24 | 25 | location /joinmarket/extrapath { 26 | proxy_pass http://host.docker.internal:3000/joinmarket; 27 | } 28 | 29 | location /joinmarket { 30 | proxy_pass http://host.docker.internal:3000/joinmarket; 31 | } 32 | 33 | location = /health { 34 | default_type application/json; 35 | return 200 '{ "status": "UP" }'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /docker/regtest/generate-onion-address.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ### 4 | # 5 | # This script will generate keys and hostname to be used in a tor hidden 6 | # service setup. The output files are placed in a given target directory 7 | # or in a subdirectory of the current working directory if not specified. 8 | # 9 | ### 10 | 11 | set -Eeuo pipefail 12 | trap cleanup SIGINT SIGTERM ERR EXIT 13 | 14 | cleanup() { 15 | trap - SIGINT SIGTERM ERR EXIT 16 | # script cleanup here 17 | } 18 | 19 | if ! command -v git &> /dev/null; then 20 | die "This script needs 'git' to run. Consider installing it." 21 | fi 22 | if ! command -v docker &> /dev/null; then 23 | die "This script needs 'docker' to run. Consider installing it." 24 | fi 25 | 26 | SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P) 27 | 28 | TARGETDIR="${1:-"${PWD}/generate-onion-address/out"}" 29 | WORKDIR="${2:-"${PWD}/generate-onion-address/work"}" 30 | VANITYTORGEN_REPO_DIR="${WORKDIR}/vanitytorgen" 31 | # TODO: fork of "https://github.com/Kexkey/vanitytorgen" - should be forked by jam org? 32 | VANITYTORGEN_REPO_URL="https://github.com/theborakompanioni/vanitytorgen" 33 | VANITYTORGEN_REPO_BRANCH="main" 34 | VANITYTORGEN_REPO_REF="f497346ab540153f06edabe65df37ae5536a2d9a" 35 | 36 | # onion addresses are base32 - base32 alphabet allows letters [a-z] and digits [2-7] 37 | PREFIX_CHARS="234567abcdefghijklmnopqrstuvwxyz" # "jam" 38 | 39 | # choose a single random char 40 | RANDOM_PREFIX_CHAR_INDEX=$(($RANDOM % ${#PREFIX_CHARS})) 41 | ONION_ADDRESS_PREFIX="${PREFIX_CHARS:$RANDOM_PREFIX_CHAR_INDEX:1}" 42 | 43 | echo "Will use prefix: ${ONION_ADDRESS_PREFIX}" 44 | 45 | mkdir -p "$TARGETDIR" 46 | mkdir -p "$WORKDIR" 47 | 48 | # download "vanitygen" repo if necessary 49 | if ! [ -d "${VANITYTORGEN_REPO_DIR}" ]; then 50 | git clone "${VANITYTORGEN_REPO_URL}" "${VANITYTORGEN_REPO_DIR}" --branch "${VANITYTORGEN_REPO_BRANCH}" \ 51 | && git --work-tree="${VANITYTORGEN_REPO_DIR}" --git-dir="${VANITYTORGEN_REPO_DIR}/.git" checkout "$VANITYTORGEN_REPO_REF" \ 52 | && rm -rf "${VANITYTORGEN_REPO_DIR}/.git" 53 | fi 54 | 55 | # build and run "vanitygen" docker container 56 | docker build --tag jam_regtest_vanitytorgen "${VANITYTORGEN_REPO_DIR}" 57 | docker run --rm --volume "$TARGETDIR:/out:z" jam_regtest_vanitytorgen "${ONION_ADDRESS_PREFIX}" /out 58 | -------------------------------------------------------------------------------- /docker/regtest/init-setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ### 4 | # 5 | # This script helps initializing the JoinMarket docker containers. 6 | # Its main goal is to make CoinJoin transactions possible in the regtest environment. 7 | # 8 | # It has two responsibilities: 9 | # - funding wallets in all containers with some coins 10 | # - starting the maker service in the secondary and tertiary container 11 | # 12 | ### 13 | 14 | set -Eeuo pipefail 15 | 16 | script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P) 17 | 18 | # fund wallet in primary JoinMarket container 19 | . "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --unmatured --blocks 3 20 | 21 | # fund wallet in secondary and tertiary JoinMarket container. 22 | # these will get more coins than the primary one in order to have enough liquidity 23 | # to run the scheduler (scheduled sweep) successfully multiple times. 24 | . "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket2 --unmatured --blocks 50 25 | . "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket3 --unmatured --blocks 50 26 | 27 | # fund addresses of seed 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' 28 | # this is useful if you "import an existing wallet" and verify rescanning the chain works as expected. 29 | dummy_wallet_address1='bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk' # 1st address of jar A (m/84'/1'/0'/0/0) 30 | dummy_wallet_address2='bcrt1qt5yxk3xzrx66q9wd5sdyynklqynqcyf7uh74j3' # 8th address of jar C (m/84'/1'/2'/0/7) 31 | dummy_wallet_address3='bcrt1qn8804dw5fahuc5cwqteuq5j4xlhk2cnkq7a8kw' # 21st change address of jar E (m/84'/1'/4'/1/21) 32 | # make block rewards spendable: 100 + 5 (default of `taker_utxo_age`) + 1 = 106 33 | . "$script_dir/mine-block.sh" 2 "$dummy_wallet_address1" &>/dev/null 34 | . "$script_dir/mine-block.sh" 2 "$dummy_wallet_address2" &>/dev/null 35 | . "$script_dir/mine-block.sh" 2 "$dummy_wallet_address3" &>/dev/null 36 | . "$script_dir/mine-block.sh" 100 &>/dev/null 37 | 38 | start_maker() { 39 | local base_url; base_url=${1:-} 40 | local wallet_name; wallet_name=${2:-} 41 | local wallet_password; wallet_password=${3:-} 42 | 43 | # Check if maker service is not yet running 44 | verify_no_open_session_or_throw "$base_url" 45 | 46 | local maker_running; maker_running=$(is_maker_running "$base_url") 47 | 48 | if [ "$maker_running" != false ]; then 49 | msg_success "Maker is already running" 50 | else 51 | # Unlock wallet 52 | local unlock_result; unlock_result=$(unlock_wallet "$base_url" "$wallet_name" "$wallet_password") 53 | 54 | local auth_token; auth_token=$(jq -r '.token' <<< "$unlock_result") 55 | local auth_header; auth_header="Authorization: Bearer $auth_token" 56 | 57 | # -------------------------- 58 | # Start maker 59 | # -------------------------- 60 | ## API: POST /api/v1/wallet/$wallet_name/maker/start 61 | ## 62 | ## Response: 63 | ## 200 OK 64 | ## {} 65 | msg "Starting maker service for wallet $wallet_name.." 66 | local start_maker_request_payload; start_maker_request_payload="{\"txfee\":\"0\",\"cjfee_a\":\"250\",\"cjfee_r\":\"0.0003\",\"ordertype\":\"sw0absoffer\",\"minsize\":\"1\"}" 67 | 68 | local start_maker_result; start_maker_result=$(curl "$base_url/api/v1/wallet/$wallet_name/maker/start" --silent --show-error --insecure -H "$auth_header" --data "$start_maker_request_payload" | jq ".") 69 | 70 | if [ "$start_maker_result" != "{}" ]; then 71 | msg_warn "There has been a problem starting the maker service: $start_maker_result" 72 | else 73 | msg_success "Successfully started maker for wallet $wallet_name." 74 | fi 75 | 76 | # do not lock the wallet as this will terminate the maker service 77 | msg_warn "Wallet $wallet_name remains unlocked!" 78 | fi 79 | } 80 | 81 | msg "Attempt to start maker service for wallet $wallet_name in secondary container.." 82 | start_maker "https://localhost:29183" "Satoshi.jmdat" "test" 83 | 84 | msg "Attempt to start maker service for wallet $wallet_name in tertiary container.." 85 | start_maker "https://localhost:30183" "Satoshi.jmdat" "test" 86 | -------------------------------------------------------------------------------- /docker/regtest/mine-block.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -Eeuo pipefail 4 | 5 | die() { 6 | local code=${2-1} # default exit status 1 7 | echo >&2 -e "${1-}" 8 | exit "$code" 9 | } 10 | 11 | BLOCKS=${1:-1} # default to mine a single block 12 | ADDRESS=${2:-bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx} # default to a "random" address 13 | 14 | [ -z "${BLOCKS//[\-0-9]}" ] || die "Invalid parameter: 'blocks' must be an integer" 15 | [ "$BLOCKS" -ge 1 ] || die "Invalid parameter: 'blocks' must be a positve integer" 16 | [ -z "${ADDRESS-}" ] && die "Missing required parameter: 'address'" 17 | 18 | docker exec -t jm_regtest_bitcoind bitcoin-cli -datadir=/home/bitcoin/data -regtest -rpcport=43782 generatetoaddress "$BLOCKS" "$ADDRESS" 19 | -------------------------------------------------------------------------------- /docker/regtest/prepare-setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ### 4 | # 5 | # This script prepares the regtest environment. 6 | # 7 | # The output of this script is a ".env.generated" file 8 | # to be used in when running `docker compose`. 9 | # e.g. 10 | # ``` 11 | # docker compose --env-file .env.generated --file docker-compose.yml up 12 | # ``` 13 | # 14 | ### 15 | 16 | set -Eeuo pipefail 17 | 18 | die() { 19 | local code=${2-1} # default exit status 1 20 | echo >&2 -e "${1-}" 21 | exit "$code" 22 | } 23 | 24 | if ! command -v cat &> /dev/null; then 25 | die "This script needs 'cat' to run. Consider installing it." 26 | fi 27 | 28 | SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P) 29 | 30 | OUTPUT_FILE="$SCRIPT_DIR/.env.generated" 31 | 32 | # generate an onion address 33 | HS_SCRIPT_TARGET_DIR="${SCRIPT_DIR}/out/hidden_service_dir" 34 | HS_SCRIPT_WORK_DIR="${SCRIPT_DIR}/.tmp/generate-onion-address-work" 35 | . "$SCRIPT_DIR/generate-onion-address.sh" "${HS_SCRIPT_TARGET_DIR}" "${HS_SCRIPT_WORK_DIR}" 36 | 37 | 38 | ONION_ADDRESS=`cat ${HS_SCRIPT_TARGET_DIR}/hostname` 39 | 40 | if ! [[ "${ONION_ADDRESS}" == *.onion ]]; then 41 | die "Invalid argument: Could not find onion address in ${HS_SCRIPT_TARGET_DIR}/hostname" 42 | fi 43 | 44 | ONION_ADDRESS_WITH_PORT="${ONION_ADDRESS}:5222" 45 | 46 | cat < "${OUTPUT_FILE}" 47 | JM_DIRECTORY_NODES=${ONION_ADDRESS_WITH_PORT} 48 | 49 | EOF 50 | 51 | echo "Successfully written to ${OUTPUT_FILE}" 52 | -------------------------------------------------------------------------------- /docs/assets/citadel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joinmarket-webui/jam/6baf92ecf459e1c4bd6398a28abf3e01bd46c721/docs/assets/citadel.png -------------------------------------------------------------------------------- /docs/assets/mynode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joinmarket-webui/jam/6baf92ecf459e1c4bd6398a28abf3e01bd46c721/docs/assets/mynode.png -------------------------------------------------------------------------------- /docs/assets/raspiblitz-dark.svg: -------------------------------------------------------------------------------- 1 | RaspiBlitz_Logo_Icon 2 | -------------------------------------------------------------------------------- /docs/assets/raspiblitz-light.svg: -------------------------------------------------------------------------------- 1 | RaspiBlitz_Logo_Icon_Negative 2 | -------------------------------------------------------------------------------- /docs/assets/raspibolt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joinmarket-webui/jam/6baf92ecf459e1c4bd6398a28abf3e01bd46c721/docs/assets/raspibolt.png -------------------------------------------------------------------------------- /docs/assets/screenshot-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joinmarket-webui/jam/6baf92ecf459e1c4bd6398a28abf3e01bd46c721/docs/assets/screenshot-dark.png -------------------------------------------------------------------------------- /docs/assets/screenshot-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joinmarket-webui/jam/6baf92ecf459e1c4bd6398a28abf3e01bd46c721/docs/assets/screenshot-light.png -------------------------------------------------------------------------------- /docs/assets/start9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joinmarket-webui/jam/6baf92ecf459e1c4bd6398a28abf3e01bd46c721/docs/assets/start9.png -------------------------------------------------------------------------------- /docs/assets/umbrel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/releasing.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | This documents our release process. 4 | 5 | ## Update the Code 6 | 7 | Checkout `master` and make sure to pull all the latest changes that sould be included in the release. 8 | 9 | ## Bump the Version 10 | 11 | Use [`npm version`](https://docs.npmjs.com/cli/v6/commands/npm-version) to bump the version and update the changelog. 12 | Most of the time you will want to run: 13 | 14 | ``` 15 | npm version 16 | ``` 17 | 18 | For more details and other possible release types see [the npm docs](https://docs.npmjs.com/cli/v6/commands/npm-version). 19 | 20 | This will: 21 | 22 | 1. Create and checkout a branch for the release preparation: `prepare-v-` 23 | 1. Bump the version in `package.json` and `package.lock.json` 24 | 1. Update the changelog 25 | 1. Commit the changes and push the branch 26 | 1. If you have the [GitHub CLI](https://cli.github.com/) installed it will automatically open a _draft_ pull request to `master` 27 | 28 | ## Merge the Release Pull Request 29 | 30 | This is a good point to clean up the changelog if needed and get feedback on the release. 31 | When everyone is happy with the release, merge the pull request to `master`. 32 | 33 | ## Tag the Release 34 | 35 | Back on `master`, tag the release and based on the new tag crate a release on GitHub. 36 | This is a step we might automate completely via GitHub Actions in the future. 37 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joinmarket-webui/jam/6baf92ecf459e1c4bd6398a28abf3e01bd46c721/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joinmarket-webui/jam/6baf92ecf459e1c4bd6398a28abf3e01bd46c721/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joinmarket-webui/jam/6baf92ecf459e1c4bd6398a28abf3e01bd46c721/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #ffffff 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joinmarket-webui/jam/6baf92ecf459e1c4bd6398a28abf3e01bd46c721/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Jam for JoinMarket 15 | 36 | 37 | 38 | 39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /public/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joinmarket-webui/jam/6baf92ecf459e1c4bd6398a28abf3e01bd46c721/public/mstile-144x144.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joinmarket-webui/jam/6baf92ecf459e1c4bd6398a28abf3e01bd46c721/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joinmarket-webui/jam/6baf92ecf459e1c4bd6398a28abf3e01bd46c721/public/mstile-310x150.png -------------------------------------------------------------------------------- /public/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joinmarket-webui/jam/6baf92ecf459e1c4bd6398a28abf3e01bd46c721/public/mstile-310x310.png -------------------------------------------------------------------------------- /public/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joinmarket-webui/jam/6baf92ecf459e1c4bd6398a28abf3e01bd46c721/public/mstile-70x70.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "JoinMarket", 3 | "short_name": "JoinMarket", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /scripts/changelog.mjs: -------------------------------------------------------------------------------- 1 | import conventionalChangelog from 'conventional-changelog' 2 | import fs from 'fs' 3 | 4 | const START_OF_LAST_RELEASE_PATTERN = /(^#+ \[?[0-9]+\.[0-9]+\.[0-9]+| { 32 | return new Promise((resolve, reject) => { 33 | let oldContent = fs.readFileSync(file, 'utf-8') 34 | const oldContentStart = oldContent.search(START_OF_LAST_RELEASE_PATTERN) 35 | 36 | if (oldContentStart !== -1) { 37 | oldContent = oldContent.substring(oldContentStart) 38 | } 39 | 40 | let changelog = '' 41 | 42 | const changelogStream = conventionalChangelog( 43 | { 44 | preset: { 45 | name: 'conventionalcommits', 46 | header: header, 47 | types: types, 48 | }, 49 | tagPrefix: 'v', 50 | }, 51 | { version: newVersion } 52 | ).on('error', function (err) { 53 | return reject(err) 54 | }) 55 | 56 | changelogStream.on('data', (buffer) => { 57 | changelog += buffer.toString() 58 | }) 59 | 60 | changelogStream.on('end', () => { 61 | changelog = changelog.replace(/###\s(\w+)/g, '#### $1').replace(/\n\n\n/g, '\n\n') 62 | const finalChangelog = header + '\n' + (changelog + oldContent).replace(/\n+$/, '\n') 63 | fs.writeFileSync(file, finalChangelog, 'utf8') 64 | return resolve() 65 | }) 66 | }) 67 | } 68 | 69 | await generateChangelog(process.env.npm_package_version) 70 | -------------------------------------------------------------------------------- /src/components/Accordion.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, PropsWithChildren, useState } from 'react' 2 | import classNames from 'classnames' 3 | import { useSettings } from '../context/SettingsContext' 4 | import * as rb from 'react-bootstrap' 5 | import Sprite from './Sprite' 6 | 7 | interface AccordionProps { 8 | title: ReactNode | string 9 | defaultOpen?: boolean 10 | disabled?: boolean 11 | variant?: 'warning' | 'danger' 12 | } 13 | 14 | const Accordion = ({ 15 | title, 16 | defaultOpen = false, 17 | disabled = false, 18 | variant, 19 | children, 20 | }: PropsWithChildren) => { 21 | const settings = useSettings() 22 | const [isOpen, setIsOpen] = useState(defaultOpen) 23 | 24 | return ( 25 |
26 | setIsOpen((current) => !current)} 30 | disabled={disabled} 31 | > 32 |
37 | {variant && ( 38 |
46 | 47 |
48 | )} 49 | {title} 50 |
51 | 52 |
53 |
54 | 55 |
{children}
56 |
57 |
58 | ) 59 | } 60 | 61 | export default Accordion 62 | -------------------------------------------------------------------------------- /src/components/ActivityIndicators.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react' 2 | import classNames from 'classnames' 3 | import Sprite from './Sprite' 4 | 5 | interface ActivityIndicatorProps { 6 | isOn: boolean 7 | } 8 | 9 | function ActivityIndicator({ isOn, children }: PropsWithChildren) { 10 | return ( 11 | 12 | {children} 13 | 14 | ) 15 | } 16 | 17 | interface JoiningIndicatorProps { 18 | isOn: boolean 19 | size?: number 20 | title?: string 21 | className?: string 22 | } 23 | 24 | export function JoiningIndicator({ isOn, size = 32, className = '', ...props }: JoiningIndicatorProps) { 25 | return ( 26 | 27 | 28 | {isOn && } 29 | 30 | 31 | ) 32 | } 33 | 34 | interface TabActivityIndicatorProps { 35 | isOn: boolean 36 | className?: string 37 | } 38 | 39 | export function TabActivityIndicator({ isOn, className }: TabActivityIndicatorProps) { 40 | return ( 41 | 42 | 43 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/components/Alert.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Alert as BsAlert } from 'react-bootstrap' 3 | 4 | export default function Alert({ message, onClose, ...props }: SimpleAlert) { 5 | const [show, setShow] = useState(true) 6 | 7 | return ( 8 | { 11 | setShow(false) 12 | onClose && onClose(a, b) 13 | }} 14 | show={show} 15 | {...props} 16 | > 17 | {message} 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, act } from '../testUtils' 2 | import user from '@testing-library/user-event' 3 | import * as apiMock from '../libs/JmWalletApi' 4 | 5 | import App from './App' 6 | 7 | jest.mock('../libs/JmWalletApi', () => ({ 8 | ...jest.requireActual('../libs/JmWalletApi'), 9 | getGetinfo: jest.fn(), 10 | getSession: jest.fn(), 11 | })) 12 | 13 | describe('', () => { 14 | beforeEach(() => { 15 | const neverResolvingPromise = new Promise(() => {}) 16 | ;(apiMock.getGetinfo as jest.Mock).mockResolvedValue(neverResolvingPromise) 17 | ;(apiMock.getSession as jest.Mock).mockResolvedValue(neverResolvingPromise) 18 | }) 19 | 20 | it('should display Onboarding screen initially', async () => { 21 | await act(async () => render()) 22 | 23 | // Onboarding screen 24 | expect(screen.getByText('onboarding.splashscreen_button_get_started')).toBeInTheDocument() 25 | expect(screen.getByText('onboarding.splashscreen_button_skip_intro')).toBeInTheDocument() 26 | 27 | // Wallets screen shown after Intro is skipped 28 | expect(screen.queryByText('wallets.title')).not.toBeInTheDocument() 29 | 30 | const skipIntro = screen.getByText('onboarding.splashscreen_button_skip_intro') 31 | await user.click(skipIntro) 32 | 33 | expect(screen.getByText('wallets.title')).toBeInTheDocument() 34 | }) 35 | 36 | it('should display Wallets screen directly when Onboarding screen has been shown', async () => { 37 | global.__DEV__.addToAppSettings({ showOnboarding: false }) 38 | 39 | await act(async () => render()) 40 | 41 | // Wallets screen 42 | expect(screen.getByText('wallets.title')).toBeInTheDocument() 43 | expect(screen.getByText('wallets.button_new_wallet')).toBeInTheDocument() 44 | }) 45 | 46 | it('should display a modal with beta warning information', async () => { 47 | global.__DEV__.addToAppSettings({ showOnboarding: false }) 48 | 49 | await act(async () => render()) 50 | 51 | expect(screen.getByText('Read this before using.')).toBeInTheDocument() 52 | expect(screen.queryByText(/While JoinMarket is tried and tested, Jam is not./)).not.toBeInTheDocument() 53 | 54 | const readThis = screen.getByText('Read this before using.') 55 | await user.click(readThis) 56 | 57 | expect(screen.getByText('footer.warning_alert_text')).toBeInTheDocument() 58 | expect(screen.getByText('footer.warning_alert_button_ok')).toBeInTheDocument() 59 | }) 60 | 61 | it('should display websocket connection indicator as CONNECTED', async () => { 62 | global.__DEV__.addToAppSettings({ showOnboarding: false }) 63 | 64 | await act(async () => { 65 | render() 66 | }) 67 | 68 | await global.__DEV__.JM_WEBSOCKET_SERVER_MOCK.connected 69 | 70 | expect(screen.getByTestId('connection-indicator-icon').classList.contains('text-success')).toBe(true) 71 | expect(screen.getByTestId('connection-indicator-icon').classList.contains('text-secondary')).toBe(false) 72 | }) 73 | 74 | it('should display websocket connection indicator AS DISCONNECTED', async () => { 75 | global.__DEV__.addToAppSettings({ showOnboarding: false }) 76 | 77 | await act(async () => { 78 | render() 79 | }) 80 | 81 | await act(async () => { 82 | global.__DEV__.JM_WEBSOCKET_SERVER_MOCK.close() 83 | }) 84 | 85 | expect(screen.getByTestId('connection-indicator-icon').classList.contains('text-success')).toBe(false) 86 | expect(screen.getByTestId('connection-indicator-icon').classList.contains('text-secondary')).toBe(true) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /src/components/Balance.module.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --jam-balance-color: #212529; 3 | --jam-balance-deemphasize-color: #9eacba; 4 | } 5 | 6 | :root[data-theme='dark'] { 7 | --jam-balance-color: #ffffff; 8 | --jam-balance-deemphasize-color: #555c62; 9 | } 10 | 11 | .frozen { 12 | --jam-balance-color: #0d6efd; 13 | --jam-balance-deemphasize-color: #7eb2ff; 14 | } 15 | 16 | :root[data-theme='dark'] .frozen { 17 | --jam-balance-color: #1372ff; 18 | --jam-balance-deemphasize-color: #1153b5; 19 | } 20 | 21 | .balanceColor { 22 | color: var(--jam-balance-color); 23 | } 24 | 25 | .hideSymbol { 26 | padding-left: 0.1em; 27 | color: var(--jam-balance-deemphasize-color); 28 | } 29 | 30 | .bitcoinSymbol { 31 | order: -1; 32 | width: 1em; 33 | padding-right: 0.1em; 34 | } 35 | 36 | .satsSymbol { 37 | order: 5; 38 | } 39 | 40 | .frozenSymbol { 41 | order: 5; 42 | } 43 | .bitcoinAmount + .frozenSymbol { 44 | order: -2; 45 | width: 1em; 46 | height: 1em; 47 | } 48 | 49 | .frozenSymbol, 50 | .bitcoinSymbol, 51 | .satsSymbol { 52 | display: flex; 53 | justify-content: center; 54 | } 55 | 56 | .bitcoinAmountSpacing .fractionalPart :nth-child(3)::before, 57 | .bitcoinAmountSpacing .fractionalPart :nth-child(6)::before { 58 | content: '\202F'; 59 | } 60 | 61 | /** Integer Part **/ 62 | .bitcoinAmountColor[data-integer-part-is-zero="true"] .integerPart, 63 | /** Decimal Point **/ 64 | .bitcoinAmountColor[data-integer-part-is-zero="true"] .decimalPoint, 65 | .bitcoinAmountColor[data-fractional-part-starts-with-zero="true"] .decimalPoint, 66 | /** Fractional Part **/ 67 | .bitcoinAmountColor[data-integer-part-is-zero="false"] .fractionalPart, 68 | .bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]), 69 | .bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]) + span[data-digit="0"], 70 | .bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]) + span[data-digit="0"] + span[data-digit="0"], 71 | .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"], 72 | .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"], 73 | .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"], 74 | .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"], 75 | .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"], 76 | /** Symbol */ 77 | .bitcoinAmountColor[data-raw-value="0"] + .bitcoinSymbol { 78 | color: var(--jam-balance-deemphasize-color); 79 | } 80 | 81 | .satsAmountColor[data-raw-value='0'], 82 | .satsAmountColor[data-raw-value='0'] + .satsSymbol { 83 | color: var(--jam-balance-deemphasize-color); 84 | } 85 | -------------------------------------------------------------------------------- /src/components/BitcoinQR.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import QRCode from 'qrcode' 3 | 4 | import { satsToBtc } from '../utils' 5 | import { AmountSats, BitcoinAddress } from '../libs/JmWalletApi' 6 | 7 | interface BitcoinQRProps { 8 | address: BitcoinAddress 9 | amount?: AmountSats 10 | errorCorrectionLevel?: QRCode.QRCodeErrorCorrectionLevel 11 | width?: number 12 | } 13 | 14 | export const BitcoinQR = ({ address, amount, errorCorrectionLevel = 'H', width = 260 }: BitcoinQRProps) => { 15 | const [data, setData] = useState() 16 | const [image, setImage] = useState() 17 | 18 | useEffect(() => { 19 | const btc = amount ? satsToBtc(String(amount)) || 0 : 0 20 | const uri = `bitcoin:${address}${btc > 0 ? `?amount=${btc.toFixed(8)}` : ''}` 21 | 22 | QRCode.toDataURL(uri, { 23 | errorCorrectionLevel, 24 | width, 25 | }) 26 | .then((val) => { 27 | setImage(val) 28 | setData(uri) 29 | }) 30 | .catch(() => { 31 | setImage(undefined) 32 | setData(uri) 33 | }) 34 | }, [address, amount, errorCorrectionLevel, width]) 35 | 36 | return ( 37 |
38 | {data} 39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/components/Cheatsheet.module.css: -------------------------------------------------------------------------------- 1 | .cheatsheet { 2 | height: auto !important; 3 | /* page height - navbar height - some spacing*/ 4 | max-height: calc(100% - 4.75rem - 1rem) !important; 5 | margin: 0px auto !important; 6 | border: none !important; 7 | border-top-left-radius: 1rem; 8 | border-top-right-radius: 1rem; 9 | width: 33rem; 10 | 11 | box-shadow: 6px -3px 12px 3px rgba(0, 0, 0, 0.1); 12 | } 13 | 14 | :root[data-theme='dark'] .cheatsheet { 15 | box-shadow: 6px -3px 12px 3px rgba(0, 0, 0, 0.5); 16 | } 17 | 18 | .cheatsheet a { 19 | color: inherit !important; 20 | } 21 | 22 | .cheatsheet .cheatsheet-list-item { 23 | align-items: start; 24 | } 25 | 26 | .cheatsheet-list-item.upcoming-feature { 27 | opacity: 0.25; 28 | } 29 | 30 | .cheatsheet-list-item h6 { 31 | margin-bottom: 0.1rem; 32 | } 33 | 34 | .numbered { 35 | display: flex; 36 | justify-content: center; 37 | align-items: center; 38 | min-width: 2rem; 39 | height: 2rem; 40 | border-radius: 50%; 41 | background-color: rgb(0, 0, 0); 42 | color: white; 43 | } 44 | 45 | :root[data-theme='dark'] .numbered { 46 | color: rgb(0, 0, 0); 47 | background-color: white; 48 | } 49 | -------------------------------------------------------------------------------- /src/components/CoinjoinPreconditionViolationAlert.tsx: -------------------------------------------------------------------------------- 1 | import { Ref, forwardRef } from 'react' 2 | import * as rb from 'react-bootstrap' 3 | import { Trans, useTranslation } from 'react-i18next' 4 | import { useSettings } from '../context/SettingsContext' 5 | import { CoinjoinRequirementSummary } from '../hooks/CoinjoinRequirements' 6 | import { jarInitial } from './jars/Jar' 7 | import { shortenStringMiddle } from '../utils' 8 | import Sprite from './Sprite' 9 | import Balance from './Balance' 10 | 11 | interface CoinjoinPreconditionViolationAlertProps { 12 | summary: CoinjoinRequirementSummary 13 | i18nPrefix?: string 14 | } 15 | 16 | export const CoinjoinPreconditionViolationAlert = forwardRef( 17 | ({ summary, i18nPrefix = '' }: CoinjoinPreconditionViolationAlertProps, ref: Ref) => { 18 | const { t } = useTranslation() 19 | const settings = useSettings() 20 | 21 | if (summary.isFulfilled) return <> 22 | 23 | if (summary.numberOfMissingUtxos > 0) { 24 | return ( 25 | 26 | {t(`${i18nPrefix}hint_missing_utxos`, { 27 | minConfirmations: summary.options.minConfirmations, 28 | })} 29 | 30 | ) 31 | } 32 | 33 | if (summary.numberOfMissingConfirmations > 0) { 34 | return ( 35 | 36 | {t(`${i18nPrefix}hint_missing_confirmations`, { 37 | minConfirmations: summary.options.minConfirmations, 38 | amountOfMissingConfirmations: summary.numberOfMissingConfirmations, 39 | })} 40 | 41 | ) 42 | } 43 | 44 | const utxosViolatingRetriesLeft = summary.violations 45 | .map((it) => it.utxosViolatingRetriesLeft) 46 | .reduce((acc, utxos) => acc.concat(utxos), []) 47 | 48 | if (utxosViolatingRetriesLeft.length > 0) { 49 | return ( 50 | 51 | <> 52 | 53 | You tried too many times. See 54 |
59 | the docs 60 | {' '} 61 | for more info. 62 | 63 |
64 |
65 | 66 | Following utxos have been used unsuccessfully too many times: 67 |
    68 | {utxosViolatingRetriesLeft.map((utxo, index) => ( 69 |
  • 70 | 71 | 72 | 73 | {jarInitial(utxo.mixdepth)} 74 | 75 | : 76 | 77 |
    78 | {utxo.address} 79 |  ( 80 | 85 | ) 86 |
    87 | {shortenStringMiddle(utxo.utxo, 32)} 88 |
    89 |
  • 90 | ))} 91 |
92 |
93 | 94 | 95 | ) 96 | } 97 | 98 | return <> 99 | }, 100 | ) 101 | -------------------------------------------------------------------------------- /src/components/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, PropsWithChildren, useState, useEffect, useRef } from 'react' 2 | 3 | const copyToClipboard = ( 4 | text: string, 5 | fallbackInputField: HTMLInputElement, 6 | errorMessage?: string, 7 | ): Promise => { 8 | const copyToClipboardFallback = ( 9 | inputField: HTMLInputElement, 10 | errorMessage = 'Cannot copy value to clipboard', 11 | ): Promise => 12 | new Promise((resolve, reject) => { 13 | inputField.select() 14 | const success = document.execCommand && document.execCommand('copy') 15 | inputField.blur() 16 | success ? resolve(success) : reject(new Error(errorMessage)) 17 | }) 18 | 19 | // The `navigator.clipboard` API might not be available, e.g. on sites served over HTTP. 20 | if (!navigator.clipboard) { 21 | return copyToClipboardFallback(fallbackInputField) 22 | } 23 | 24 | return navigator.clipboard 25 | .writeText(text) 26 | .then(() => true) 27 | .catch((e: Error) => { 28 | if (fallbackInputField) { 29 | return copyToClipboardFallback(fallbackInputField, errorMessage) 30 | } else { 31 | throw e 32 | } 33 | }) 34 | } 35 | 36 | interface CopyableProps { 37 | value: string 38 | onSuccess?: () => void 39 | onError?: (e: Error) => void 40 | className?: string 41 | disabled?: boolean 42 | } 43 | 44 | function Copyable({ 45 | value, 46 | onSuccess, 47 | onError, 48 | className, 49 | children, 50 | disabled, 51 | ...props 52 | }: PropsWithChildren) { 53 | const valueFallbackInputRef = useRef(null) 54 | 55 | return ( 56 | <> 57 | 66 | 77 | 78 | ) 79 | } 80 | 81 | interface CopyButtonProps extends CopyableProps { 82 | text: ReactNode 83 | successText?: ReactNode 84 | successTextTimeout?: number 85 | disabled?: boolean 86 | } 87 | 88 | export function CopyButton({ 89 | value, 90 | onSuccess, 91 | onError, 92 | text, 93 | successText = text, 94 | successTextTimeout = 1_500, 95 | className, 96 | disabled, 97 | ...props 98 | }: CopyButtonProps) { 99 | const [showValueCopiedConfirmation, setShowValueCopiedConfirmation] = useState(false) 100 | const [valueCopiedFlag, setValueCopiedFlag] = useState(0) 101 | 102 | useEffect(() => { 103 | if (valueCopiedFlag < 1) return 104 | 105 | setShowValueCopiedConfirmation(true) 106 | const timer = setTimeout(() => { 107 | setShowValueCopiedConfirmation(false) 108 | }, successTextTimeout) 109 | 110 | return () => clearTimeout(timer) 111 | }, [valueCopiedFlag, successTextTimeout]) 112 | 113 | return ( 114 | { 121 | setValueCopiedFlag((current) => current + 1) 122 | onSuccess && onSuccess() 123 | }} 124 | > 125 |
126 | {showValueCopiedConfirmation ? successText : text} 127 |
128 |
129 | ) 130 | } 131 | -------------------------------------------------------------------------------- /src/components/Divider.module.css: -------------------------------------------------------------------------------- 1 | .dividerContainer { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | } 6 | 7 | .dividerContainer .dividerLine { 8 | margin: 0; 9 | width: 50%; 10 | flex-grow: 0; 11 | flex-shrink: 1; 12 | } 13 | 14 | .dividerContainer .dividerButton { 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | margin: 0 1rem; 19 | flex-shrink: 0; 20 | flex-grow: 1; 21 | color: var(--bs-body-color); 22 | cursor: pointer; 23 | background-color: transparent; 24 | border: 1px solid var(--bs-body-color); 25 | border-radius: 50%; 26 | width: 2rem; 27 | height: 2rem; 28 | } 29 | 30 | .dividerContainer .dividerButton:disabled { 31 | cursor: not-allowed; 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Divider.tsx: -------------------------------------------------------------------------------- 1 | import * as rb from 'react-bootstrap' 2 | import classNames from 'classnames' 3 | import Sprite from './Sprite' 4 | import styles from './Divider.module.css' 5 | 6 | type DividerProps = rb.ColProps & { 7 | toggled: boolean 8 | onToggle: (current: boolean) => void 9 | disabled?: boolean 10 | className?: string 11 | } 12 | 13 | export default function Divider({ toggled, onToggle, disabled, className, ...colProps }: DividerProps) { 14 | return ( 15 | 16 | 17 |
18 |
19 | 22 |
23 |
24 |
25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/components/Earn.module.css: -------------------------------------------------------------------------------- 1 | .earn .input-loader { 2 | height: 3.5rem; 3 | border-radius: 0.25rem; 4 | } 5 | 6 | .earn .fidelityBondsLoader { 7 | height: 11rem; 8 | border-radius: 0.25rem; 9 | } 10 | 11 | .earn form input:not([type='checkbox']) { 12 | height: 3.5rem; 13 | } 14 | 15 | .inputGroupText { 16 | width: 5ch; 17 | display: inline-flex; 18 | justify-content: center; 19 | align-items: center; 20 | } 21 | 22 | .offerLoader { 23 | height: 10rem; 24 | border-radius: 0.25rem; 25 | margin-bottom: 1.5rem; 26 | } 27 | 28 | .offerContainer { 29 | border: 1px solid var(--bs-gray-200); 30 | border-radius: 0.3rem; 31 | padding: 1.25rem; 32 | margin-bottom: 1.5rem; 33 | } 34 | 35 | :root[data-theme='dark'] .offerContainer { 36 | border-color: var(--bs-gray-700); 37 | } 38 | 39 | .offerContainer .offerTitle { 40 | width: 100%; 41 | font-size: 1.2rem; 42 | color: var(--bs-body-color); 43 | } 44 | 45 | .offerContainer .offerLabel { 46 | color: var(--bs-gray-600); 47 | font-size: 0.8rem; 48 | } 49 | 50 | .offerContainer .offerContent { 51 | font-size: 0.8rem; 52 | word-break: break-all; 53 | } 54 | -------------------------------------------------------------------------------- /src/components/EarnReport.module.css: -------------------------------------------------------------------------------- 1 | .report-line-placeholder { 2 | height: 2.625rem; 3 | margin: 1px 0; 4 | } 5 | 6 | .overlayContainer .earnReportContainer { 7 | display: flex; 8 | flex-direction: column; 9 | gap: 0.5rem; 10 | background-color: var(--bs-body-bg); 11 | } 12 | 13 | @media only screen and (min-width: 992px) { 14 | .overlayContainer .earnReportContainer { 15 | gap: 1.5rem; 16 | padding: 2rem; 17 | border-radius: 0.5rem; 18 | } 19 | } 20 | 21 | .overlayContainer .earnReportContainer > .titleBar { 22 | min-height: 3.6rem; 23 | display: flex; 24 | justify-content: space-between; 25 | flex-direction: column; 26 | align-items: flex-start; 27 | gap: 0.5rem; 28 | padding: 0 0.5rem 0.8rem 0.5rem; 29 | background-color: var(--bs-gray-100); 30 | } 31 | 32 | @media only screen and (min-width: 992px) { 33 | .overlayContainer .earnReportContainer .titleBar { 34 | padding: 0.8rem 1rem; 35 | border-radius: 0.6rem; 36 | } 37 | } 38 | 39 | @media only screen and (min-width: 768px) { 40 | .overlayContainer .earnReportContainer .titleBar { 41 | align-items: center; 42 | flex-direction: row; 43 | } 44 | } 45 | 46 | :root[data-theme='dark'] .overlayContainer .earnReportContainer .titleBar { 47 | background-color: var(--bs-gray-800); 48 | } 49 | 50 | .overlayContainer .earnReportContainer > .titleBar .refreshButton { 51 | display: flex; 52 | justify-content: center; 53 | align-items: center; 54 | width: 2rem; 55 | height: 2rem; 56 | padding: 0.1rem; 57 | border: none; 58 | } 59 | -------------------------------------------------------------------------------- /src/components/EarnReport.test.tsx: -------------------------------------------------------------------------------- 1 | import { yieldgenReportToEarnReportEntries } from './EarnReport' 2 | 3 | const EXPECTED_HEADER_LINE = 4 | 'timestamp,cj amount/satoshi,my input count,my input value/satoshi,cjfee/satoshi,earned/satoshi,confirm time/min,notes\n' 5 | 6 | describe('Earn Report', () => { 7 | it('should parse empty data correctly', () => { 8 | const entries = yieldgenReportToEarnReportEntries([]) 9 | expect(entries.length).toBe(0) 10 | }) 11 | 12 | it('should parse data only containing headers correctly', () => { 13 | const entries = yieldgenReportToEarnReportEntries([EXPECTED_HEADER_LINE]) 14 | 15 | expect(entries.length).toBe(0) 16 | }) 17 | 18 | it('should parse expected data structure correctly', () => { 19 | const exampleData = [ 20 | EXPECTED_HEADER_LINE, 21 | '2008/10/31 02:42:54,,,,,,,Connected\n', 22 | '2009/01/03 02:54:42,14999989490,4,20000005630,250,250,0.42,\n', 23 | '2009/01/03 03:03:32,10000000000,3,15000016390,250,250,0.8,\n', 24 | '2009/01/03 03:04:47,4999981140,1,5000016640,250,250,0,\n', 25 | '2009/01/03 03:06:07,1132600000,1,2500000000,250,250,13.37,\n', 26 | '2009/01/03 03:07:27,8867393010,2,10000000000,250,250,42,\n', 27 | '2009/01/03 03:08:52,1132595980,1,1367400250,250,250,0.17,\n', 28 | ] 29 | 30 | const entries = yieldgenReportToEarnReportEntries(exampleData) 31 | 32 | expect(entries.length).toBe(7) 33 | 34 | const firstEntry = entries[0] 35 | expect(firstEntry.timestamp.toUTCString()).toBe('Fri, 31 Oct 2008 02:42:54 GMT') 36 | expect(firstEntry.cjTotalAmount).toBe(null) 37 | expect(firstEntry.inputCount).toBe(null) 38 | expect(firstEntry.inputAmount).toBe(null) 39 | expect(firstEntry.fee).toBe(null) 40 | expect(firstEntry.earnedAmount).toBe(null) 41 | expect(firstEntry.confirmationDuration).toBe(null) 42 | expect(firstEntry.notes).toBe('Connected\n') 43 | 44 | const lastEntry = entries[entries.length - 1] 45 | expect(lastEntry.timestamp.toUTCString()).toBe('Sat, 03 Jan 2009 03:08:52 GMT') 46 | expect(lastEntry.cjTotalAmount).toBe(1132595980) 47 | expect(lastEntry.inputCount).toBe(1) 48 | expect(lastEntry.inputAmount).toBe(1367400250) 49 | expect(lastEntry.fee).toBe(250) 50 | expect(lastEntry.earnedAmount).toBe(250) 51 | expect(lastEntry.confirmationDuration).toBe(0.17) 52 | expect(lastEntry.notes).toBe('\n') 53 | }) 54 | 55 | it('should handle unexpected/malformed data in a sane way', () => { 56 | const unexpectedHeader = EXPECTED_HEADER_LINE + ',foo,bar' 57 | const emptyLine = '' // should be skipped 58 | const onlyNewLine = '\n' // should be skipped 59 | const shortLine = '2009/01/03 04:04:04,,,' // should be skipped 60 | const longLine = '2009/01/03 05:05:05,,,,,,,,,,,,,,,,,,,,,,,' // should be parsed 61 | const malformedLine = 'this,is,a,malformed,line,with,some,unexpected,data' // should be parsed 62 | 63 | const exampleData = [unexpectedHeader, emptyLine, onlyNewLine, shortLine, longLine, malformedLine] 64 | 65 | const entries = yieldgenReportToEarnReportEntries(exampleData) 66 | 67 | expect(entries.length).toBe(2) 68 | 69 | const firstEntry = entries[0] 70 | expect(firstEntry.timestamp.toUTCString()).toBe('Sat, 03 Jan 2009 05:05:05 GMT') 71 | expect(firstEntry.cjTotalAmount).toBe(null) 72 | expect(firstEntry.inputCount).toBe(null) 73 | expect(firstEntry.inputAmount).toBe(null) 74 | expect(firstEntry.fee).toBe(null) 75 | expect(firstEntry.earnedAmount).toBe(null) 76 | expect(firstEntry.confirmationDuration).toBe(null) 77 | expect(firstEntry.notes).toBe(null) 78 | 79 | const secondEntry = entries[1] 80 | expect(secondEntry.timestamp.toUTCString()).toBe('Invalid Date') 81 | expect(secondEntry.cjTotalAmount).toBe(NaN) 82 | expect(secondEntry.inputCount).toBe(NaN) 83 | expect(secondEntry.inputAmount).toBe(NaN) 84 | expect(secondEntry.fee).toBe(NaN) 85 | expect(secondEntry.earnedAmount).toBe(NaN) 86 | expect(secondEntry.confirmationDuration).toBe(NaN) 87 | expect(secondEntry.notes).toBe('unexpected') 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /src/components/ErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import { Trans, useTranslation } from 'react-i18next' 2 | import * as rb from 'react-bootstrap' 3 | import { useRouteError } from 'react-router-dom' 4 | import PageTitle from './PageTitle' 5 | import { t } from 'i18next' 6 | 7 | interface ErrorViewProps { 8 | title: string 9 | subtitle: string 10 | reason: string 11 | stacktrace?: string 12 | } 13 | 14 | function ErrorView({ title, subtitle, reason, stacktrace }: ErrorViewProps) { 15 | return ( 16 |
17 | 18 | 19 |

20 | 21 | Please{' '} 22 | 27 | open an issue on GitHub 28 | {' '} 29 | for this error to be reviewed and resolved in an upcoming version. 30 | 31 |

32 | 33 |
34 |
{t('error_page.heading_reason')}
35 | {reason} 36 |
37 | 38 | {stacktrace && ( 39 |
40 |
{t('error_page.heading_stacktrace')}
41 |
42 |             {stacktrace}
43 |           
44 |
45 | )} 46 |
47 | ) 48 | } 49 | 50 | function UnknownError({ error }: { error: any }) { 51 | const { t } = useTranslation() 52 | 53 | return ( 54 | 60 | ) 61 | } 62 | 63 | function ErrorWithDetails({ error }: { error: Error }) { 64 | const { t } = useTranslation() 65 | 66 | return ( 67 | 73 | ) 74 | } 75 | 76 | export default function ErrorPage() { 77 | const error = useRouteError() 78 | 79 | if (error instanceof Error) { 80 | return 81 | } else { 82 | return 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/components/ExtendedLink.tsx: -------------------------------------------------------------------------------- 1 | import { Link, LinkProps } from 'react-router-dom' 2 | 3 | interface Props extends LinkProps { 4 | disabled?: boolean 5 | } 6 | 7 | export function ExtendedLink({ disabled, ...props }: Props) { 8 | if (disabled) { 9 | return ( 10 | 13 | ) 14 | } 15 | return {props.children} 16 | } 17 | -------------------------------------------------------------------------------- /src/components/Jam.module.css: -------------------------------------------------------------------------------- 1 | .input { 2 | height: 3.5rem; 3 | width: 100%; 4 | } 5 | 6 | .input-loader { 7 | height: 3.5rem; 8 | border-radius: 0.25rem; 9 | } 10 | 11 | .walletLink { 12 | cursor: pointer; 13 | text-decoration: none; 14 | color: var(--bs-body-color); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/JarSelectorModal.module.css: -------------------------------------------------------------------------------- 1 | :global .modal-backdrop { 2 | background-color: rgba(0, 0, 0, 0.5) !important; 3 | } 4 | 5 | .modal :global .modal-content { 6 | background-color: var(--bs-body-bg) !important; 7 | border-radius: 1rem !important; 8 | box-shadow: 0px 0px 24px rgba(0, 0, 0, 0.25) !important; 9 | } 10 | 11 | .modalHeader { 12 | display: flex !important; 13 | justify-content: flex-start !important; 14 | background-color: transparent !important; 15 | padding: 1.25rem !important; 16 | } 17 | 18 | .modalTitle { 19 | width: 100%; 20 | font-size: 1rem !important; 21 | font-weight: 400 !important; 22 | color: var(--bs-body-color) !important; 23 | } 24 | 25 | .modalTitle > div:first-child { 26 | display: flex; 27 | justify-content: space-between; 28 | align-items: center; 29 | } 30 | 31 | .modalTitle .cancelButton { 32 | padding: 0 0 0 1rem; 33 | color: var(--bs-body-color); 34 | background-color: transparent !important; 35 | border: none; 36 | } 37 | 38 | .modalFooter { 39 | display: flex !important; 40 | justify-content: center !important; 41 | gap: 1rem; 42 | background-color: transparent !important; 43 | padding: 1rem 1.25rem 1.25rem 1.25rem !important; 44 | } 45 | 46 | .modalFooter :global .btn { 47 | flex-grow: 1; 48 | min-height: 2.8rem; 49 | font-weight: 500; 50 | border-color: none !important; 51 | } 52 | 53 | .jarsContainer { 54 | display: flex; 55 | flex-wrap: wrap; 56 | flex-direction: row; 57 | justify-content: center; 58 | align-items: center; 59 | gap: 2rem; 60 | color: var(--bs-body-color); 61 | } 62 | 63 | @media only screen and (min-width: 768px) { 64 | .jarsContainer { 65 | gap: 1.5rem; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/components/Jars.module.css: -------------------------------------------------------------------------------- 1 | .jarsTitle { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | gap: 0.75rem; 6 | cursor: pointer; 7 | padding: 0 0.5rem 0 0; 8 | } 9 | 10 | .jarsTitle .infoIcon { 11 | color: var(--bs-gray-500); 12 | border: 1px solid var(--bs-gray-500); 13 | border-radius: 50%; 14 | cursor: help; 15 | } 16 | 17 | .jarsContainer { 18 | display: flex; 19 | flex-direction: column; 20 | justify-content: space-around; 21 | align-items: center; 22 | width: 100%; 23 | gap: 2rem; 24 | color: var(--bs-body-color); 25 | } 26 | 27 | .jarsContainer :global .jar-container-hook { 28 | flex-direction: row; 29 | gap: 1rem; 30 | } 31 | 32 | .jarsContainer :global .jar-info-container-hook { 33 | align-items: flex-start; 34 | } 35 | .jarsContainer :global .jar-balance-container-hook { 36 | justify-content: start !important; 37 | } 38 | 39 | @media only screen and (min-width: 768px) { 40 | .jarsContainer { 41 | flex-direction: row; 42 | align-items: flex-start; 43 | gap: 1.5rem; 44 | } 45 | 46 | .jarsContainer :global .jar-container-hook { 47 | flex-direction: column; 48 | gap: 0; 49 | min-width: inherit; 50 | } 51 | .jarsContainer :global .jar-info-container-hook { 52 | align-items: center !important; 53 | } 54 | .jarsContainer :global .jar-balance-container-hook { 55 | justify-content: center !important; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/Jars.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import * as rb from 'react-bootstrap' 3 | import { useTranslation } from 'react-i18next' 4 | import { AccountBalances } from '../context/BalanceSummary' 5 | import { AmountSats } from '../libs/JmWalletApi' 6 | import { JarProps, OpenableJar, jarFillLevel } from './jars/Jar' 7 | import Sprite from './Sprite' 8 | 9 | import styles from './Jars.module.css' 10 | 11 | type JarsProps = Pick & { 12 | accountBalances: AccountBalances 13 | totalBalance: AmountSats 14 | onClick: (jarIndex: JarIndex) => void 15 | } 16 | 17 | const Jars = ({ size, accountBalances, totalBalance, onClick }: JarsProps) => { 18 | const { t } = useTranslation() 19 | const sortedAccountBalances = useMemo(() => { 20 | if (!accountBalances) return [] 21 | return Object.values(accountBalances).sort((lhs, rhs) => lhs.accountIndex - rhs.accountIndex) 22 | }, [accountBalances]) 23 | 24 | return ( 25 |
26 | 30 | {t('current_wallet.jars_title_popover')} 31 | 32 | } 33 | > 34 |
35 |
{t('current_wallet.jars_title')}
36 | 37 |
38 |
39 |
40 | {sortedAccountBalances.map((account) => { 41 | const jarIsEmpty = account.calculatedTotalBalanceInSats === 0 42 | 43 | return ( 44 | onClick(account.accountIndex)} 57 | /> 58 | ) 59 | })} 60 |
61 |
62 | ) 63 | } 64 | 65 | export { Jars } 66 | -------------------------------------------------------------------------------- /src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react' 2 | import * as rb from 'react-bootstrap' 3 | 4 | type LayoutVariant = 'wide' | '' 5 | 6 | interface ColProps { 7 | variant?: LayoutVariant 8 | } 9 | 10 | const Col = ({ variant, children }: PropsWithChildren) => { 11 | if (variant === 'wide') { 12 | return {children} 13 | } 14 | 15 | return ( 16 | 17 | {children} 18 | 19 | ) 20 | } 21 | 22 | interface LayoutProps { 23 | variant?: LayoutVariant 24 | } 25 | 26 | const Layout = ({ variant, children }: PropsWithChildren) => { 27 | return ( 28 | 29 | {children} 30 | 31 | ) 32 | } 33 | 34 | export default Layout 35 | -------------------------------------------------------------------------------- /src/components/LogOverlay.module.css: -------------------------------------------------------------------------------- 1 | .logContentPlaceholder { 2 | height: 2.625rem; 3 | margin: 1px 0; 4 | } 5 | 6 | .overlayContainer .logContentContainer { 7 | display: flex; 8 | flex-direction: column; 9 | gap: 0.5rem; 10 | background-color: var(--bs-body-bg); 11 | } 12 | 13 | @media only screen and (min-width: 992px) { 14 | .overlayContainer .logContentContainer { 15 | gap: 1.5rem; 16 | padding: 2rem; 17 | border-radius: 0.5rem; 18 | } 19 | } 20 | 21 | .overlayContainer .logContentContainer .titleBar { 22 | min-height: 3.6rem; 23 | display: flex; 24 | justify-content: space-between; 25 | flex-direction: column; 26 | align-items: flex-start; 27 | gap: 0.5rem; 28 | padding: 0 0.5rem 0.8rem 0.5rem; 29 | background-color: var(--bs-gray-100); 30 | } 31 | 32 | @media only screen and (min-width: 992px) { 33 | .overlayContainer .logContentContainer .titleBar { 34 | padding: 0.8rem 1rem; 35 | border-radius: 0.6rem; 36 | } 37 | } 38 | 39 | @media only screen and (min-width: 768px) { 40 | .overlayContainer .logContentContainer > .titleBar { 41 | align-items: center; 42 | flex-direction: row; 43 | } 44 | } 45 | 46 | :root[data-theme='dark'] .overlayContainer .logContentContainer .titleBar { 47 | background-color: var(--bs-gray-800); 48 | } 49 | 50 | .overlayContainer .logContentContainer .titleBar .refreshButton { 51 | display: flex; 52 | justify-content: center; 53 | align-items: center; 54 | width: 2rem; 55 | height: 2rem; 56 | padding: 0.1rem; 57 | border: none; 58 | } 59 | 60 | .logContent { 61 | min-height: 300px; 62 | max-height: 60vh; 63 | overflow: scroll; 64 | } 65 | -------------------------------------------------------------------------------- /src/components/MainWalletView.module.css: -------------------------------------------------------------------------------- 1 | .walletHeader { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | } 6 | 7 | .walletHeader .titlePlaceholder { 8 | width: 5rem; 9 | margin-bottom: 0.5rem; 10 | } 11 | 12 | .walletHeader .subtitlePlaceholder { 13 | width: 12rem; 14 | height: 1.8rem; 15 | margin-bottom: 0.45rem; 16 | } 17 | 18 | :global(.jm-rescan-in-progress) .walletHeader { 19 | cursor: wait; 20 | } 21 | 22 | :global(.jm-rescan-in-progress) .walletBody { 23 | filter: blur(2px); 24 | } 25 | 26 | .jarsPlaceholder { 27 | width: 100%; 28 | height: 3.5rem; 29 | } 30 | -------------------------------------------------------------------------------- /src/components/MnemonicPhraseInput.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react' 2 | import { Bip39MnemonicWordInput } from './MnemonicWordInput' 3 | 4 | interface MnemonicPhraseInputProps { 5 | columns?: number 6 | mnemonicPhrase: MnemonicPhrase 7 | isDisabled?: (index: number) => boolean 8 | isValid?: (index: number) => boolean 9 | onChange: (value: MnemonicPhrase) => void 10 | } 11 | 12 | export default function MnemonicPhraseInput({ 13 | columns = 3, 14 | mnemonicPhrase, 15 | isDisabled, 16 | isValid, 17 | onChange, 18 | }: MnemonicPhraseInputProps) { 19 | const [activeIndex, setActiveIndex] = useState(0) 20 | const inputRefs = useRef([]) 21 | 22 | useEffect(() => { 23 | if (activeIndex < mnemonicPhrase.length && isValid && isValid(activeIndex)) { 24 | const nextIndex = activeIndex + 1 25 | setActiveIndex(nextIndex) 26 | 27 | if (inputRefs.current[nextIndex]) { 28 | inputRefs.current[nextIndex].focus() 29 | } 30 | } 31 | }, [mnemonicPhrase, activeIndex, isValid]) 32 | 33 | return ( 34 |
35 | {mnemonicPhrase.map((_, outerIndex) => { 36 | if (outerIndex % columns !== 0) return null 37 | 38 | const wordGroup = mnemonicPhrase.slice(outerIndex, Math.min(outerIndex + columns, mnemonicPhrase.length)) 39 | 40 | return ( 41 |
42 | {wordGroup.map((givenWord, innerIndex) => { 43 | const wordIndex = outerIndex + innerIndex 44 | const isCurrentActive = wordIndex === activeIndex 45 | return ( 46 |
47 | (inputRefs.current[wordIndex] = el)} 49 | index={wordIndex} 50 | value={givenWord} 51 | setValue={(value, i) => { 52 | const newPhrase = mnemonicPhrase.map((old, index) => (index === i ? value : old)) 53 | onChange(newPhrase) 54 | }} 55 | isValid={isValid ? isValid(wordIndex) : undefined} 56 | disabled={isDisabled ? isDisabled(wordIndex) : undefined} 57 | onFocus={() => setActiveIndex(wordIndex)} 58 | autoFocus={isCurrentActive} 59 | /> 60 |
61 | ) 62 | })} 63 |
64 | ) 65 | })} 66 |
67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /src/components/MnemonicWordInput.module.css: -------------------------------------------------------------------------------- 1 | .input { 2 | height: 3.5rem; 3 | width: 100%; 4 | } 5 | 6 | .seedwordIndexBackup { 7 | width: 5ch; 8 | justify-content: right; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/MnemonicWordInput.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '../testUtils' 2 | import { Bip39MnemonicWordInput, MnemonicWordInputProps } from './MnemonicWordInput' 3 | 4 | const NOOP = () => {} 5 | 6 | describe('', () => { 7 | const validBip39MnemonicWord = 'abandon' 8 | const invalidBip39MnemonicWord = 'not a bip39 word!' 9 | 10 | const setup = (props: MnemonicWordInputProps) => { 11 | render() 12 | } 13 | 14 | it('should render without errors', async () => { 15 | setup({ index: 0, value: '', setValue: NOOP }) 16 | 17 | expect(await screen.findByTestId('mnemonic-word-input')).toBeVisible() 18 | }) 19 | 20 | it('should report if input is NOT included in the BIP-39 wordlist', async () => { 21 | setup({ index: 0, value: invalidBip39MnemonicWord, setValue: NOOP }) 22 | 23 | const input = await screen.findByTestId('mnemonic-word-input') 24 | expect(input).toBeVisible() 25 | expect(input).toHaveClass('is-invalid') 26 | expect(input).not.toHaveClass('is-valid') 27 | }) 28 | 29 | it('should report if input IS INCLUDED in the BIP-39 wordlist', async () => { 30 | setup({ index: 0, value: validBip39MnemonicWord, setValue: NOOP }) 31 | 32 | const input = await screen.findByTestId('mnemonic-word-input') 33 | expect(input).toBeVisible() 34 | expect(input).toHaveClass('is-valid') 35 | expect(input).not.toHaveClass('is-invalid') 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/components/MnemonicWordInput.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import * as rb from 'react-bootstrap' 3 | import { useTranslation } from 'react-i18next' 4 | import { MNEMONIC_WORDS } from '../constants/bip39words' 5 | import styles from './MnemonicWordInput.module.css' 6 | 7 | export interface MnemonicWordInputProps { 8 | forwardRef?: (el: HTMLInputElement) => void 9 | index: number 10 | value: string 11 | setValue: (value: string, index: number) => void 12 | isValid?: boolean 13 | disabled?: boolean 14 | onFocus?: () => void 15 | autoFocus?: boolean 16 | } 17 | 18 | const MnemonicWordInput = ({ 19 | forwardRef, 20 | index, 21 | value, 22 | setValue, 23 | isValid, 24 | disabled, 25 | onFocus, 26 | autoFocus, 27 | }: MnemonicWordInputProps) => { 28 | const { t } = useTranslation() 29 | return ( 30 | 31 | {index + 1}. 32 | setValue(e.target.value.trim(), index)} 39 | className={styles.input} 40 | disabled={disabled} 41 | isInvalid={isValid === false && value.length > 0} 42 | isValid={isValid === true} 43 | onFocus={onFocus} 44 | autoFocus={autoFocus} 45 | required 46 | /> 47 | 48 | ) 49 | } 50 | 51 | export const Bip39MnemonicWordInput = ({ value, ...props }: MnemonicWordInputProps) => { 52 | const isBip39Value = useMemo(() => MNEMONIC_WORDS.includes(value), [value]) 53 | 54 | return 55 | } 56 | -------------------------------------------------------------------------------- /src/components/Modal.module.css: -------------------------------------------------------------------------------- 1 | :global .modal-backdrop { 2 | background-color: rgba(0, 0, 0, 0.5) !important; 3 | } 4 | 5 | .modal :global .modal-content { 6 | background-color: var(--bs-body-bg) !important; 7 | border-radius: 1rem !important; 8 | box-shadow: 0px 0px 24px rgba(0, 0, 0, 0.25) !important; 9 | } 10 | 11 | .modalHeader { 12 | display: flex !important; 13 | justify-content: center !important; 14 | background-color: transparent !important; 15 | border: none !important; 16 | padding: 1.25rem 1.25rem 0 1.25rem !important; 17 | } 18 | 19 | .modalTitle { 20 | font-size: 1.3rem !important; 21 | font-weight: 600 !important; 22 | color: var(--bs-body-color) !important; 23 | } 24 | 25 | .modalBody { 26 | text-align: center !important; 27 | font-size: 1rem !important; 28 | font-weight: 400 !important; 29 | padding: 0.25rem 1.25rem 1rem 1.25rem !important; 30 | } 31 | 32 | .modalFooter { 33 | display: flex !important; 34 | justify-content: center !important; 35 | gap: 1rem; 36 | background-color: transparent !important; 37 | padding: 1rem 1.25rem 1.25rem 1.25rem !important; 38 | } 39 | 40 | .modalFooter :global .btn { 41 | --bs-btn-border-color: var(--bs-border-color); 42 | flex-grow: 1; 43 | min-height: 2.8rem; 44 | font-weight: 500; 45 | } 46 | -------------------------------------------------------------------------------- /src/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, PropsWithChildren } from 'react' 2 | import * as rb from 'react-bootstrap' 3 | import { useTranslation } from 'react-i18next' 4 | import Sprite from './Sprite' 5 | import styles from './Modal.module.css' 6 | 7 | type BaseModalProps = Pick & 8 | Pick & { 9 | isShown: boolean 10 | title: ReactNode | string 11 | onCancel: () => void 12 | headerClassName?: rb.ModalHeaderProps['className'] 13 | titleClassName?: rb.ModalTitleProps['className'] 14 | } 15 | 16 | const BaseModal = ({ 17 | isShown, 18 | title, 19 | children, 20 | onCancel, 21 | size, 22 | backdrop = 'static', 23 | closeButton = false, 24 | className = styles.modal, 25 | headerClassName = styles.modalHeader, 26 | titleClassName = styles.modalTitle, 27 | }: PropsWithChildren) => { 28 | return ( 29 | onCancel()} 33 | onHide={() => onCancel()} 34 | centered={true} 35 | animation={true} 36 | backdrop={backdrop} 37 | size={size} 38 | className={className} 39 | > 40 | 41 | {title} 42 | 43 | {children} 44 | 45 | ) 46 | } 47 | 48 | export type InfoModalProps = Omit & { 49 | onSubmit: () => void 50 | submitButtonText: React.ReactNode | string 51 | } 52 | 53 | const InfoModal = ({ 54 | children, 55 | onCancel, 56 | onSubmit, 57 | submitButtonText, 58 | ...baseModalProps 59 | }: PropsWithChildren) => { 60 | return ( 61 | 62 | {children} 63 | 64 | onSubmit()}> 65 | {submitButtonText} 66 | 67 | 68 | 69 | ) 70 | } 71 | 72 | export type ConfirmModalProps = Omit & { 73 | onConfirm: () => void 74 | disabled?: boolean 75 | } 76 | 77 | const ConfirmModal = ({ 78 | children, 79 | onCancel, 80 | onConfirm, 81 | disabled = false, 82 | ...baseModalProps 83 | }: PropsWithChildren) => { 84 | const { t } = useTranslation() 85 | 86 | return ( 87 | 88 | {children} 89 | 90 | onCancel()} 93 | className="d-flex justify-content-center align-items-center" 94 | > 95 | 96 |
{t('modal.confirm_button_reject')}
97 |
98 | onConfirm()} disabled={disabled}> 99 | {t('modal.confirm_button_accept')} 100 | 101 |
102 |
103 | ) 104 | } 105 | 106 | export { BaseModal, InfoModal, ConfirmModal } 107 | -------------------------------------------------------------------------------- /src/components/Navbar.module.css: -------------------------------------------------------------------------------- 1 | :global(.jm-rescan-in-progress) :global(.center-nav-link), 2 | :global(.jm-rescan-in-progress) :global(.center-nav-link-divider) { 3 | filter: blur(2px); 4 | } 5 | 6 | .balancePlaceholder { 7 | width: 7.5rem; 8 | } 9 | 10 | .loadingIndicator { 11 | display: none !important; 12 | } 13 | 14 | :global(.jam-reload-wallet-info-in-progress) .walletSprite { 15 | display: none !important; 16 | } 17 | 18 | :global(.jam-reload-wallet-info-in-progress) .loadingIndicator { 19 | display: inline-block !important; 20 | } 21 | 22 | .offcanvasBody { 23 | font-size: calc(var(--bs-body-font-size) * 1.5); 24 | } 25 | 26 | .offcanvasBody :global(.nav-link) { 27 | width: 100%; 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Onboarding.module.css: -------------------------------------------------------------------------------- 1 | .icon { 2 | min-height: 19ch; 3 | } 4 | .title { 5 | min-height: 4ch; 6 | } 7 | .description { 8 | min-height: 17ch; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Orderbook.module.css: -------------------------------------------------------------------------------- 1 | .orderbookContentPlaceholder { 2 | height: 2.625rem; 3 | margin: 1px 0; 4 | } 5 | 6 | .overlayContainer .orderbookContainer { 7 | display: flex; 8 | flex-direction: column; 9 | gap: 0.5rem; 10 | background-color: var(--bs-body-bg); 11 | } 12 | 13 | @media only screen and (min-width: 992px) { 14 | .overlayContainer .orderbookContainer { 15 | gap: 1.5rem; 16 | padding: 2rem; 17 | border-radius: 0.5rem; 18 | } 19 | } 20 | 21 | .overlayContainer .orderbookContainer .titleBar { 22 | min-height: 3.6rem; 23 | display: flex; 24 | justify-content: space-between; 25 | flex-direction: column; 26 | align-items: flex-start; 27 | gap: 0.5rem; 28 | padding: 0 0.5rem 0.8rem 0.5rem; 29 | background-color: var(--bs-gray-100); 30 | } 31 | 32 | @media only screen and (min-width: 992px) { 33 | .overlayContainer .orderbookContainer .titleBar { 34 | padding: 0.8rem 1rem; 35 | border-radius: 0.6rem; 36 | } 37 | } 38 | 39 | @media only screen and (min-width: 768px) { 40 | .overlayContainer .orderbookContainer .titleBar { 41 | align-items: center; 42 | flex-direction: row; 43 | } 44 | } 45 | 46 | :root[data-theme='dark'] .overlayContainer .orderbookContainer .titleBar { 47 | background-color: var(--bs-gray-800); 48 | } 49 | 50 | .overlayContainer .orderbookContainer .titleBar .refreshButton { 51 | display: flex; 52 | justify-content: center; 53 | align-items: center; 54 | width: 2rem; 55 | height: 2rem; 56 | padding: 0.1rem; 57 | border: none; 58 | } 59 | 60 | .orderbookContainer tr.highlighted td { 61 | background-color: rgba(var(--bs-success-rgb), 0.33) !important; 62 | } 63 | .orderbookContainer tr:hover.highlighted td { 64 | background-color: rgba(var(--bs-success-rgb), 0.66) !important; 65 | } 66 | -------------------------------------------------------------------------------- /src/components/PageTitle.tsx: -------------------------------------------------------------------------------- 1 | import Sprite from './Sprite' 2 | import classNames from 'classnames' 3 | 4 | interface PageTitleProps { 5 | title: string 6 | subtitle?: string 7 | success?: boolean 8 | center?: boolean 9 | } 10 | 11 | export default function PageTitle({ title, subtitle, success = false, center = false }: PageTitleProps) { 12 | return ( 13 |
18 | {success && ( 19 |
24 |
34 | 35 |
36 |
37 | )} 38 |
{title}
39 | {subtitle &&

{subtitle}

} 40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/components/PaymentConfirmModal.module.css: -------------------------------------------------------------------------------- 1 | .infoIcon { 2 | margin: 2px 0 0 0.25rem; 3 | color: var(--bs-gray-500); 4 | border: 1px solid var(--bs-gray-500); 5 | border-radius: 50%; 6 | cursor: help; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/PreventLeavingPageByMistake.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | const PreventLeavingPageByMistake = () => { 4 | // prompt users before refreshing or closing the page when this component is present. 5 | // Firefox will show: "This page is asking you to confirm that you want to leave [...]" 6 | // Chrome: "Leave site? Changes you made may not be saved." 7 | useEffect(() => { 8 | const abortCtrl = new AbortController() 9 | 10 | window.addEventListener( 11 | 'beforeunload', 12 | (event) => { 13 | // cancel the event as stated by the standard. 14 | event.preventDefault() 15 | 16 | // Chrome requires returnValue to be set. 17 | event.returnValue = '' 18 | 19 | // return something to trigger a dialog 20 | return '' 21 | }, 22 | { signal: abortCtrl.signal }, 23 | ) 24 | 25 | return () => abortCtrl.abort() 26 | }, []) 27 | 28 | return <> 29 | } 30 | 31 | export default PreventLeavingPageByMistake 32 | -------------------------------------------------------------------------------- /src/components/Receive.module.css: -------------------------------------------------------------------------------- 1 | .receive button { 2 | font-weight: 500; 3 | } 4 | 5 | .receive form input { 6 | height: 3.5rem; 7 | width: 100%; 8 | } 9 | 10 | .receive select { 11 | height: 3.5rem; 12 | width: 100%; 13 | } 14 | 15 | .receive-placeholder-container { 16 | height: 1.5rem; 17 | text-align: center; 18 | margin-bottom: 1rem; 19 | } 20 | 21 | .receive-placeholder { 22 | height: 1.5rem; 23 | } 24 | 25 | .inputGroupText { 26 | width: 5ch; 27 | display: inline-flex; 28 | justify-content: center; 29 | align-items: center; 30 | } 31 | 32 | .qr-container { 33 | display: flex; 34 | justify-content: center; 35 | min-height: 16.25rem; 36 | } 37 | 38 | :global(.jm-rescan-in-progress) .cardContainer { 39 | display: none; 40 | } 41 | 42 | :global(.jm-rescan-in-progress) .receiveForm { 43 | filter: blur(2px); 44 | } 45 | 46 | .receive-placeholder-qr-container { 47 | width: 16.25rem; 48 | } 49 | 50 | .receive-placeholder-qr { 51 | width: 13.75rem; 52 | height: 13.75rem; 53 | margin: 1.25rem; 54 | } 55 | 56 | :root[data-theme='dark'] .receive-placeholder-qr { 57 | width: 16.25rem; 58 | height: 16.25rem; 59 | margin: 0; 60 | } 61 | 62 | .address { 63 | font-size: 0.8rem; 64 | padding: 1rem 0 1rem; 65 | } 66 | 67 | .jarsContainer { 68 | display: flex; 69 | flex-wrap: wrap; 70 | flex-direction: row; 71 | justify-content: space-evenly; 72 | align-items: center; 73 | gap: 2rem; 74 | color: var(--bs-body-color); 75 | margin-bottom: 2rem; 76 | } 77 | 78 | .jarsPlaceholder { 79 | width: 100%; 80 | height: 8.5rem; 81 | margin-bottom: 2rem; 82 | } 83 | 84 | @media only screen and (min-width: 768px) { 85 | .address { 86 | font-size: var(--bs-body-font-size); 87 | padding: 0; 88 | } 89 | .jarsContainer { 90 | gap: 1.5rem; 91 | flex-wrap: nowrap; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/components/ScheduleProgress.module.css: -------------------------------------------------------------------------------- 1 | .schedule-progress { 2 | --progress-bar-height: 0.3rem; 3 | --progress-upcoming-step-size: 0.8rem; 4 | --progress-completed-step-size: 1.4rem; 5 | 6 | --progress-track-color: var(--bs-gray-300); 7 | --progress-step-upcoming-color: var(--bs-gray-300); 8 | --progress-step-completed-color: rgba(39, 174, 96, 1); 9 | --progress-step-active-color: rgba(39, 174, 96, 1); 10 | 11 | --progress-bar-corner-radius: calc( 12 | var(--progress-upcoming-step-size) + ((var(--progress-bar-height) - var(--progress-upcoming-step-size)) / 2) 13 | ); 14 | 15 | position: relative; 16 | } 17 | 18 | .progress-container { 19 | display: flex; 20 | position: relative; 21 | min-height: var(--progress-completed-step-size); 22 | top: calc((var(--progress-completed-step-size) - var(--progress-bar-height)) / 2); 23 | } 24 | 25 | /* progress bar background */ 26 | .progress-track { 27 | position: absolute; 28 | width: 100%; 29 | height: var(--progress-bar-height); 30 | background-color: var(--progress-track-color); 31 | border-radius: var(--progress-bar-corner-radius); 32 | z-index: -1; 33 | top: 0rem; 34 | } 35 | 36 | /* step marker container */ 37 | .progress-step { 38 | position: relative; 39 | display: flex; 40 | } 41 | 42 | /* step marker future */ 43 | .progress-step:before { 44 | content: ''; 45 | position: relative; 46 | top: calc((var(--progress-bar-height) - var(--progress-upcoming-step-size)) / 2); 47 | left: calc(0rem - ((var(--progress-bar-height) - var(--progress-upcoming-step-size)) / 2)); 48 | width: var(--progress-upcoming-step-size); 49 | height: var(--progress-upcoming-step-size); 50 | background: var(--progress-step-upcoming-color); 51 | border-radius: 100%; 52 | margin: 0 0 0 auto; 53 | } 54 | 55 | /* step marker active */ 56 | .progress-step.is-active:before { 57 | background: var(--progress-step-active-color); 58 | animation: pulse 2s infinite; 59 | } 60 | 61 | /* step marker complete */ 62 | .progress-step.is-complete:before { 63 | content: '\2713'; 64 | top: calc(0rem - ((var(--progress-completed-step-size) - var(--progress-bar-height)) / 2)); 65 | left: calc( 66 | (0rem - ((var(--progress-bar-height) - var(--progress-upcoming-step-size)) / 2)) + 67 | ((var(--progress-completed-step-size) - var(--progress-upcoming-step-size)) / 2) 68 | ); 69 | color: var(--progress-step-upcoming-color); 70 | display: flex; 71 | align-items: center; 72 | justify-content: center; 73 | width: var(--progress-completed-step-size); 74 | height: var(--progress-completed-step-size); 75 | background: var(--progress-step-completed-color); 76 | } 77 | 78 | /* progress bar */ 79 | .progress-step.is-complete:after { 80 | content: ''; 81 | position: absolute; 82 | width: 100%; 83 | height: var(--progress-bar-height); 84 | background: rgba(39, 174, 96, 0.4); 85 | z-index: -1; 86 | } 87 | 88 | /* progress bar left end */ 89 | .progress-step.is-complete.is-first:after { 90 | border-radius: var(--progress-bar-corner-radius) 0 0 var(--progress-bar-corner-radius); 91 | } 92 | 93 | .progress-step.is-active.is-first:after { 94 | border-radius: var(--progress-bar-corner-radius); 95 | } 96 | 97 | /* progress bar tip */ 98 | .progress-step.is-active:after { 99 | content: ''; 100 | position: absolute; 101 | width: 100%; 102 | border-radius: 0 var(--progress-bar-corner-radius) var(--progress-bar-corner-radius) 0; 103 | height: var(--progress-bar-height); 104 | background: rgba(39, 174, 96, 0.4); 105 | z-index: -1; 106 | animation: nextStep 1s; 107 | } 108 | 109 | .progress-step.is-last.is-complete:after { 110 | border-radius: 0 var(--progress-bar-corner-radius) var(--progress-bar-corner-radius) 0; 111 | } 112 | 113 | @keyframes pulse { 114 | 0% { 115 | box-shadow: 0 0 0 0 rgba(39, 174, 96, 0.4); 116 | } 117 | 70% { 118 | box-shadow: 0 0 0 10px rgba(39, 174, 96, 0); 119 | } 120 | 100% { 121 | box-shadow: 0 0 0 0 rgba(39, 174, 96, 0); 122 | } 123 | } 124 | 125 | @keyframes nextStep { 126 | 0% { 127 | width: 0%; 128 | } 129 | 100% { 130 | width: 100%; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/components/Seedphrase.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | gap: 0.3rem; 3 | } 4 | 5 | .item { 6 | background-color: rgba(244, 244, 244, 1); 7 | width: 9rem; 8 | } 9 | 10 | .item-index { 11 | width: 2ch; 12 | } 13 | 14 | :root[data-theme='dark'] .item { 15 | background-color: var(--bs-gray-800); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/Seedphrase.tsx: -------------------------------------------------------------------------------- 1 | import styles from './Seedphrase.module.css' 2 | 3 | interface SeedphraseProps { 4 | seedphrase: string 5 | isBlurred?: boolean 6 | centered?: boolean 7 | } 8 | 9 | export default function Seedphrase({ seedphrase, isBlurred = true, centered = false }: SeedphraseProps) { 10 | return ( 11 |
16 | {seedphrase.split(' ').map((seedWord, index) => ( 17 |
18 | {index + 1} 19 | 20 | {isBlurred ? 'abcdef' : seedWord} 21 |
22 | ))} 23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/SegmentedTabs.module.css: -------------------------------------------------------------------------------- 1 | .segmentedTabs { 2 | background-color: var(--bs-gray-300); 3 | border-radius: 0.25rem; 4 | padding: 0.25rem; 5 | width: 100%; 6 | } 7 | 8 | .segmentedTab > label { 9 | height: 100%; 10 | display: flex; 11 | flex-direction: column; 12 | justify-content: center; 13 | } 14 | 15 | :root[data-theme='dark'] .segmentedTabs { 16 | color: var(--bs-gray-100); 17 | background-color: var(--bs-gray-800); 18 | } 19 | 20 | .segmentedTab { 21 | flex: 1 1 0; 22 | } 23 | 24 | .segmentedTab input[type='radio'] { 25 | appearance: none; 26 | margin: 0; 27 | position: absolute; 28 | opacity: 0; 29 | height: 0; 30 | width: 0; 31 | } 32 | 33 | .segmentedTab label { 34 | background-color: white; 35 | padding: 0.25rem 1rem; 36 | border-radius: 0.1rem; 37 | font-weight: 500; 38 | width: 100%; 39 | text-align: center; 40 | } 41 | 42 | .segmentedTab input[type='radio']:disabled ~ label { 43 | background-color: transparent; 44 | color: var(--bs-gray-600); 45 | opacity: 0.5; 46 | } 47 | 48 | :root[data-theme='dark'] .segmentedTab input[type='radio']:disabled ~ label { 49 | color: var(--bs-gray-600); 50 | opacity: 0.5; 51 | } 52 | 53 | .segmentedTab input[type='radio']:not(:checked):not(:disabled) ~ label { 54 | background-color: transparent; 55 | } 56 | 57 | .segmentedTab input[type='radio']:not(:disabled) ~ label { 58 | cursor: pointer; 59 | } 60 | .segmentedTab input[type='radio']:checked:not(:disabled) ~ label { 61 | background-color: var(--bs-gray-100); 62 | box-shadow: 1px 1px 3px 1px rgba(0, 0, 0, 0.1); 63 | } 64 | 65 | :root[data-theme='dark'] .segmentedTab input[type='radio']:checked:not(:disabled) ~ label { 66 | background-color: var(--bs-gray-600); 67 | } 68 | 69 | .segmentedTab input[type='radio']:focus ~ label { 70 | box-shadow: 0 0 0 0.25rem var(--bs-focus-ring-color) !important; 71 | } 72 | -------------------------------------------------------------------------------- /src/components/SegmentedTabs.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent } from 'react' 2 | import * as rb from 'react-bootstrap' 3 | import styles from './SegmentedTabs.module.css' 4 | 5 | type SegmentedTabValue = string 6 | interface SegmentedTab { 7 | label: string 8 | value: SegmentedTabValue 9 | disabled?: boolean 10 | } 11 | 12 | const SegmentedTabFormCheck = ({ id, name, value, label, disabled, checked, onChange }: rb.FormCheckProps) => ( 13 |
14 | 15 | 16 |
17 | ) 18 | 19 | interface SegmentedTabsProps { 20 | name: string 21 | tabs: SegmentedTab[] 22 | onChange: (tab: SegmentedTab) => void 23 | value?: SegmentedTabValue 24 | disabled?: boolean 25 | } 26 | 27 | export default function SegmentedTabs({ name, tabs, onChange, value, disabled = false }: SegmentedTabsProps) { 28 | const _onChange = (e: ChangeEvent, tab: SegmentedTab) => { 29 | e.stopPropagation() 30 | onChange(tab) 31 | } 32 | 33 | return ( 34 |
35 |
36 | {tabs.map((tab, index) => { 37 | return ( 38 | _onChange(e, tab)} 48 | /> 49 | ) 50 | })} 51 |
52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/components/Send/AmountInputField.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | font-size: 0.875rem !important; 3 | } 4 | 5 | .inputGroupText { 6 | width: 5ch; 7 | display: inline-flex; 8 | justify-content: center; 9 | align-items: center; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/Send/AmountInputField.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | import { useTranslation } from 'react-i18next' 3 | import * as rb from 'react-bootstrap' 4 | import { useField, useFormikContext } from 'formik' 5 | import classNames from 'classnames' 6 | import Sprite from '../Sprite' 7 | import { AccountBalanceSummary } from '../../context/BalanceSummary' 8 | import { formatBtcDisplayValue } from '../../utils' 9 | import BitcoinAmountInput, { AmountValue } from '../BitcoinAmountInput' 10 | import styles from './AmountInputField.module.css' 11 | 12 | export type AmountInputFieldProps = { 13 | name: string 14 | label: string 15 | className?: string 16 | placeholder?: string 17 | isLoading: boolean 18 | disabled?: boolean 19 | enableSweep?: boolean 20 | sourceJarBalance?: AccountBalanceSummary 21 | } 22 | 23 | export const AmountInputField = ({ 24 | name, 25 | label, 26 | className, 27 | placeholder, 28 | isLoading, 29 | disabled = false, 30 | enableSweep = false, 31 | sourceJarBalance, 32 | }: AmountInputFieldProps) => { 33 | const { t } = useTranslation() 34 | const [field] = useField(name) 35 | const form = useFormikContext() 36 | const ref = useRef(null) 37 | 38 | return ( 39 | <> 40 | 41 | {label} 42 | 43 | {isLoading ? ( 44 | 45 | 46 | 47 | ) : ( 48 |
49 | 59 | {field.value?.isSweep === true && ( 60 | { 64 | form.setFieldValue(field.name, form.initialValues[field.name], true) 65 | setTimeout(() => ref.current?.focus(), 4) 66 | }} 67 | disabled={disabled} 68 | > 69 |
70 | 71 | <>{t('send.button_clear_sweep')} 72 |
73 |
74 | )} 75 | {enableSweep && field.value?.isSweep !== true && ( 76 | { 80 | if (!sourceJarBalance) return 81 | form.setFieldValue( 82 | field.name, 83 | { 84 | value: 0, 85 | isSweep: true, 86 | displayValue: formatBtcDisplayValue(sourceJarBalance.calculatedAvailableBalanceInSats), 87 | }, 88 | true, 89 | ) 90 | }} 91 | disabled={disabled || !sourceJarBalance} 92 | > 93 |
94 | 95 | {t('send.button_sweep')} 96 |
97 |
98 | )} 99 |
100 |
101 | )} 102 |
103 | 104 | ) 105 | } 106 | -------------------------------------------------------------------------------- /src/components/Send/CollaboratorsSelector.module.css: -------------------------------------------------------------------------------- 1 | .collaboratorsSelectorElement { 2 | min-width: 6rem; 3 | flex: 1; 4 | padding: 0.5rem; 5 | text-align: center; 6 | border-radius: var(--bs-border-radius); 7 | } 8 | 9 | :root[data-theme='dark'] input.collaboratorsSelectorElement { 10 | background-color: var(--bs-gray-800); 11 | color: var(--bs-white); 12 | } 13 | 14 | .collaboratorsSelectorElement.selected { 15 | border-color: var(--bs-dark-rgb) !important; 16 | } 17 | 18 | :root[data-theme='dark'] .collaboratorsSelectorElement.selected { 19 | background-color: var(--bs-gray-dark); 20 | border-color: var(--bs-gray-dark) !important; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Send/SendForm.module.css: -------------------------------------------------------------------------------- 1 | .blurred { 2 | filter: blur(2px); 3 | } 4 | 5 | .input { 6 | height: 3.5rem; 7 | width: 100%; 8 | border-radius: 0.25rem; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Send/ShowUtxos.module.css: -------------------------------------------------------------------------------- 1 | .utxoListDisplayHeight { 2 | max-height: 15.7rem; 3 | } 4 | 5 | .row { 6 | --jam-utxo-bg-color: transparent; 7 | --jam-utxo-checkbox-accent-color: var(--jam-utxo-color); 8 | background-color: var(--jam-utxo-bg-color) !important; 9 | color: var(--jam-utxo-color) !important; 10 | } 11 | 12 | .row .checkbox { 13 | width: 24px; 14 | height: 24px; 15 | accent-color: var(--jam-utxo-checkbox-accent-color); 16 | } 17 | 18 | .row.row-normal { 19 | --jam-utxo-bg-color: transparent; 20 | --jam-utxo-checkbox-accent-color: var(--bs-black); 21 | } 22 | .row.row-success { 23 | --jam-utxo-bg-color: #27ae600d; 24 | --jam-utxo-color: #27ae60; 25 | } 26 | .row.row-warning { 27 | --jam-utxo-bg-color: #bb97200d; 28 | --jam-utxo-color: #ebc957; 29 | } 30 | .row.row-danger { 31 | --jam-utxo-bg-color: #eb57570d; 32 | --jam-utxo-color: #eb5757; 33 | } 34 | .row.row-frozen { 35 | --jam-utxo-bg-color: #2d9cdb0d; 36 | --jam-utxo-color: #2d9cdb; 37 | --jam-utxo-checkbox-accent-color: var(--jam-utxo-color); 38 | } 39 | 40 | .row.row-dark { 41 | --bs-code-color: var(--bs-gray-600); 42 | --jam-utxo-bg-color: var(--bs-gray-200); 43 | --jam-utxo-color: var(--bs-gray-600); 44 | } 45 | :root[data-theme='dark'] .row.row-dark { 46 | --bs-code-color: var(--bs-gray-900); 47 | --jam-utxo-bg-color: var(--bs-gray-700); 48 | --jam-utxo-color: var(--bs-gray-900); 49 | } 50 | -------------------------------------------------------------------------------- /src/components/Send/SourceJarSelector.module.css: -------------------------------------------------------------------------------- 1 | .sourceJarsContainer { 2 | display: flex; 3 | flex-wrap: wrap; 4 | flex-direction: row; 5 | justify-content: space-evenly; 6 | align-items: center; 7 | gap: 1rem; 8 | color: var(--bs-body-color); 9 | margin-bottom: 1.5rem; 10 | margin-top: 2rem; 11 | } 12 | 13 | .sourceJarsPlaceholder { 14 | width: 100%; 15 | height: 8rem; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/Send/SweepBreakdown.module.css: -------------------------------------------------------------------------------- 1 | .sweepBreakdown { 2 | font-size: 0.8rem; 3 | } 4 | 5 | .sweepBreakdownTable { 6 | color: var(--bs-dark); 7 | } 8 | 9 | :root[data-theme='dark'] .sweepBreakdownTable { 10 | color: var(--bs-light); 11 | } 12 | 13 | .sweepBreakdownTable .balanceCol { 14 | text-align: right; 15 | } 16 | 17 | .sweepBreakdownAnchor { 18 | font-size: 0.8rem; 19 | color: var(--bs-dark); 20 | } 21 | 22 | :root[data-theme='dark'] .sweepBreakdownAnchor { 23 | color: var(--bs-light) !important; 24 | } 25 | 26 | :root[data-theme='dark'] .sweepBreakdownParagraph { 27 | color: var(--bs-light) !important; 28 | } 29 | 30 | .accordionButton { 31 | background-color: transparent; 32 | color: var(--bs-dark); 33 | display: flex; 34 | justify-content: flex-end; 35 | font-size: 0.8rem; 36 | height: 1rem; 37 | padding: 0; 38 | box-shadow: none; 39 | border: none; 40 | width: 100%; 41 | } 42 | 43 | .accordionButton:hover { 44 | text-decoration: underline; 45 | } 46 | 47 | .accordionButton::after { 48 | width: 0rem; 49 | height: 0rem; 50 | background-image: none; 51 | } 52 | 53 | :root[data-theme='dark'] .accordionButton { 54 | color: var(--bs-light); 55 | } 56 | -------------------------------------------------------------------------------- /src/components/Send/helpers.ts: -------------------------------------------------------------------------------- 1 | import { isValidNumber } from '../../utils' 2 | 3 | export const MAX_NUM_COLLABORATORS = 99 4 | 5 | export const initialNumCollaborators = (minValue: number): number => { 6 | if (minValue > 8) { 7 | return minValue + pseudoRandomNumber(0, 2) 8 | } 9 | 10 | return pseudoRandomNumber(8, 10) 11 | } 12 | 13 | // not cryptographically random. returned number is in range [min, max] (both inclusive). 14 | export const pseudoRandomNumber = (min: number, max: number) => { 15 | return Math.round(Math.random() * (max - min)) + min 16 | } 17 | 18 | export const isValidAddress = (candidate: string | null) => { 19 | return typeof candidate === 'string' && !(candidate === '') 20 | } 21 | 22 | export const isValidJarIndex = (candidate: number) => { 23 | return isValidNumber(candidate) && candidate >= 0 24 | } 25 | 26 | export const isValidAmount = (candidate: number | null, isSweep: boolean) => { 27 | return candidate !== null && isValidNumber(candidate) && (isSweep ? candidate === 0 : candidate > 0) 28 | } 29 | 30 | export const isValidNumCollaborators = (candidate: number | null, minNumCollaborators: number) => { 31 | return ( 32 | candidate !== null && 33 | isValidNumber(candidate) && 34 | candidate >= minNumCollaborators && 35 | candidate <= MAX_NUM_COLLABORATORS 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/components/Settings.module.css: -------------------------------------------------------------------------------- 1 | .settings .settings-btn { 2 | width: 100%; 3 | display: inline-flex; 4 | align-items: center; 5 | gap: 0.5rem; 6 | 7 | border: none; 8 | text-align: var(--bs-body-text-align); 9 | } 10 | 11 | .settings .settings-btn > svg:first-child { 12 | min-width: 24px; 13 | min-height: 24px; 14 | } 15 | 16 | .settings-group-container { 17 | display: flex; 18 | flex-direction: column; 19 | gap: 0.5rem; 20 | margin-bottom: 0.25rem; 21 | } 22 | 23 | .settings-links { 24 | display: flex; 25 | flex-direction: column; 26 | gap: 1rem; 27 | margin-bottom: 0.25rem; 28 | padding: 0.375rem 0.75rem; 29 | } 30 | 31 | .section-title { 32 | font-size: 1.3rem; 33 | font-weight: 600; 34 | margin-bottom: 0; 35 | } 36 | -------------------------------------------------------------------------------- /src/components/Settings.test.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter } from 'react-router-dom' 2 | import { render, screen, act } from '../testUtils' 3 | 4 | import Settings from './Settings' 5 | import { CurrentWallet } from '../context/WalletContext' 6 | 7 | const dummyWalletFileName = 'dummy.jmdat' 8 | const dummyToken = 'dummyToken' 9 | 10 | describe('', () => { 11 | const setup = ({ wallet }: { wallet: CurrentWallet }) => { 12 | render( 13 | 14 | ({})} /> 15 | , 16 | ) 17 | } 18 | 19 | it('should render settings without errors', async () => { 20 | await act(async () => 21 | setup({ 22 | wallet: { 23 | walletFileName: dummyWalletFileName, 24 | displayName: dummyWalletFileName, 25 | token: dummyToken, 26 | }, 27 | }), 28 | ) 29 | 30 | expect(screen.getByText('settings.section_title_display')).toBeVisible() 31 | expect(screen.queryByText(/settings.(show|hide)_balance/)).toBeVisible() 32 | expect(screen.queryByText(/settings.use_(sats|bitcoin)/)).toBeVisible() 33 | expect(screen.queryByText(/settings.use_(dark|light)_theme/)).toBeVisible() 34 | expect(screen.queryByText(/English/)).toBeVisible() 35 | 36 | expect(screen.getByText('settings.section_title_market')).toBeVisible() 37 | expect(screen.queryByText(/settings.show_fee_config/)).toBeVisible() 38 | 39 | expect(screen.getByText('settings.section_title_wallet')).toBeVisible() 40 | expect(screen.queryByText(/settings.(show|hide)_seed/)).toBeVisible() 41 | expect(screen.queryByText(/settings.button_lock_wallet/)).toBeVisible() 42 | expect(screen.queryByText(/settings.button_switch_wallet/)).toBeVisible() 43 | 44 | expect(screen.getByText('settings.section_title_community')).toBeVisible() 45 | expect(screen.queryByText(/settings.matrix/)).toBeVisible() 46 | expect(screen.queryByText(/settings.telegram/)).toBeVisible() 47 | 48 | expect(screen.getByText('settings.section_title_community')).toBeVisible() 49 | expect(screen.queryByText(/settings.documentation/)).toBeVisible() 50 | expect(screen.queryByText(/settings.github/)).toBeVisible() 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /src/components/ShareButton.tsx: -------------------------------------------------------------------------------- 1 | import * as rb from 'react-bootstrap' 2 | import Sprite from './Sprite' 3 | 4 | const checkIsWebShareAPISupported = () => { 5 | return !!navigator.share 6 | } 7 | 8 | type ShareButtonProps = Omit & { 9 | value: string 10 | } 11 | 12 | const ShareButton = ({ value, ...buttonProps }: ShareButtonProps) => { 13 | const handleShare = async () => { 14 | if (!checkIsWebShareAPISupported()) { 15 | console.error('Sharing failed: Web Share API not supported.') 16 | return 17 | } 18 | 19 | try { 20 | await navigator.share({ 21 | text: value, 22 | }) 23 | } catch (error) { 24 | console.error(`Sharing failed: ${error}`) 25 | } 26 | } 27 | 28 | return ( 29 | 30 |
31 | 32 | Share 33 |
34 |
35 | ) 36 | } 37 | 38 | export { ShareButton, checkIsWebShareAPISupported } 39 | -------------------------------------------------------------------------------- /src/components/Sprite.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface SpriteProps extends React.SVGProps { 4 | symbol: string 5 | className?: string 6 | } 7 | 8 | export default function Sprite({ symbol, className, ...props }: SpriteProps) { 9 | return ( 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/TablePagination.module.css: -------------------------------------------------------------------------------- 1 | .paginationSelect { 2 | height: 32px; 3 | padding-top: 0 !important; 4 | padding-bottom: 0 !important; 5 | border-color: var(--bs-btn-border-color) !important; 6 | } 7 | 8 | .paginationSelect option { 9 | /* For chromium based browser (Firefox applies own styles to