diff --git a/docker/regtest/readme.md b/docker/regtest/readme.md index 0ef192c25..820767a55 100644 --- a/docker/regtest/readme.md +++ b/docker/regtest/readme.md @@ -1,7 +1,31 @@ -# Docker setup for running JoinMarket in regtest mode +# Docker setup for running Jam in regtest mode This setup will help you set up a regtest environment quickly. -It starts two JoinMarket containers, hence not only API calls but also actual CoinJoin transactions can be tested. +It starts multiple JoinMarket containers, hence not only API calls but also actual CoinJoin transactions can be tested. +Communication between these containers is done via Tor (if internet connection is available) and IRC (locally running container). + +## Common flow +```sh +# (optional) once in a while rebuild the images +npm run regtest:rebuild + +# start the regtest environment +npm run regtest:up + +# fund wallets and start maker +./docker/regtest/init-setup.sh + +# mine blocks in regtest periodically +npm run regtest:mine + +[...] + +# stop the regtest environment +npm run regtest:down + +# (optional) wipe all test data and start from scratch next time +npm run regtest:clear +``` ## Commands @@ -41,6 +65,8 @@ The second JoinMarket container is based on `joinmarket-webui/joinmarket-webui-d (username `joinmarket` and pass `joinmarket` for Basic Authentication). This is useful if you want to perform regression tests. +The third JoinMarket container acts as [Directory Node](https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/docs/onion-message-channels.md#directory) and exists solely to enable communication between peers. + ### Rebuild In order to incorporate the current contents of `master` branch, simply rebuild the joinmarket images from scratch. @@ -69,6 +95,13 @@ docker exec -t jm_regtest_joinmarket git log --oneline -1 Some helper scripts are included to make recurring tasks and interaction with the containers easier. +### `npm run regtest:mine` + +Mine regtest blocks in a fixed interval (current default is every 10 seconds). +This is useful for features that await confirmations or need incoming blocks regularly. +e.g. This is necessary for scheduled transactions to execute successfully. + + ### `init-setup.sh` This script helps in providing both JoinMarket containers a wallet with spendable coins and starting the Maker Service in the secondary container. diff --git a/package-lock.json b/package-lock.json index ff73e751c..15e70413f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,8 @@ "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^12.1.4", "@testing-library/user-event": "^13.5.0", - "@types/jest": "^27.4.1", - "@types/node": "^17.0.26", + "@types/jest": "^27.5.1", + "@types/node": "^17.0.35", "@types/react": "^17.0.43", "@types/react-dom": "^17.0.14", "bootstrap": "^5.1.3", @@ -26,24 +26,23 @@ "conventional-changelog": "^3.1.25", "formik": "^2.2.9", "http-proxy-middleware": "^2.0.6", - "husky": "^7.0.4", - "i18next": "^21.6.16", + "husky": "^8.0.1", + "i18next": "^21.8.4", "i18next-browser-languagedetector": "^6.1.4", "jest-watch-typeahead": "^0.6.5", "jest-websocket-mock": "^2.3.0", - "lint-staged": "^12.4.0", + "lint-staged": "^12.4.2", "moving-letters": "^1.0.1", - "nth-check": ">=2.0.1", "prettier": "^2.6.2", "qrcode": "^1.5.0", "react": "^17.0.2", - "react-bootstrap": "^2.3.0", + "react-bootstrap": "^2.4.0", "react-dom": "^17.0.2", - "react-i18next": "^11.16.7", + "react-i18next": "^11.16.9", "react-router-bootstrap": "^0.26.1", "react-router-dom": "^6.3.0", "react-scripts": "^5.0.1", - "typescript": "^4.6.3", + "typescript": "^4.7.2", "web-vitals": "^2.1.4" }, "engines": { @@ -3350,9 +3349,9 @@ } }, "node_modules/@types/jest": { - "version": "27.4.1", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.4.1.tgz", - "integrity": "sha512-23iPJADSmicDVrWk+HT58LMJtzLAnB2AgIzplQuq/bSrGaxCrlvRFjGbXmamnnk/mAmCdLStiGqggu28ocUyiw==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.5.1.tgz", + "integrity": "sha512-fUy7YRpT+rHXto1YlL+J9rs0uLGyiqVt3ZOTQR+4ROc47yNl8WLdVLgUloBRhOxP1PZvguHl44T3H0wAWxahYQ==", "dev": true, "dependencies": { "jest-matcher-utils": "^27.0.0", @@ -3384,9 +3383,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "17.0.26", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.26.tgz", - "integrity": "sha512-z/FG/6DUO7pnze3AE3TBGIjGGKkvCcGcWINe1C7cADY8hKLJPDYpzsNE37uExQ4md5RFtTCvg+M8Mu1Enyeg2A==", + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.35.tgz", + "integrity": "sha512-vu1SrqBjbbZ3J6vwY17jBs8Sr/BKA+/a/WtjRG+whKg1iuLFOosq872EXS0eXWILdO36DHQQeku/ZcL6hz2fpg==", "dev": true }, "node_modules/@types/normalize-package-data": { @@ -9443,24 +9442,24 @@ } }, "node_modules/husky": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/husky/-/husky-7.0.4.tgz", - "integrity": "sha512-vbaCKN2QLtP/vD4yvs6iz6hBEo6wkSzs8HpRah1Z6aGmF2KW5PdYuAd7uX5a+OyBZHBhd+TFLqgjUgytQr4RvQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.1.tgz", + "integrity": "sha512-xs7/chUH/CKdOCs7Zy0Aev9e/dKOMZf3K1Az1nar3tzlv0jfqnYtu235bstsWTmXOR0EfINrPa97yy4Lz6RiKw==", "dev": true, "bin": { "husky": "lib/bin.js" }, "engines": { - "node": ">=12" + "node": ">=14" }, "funding": { "url": "https://github.com/sponsors/typicode" } }, "node_modules/i18next": { - "version": "21.6.16", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.6.16.tgz", - "integrity": "sha512-xJlzrVxG9CyAGsbMP1aKuiNr1Ed2m36KiTB7hjGMG2Zo4idfw3p9THUEu+GjBwIgEZ7F11ZbCzJcfv4uyfKNuw==", + "version": "21.8.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.8.4.tgz", + "integrity": "sha512-b3LQ5n9V1juu8UItb5x1QTI4OTvNqsNs/wetwQlBvfijEqks+N5HKMKSoevf8w0/RGUrDQ7g4cvVzF8WBp9pUw==", "dev": true, "funding": [ { @@ -11822,9 +11821,9 @@ "dev": true }, "node_modules/lint-staged": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-12.4.0.tgz", - "integrity": "sha512-3X7MR0h9b7qf4iXf/1n7RlVAx+EzpAZXoCEMhVSpaBlgKDfH2ewf+QUm7BddFyq29v4dgPP+8+uYpWuSWx035A==", + "version": "12.4.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-12.4.2.tgz", + "integrity": "sha512-JAJGIzY/OioIUtrRePr8go6qUxij//mL+RGGoFKU3VWQRtIHgWoHizSqH0QVn2OwrbXS9Q6CICQjfj+E5qvrXg==", "dev": true, "dependencies": { "cli-truncate": "^3.1.0", @@ -14959,9 +14958,9 @@ } }, "node_modules/react-bootstrap": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.3.0.tgz", - "integrity": "sha512-O8DU/R3CHLqj1IGfqZD1mOm9Jx6tm8wmfIIshNeaIdd5AZnlO7eOPF7UyzijAxNIogN0/U3U4tWIH+9gQYlwVA==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.4.0.tgz", + "integrity": "sha512-dn599jNK1Fg5GGjJH+lQQDwELVzigh/MdusKpB/0el+sCjsO5MZDH5gRMmBjRhC+vb7VlCDr6OXffPIDSkNMLw==", "dev": true, "dependencies": { "@babel/runtime": "^7.17.2", @@ -15119,9 +15118,9 @@ "dev": true }, "node_modules/react-i18next": { - "version": "11.16.7", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.16.7.tgz", - "integrity": "sha512-7yotILJLnKfvUfrl/nt9eK9vFpVFjZPLWAwBzWL6XppSZZEvlmlKk0GBGDCAPfLfs8oND7WAbry8wGzdoiW5Nw==", + "version": "11.16.9", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.16.9.tgz", + "integrity": "sha512-euXxWvcEAvsY7ZVkwx9ztCq4butqtsGHEkpkuo0RMj8Ru09IF9o2KxCyN+zyv51Nr0aBh/elaTIiR6fMb8YfVg==", "dev": true, "dependencies": { "@babel/runtime": "^7.14.5", @@ -17753,9 +17752,9 @@ } }, "node_modules/typescript": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz", - "integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==", + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.2.tgz", + "integrity": "sha512-Mamb1iX2FDUpcTRzltPxgWMKy3fhg0TN378ylbktPGPK/99KbDtMQ4W1hwgsbPAsG3a0xKa1vmw4VKZQbkvz5A==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -21420,9 +21419,9 @@ } }, "@types/jest": { - "version": "27.4.1", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.4.1.tgz", - "integrity": "sha512-23iPJADSmicDVrWk+HT58LMJtzLAnB2AgIzplQuq/bSrGaxCrlvRFjGbXmamnnk/mAmCdLStiGqggu28ocUyiw==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.5.1.tgz", + "integrity": "sha512-fUy7YRpT+rHXto1YlL+J9rs0uLGyiqVt3ZOTQR+4ROc47yNl8WLdVLgUloBRhOxP1PZvguHl44T3H0wAWxahYQ==", "dev": true, "requires": { "jest-matcher-utils": "^27.0.0", @@ -21454,9 +21453,9 @@ "dev": true }, "@types/node": { - "version": "17.0.26", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.26.tgz", - "integrity": "sha512-z/FG/6DUO7pnze3AE3TBGIjGGKkvCcGcWINe1C7cADY8hKLJPDYpzsNE37uExQ4md5RFtTCvg+M8Mu1Enyeg2A==", + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.35.tgz", + "integrity": "sha512-vu1SrqBjbbZ3J6vwY17jBs8Sr/BKA+/a/WtjRG+whKg1iuLFOosq872EXS0eXWILdO36DHQQeku/ZcL6hz2fpg==", "dev": true }, "@types/normalize-package-data": { @@ -26088,15 +26087,15 @@ "dev": true }, "husky": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/husky/-/husky-7.0.4.tgz", - "integrity": "sha512-vbaCKN2QLtP/vD4yvs6iz6hBEo6wkSzs8HpRah1Z6aGmF2KW5PdYuAd7uX5a+OyBZHBhd+TFLqgjUgytQr4RvQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.1.tgz", + "integrity": "sha512-xs7/chUH/CKdOCs7Zy0Aev9e/dKOMZf3K1Az1nar3tzlv0jfqnYtu235bstsWTmXOR0EfINrPa97yy4Lz6RiKw==", "dev": true }, "i18next": { - "version": "21.6.16", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.6.16.tgz", - "integrity": "sha512-xJlzrVxG9CyAGsbMP1aKuiNr1Ed2m36KiTB7hjGMG2Zo4idfw3p9THUEu+GjBwIgEZ7F11ZbCzJcfv4uyfKNuw==", + "version": "21.8.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.8.4.tgz", + "integrity": "sha512-b3LQ5n9V1juu8UItb5x1QTI4OTvNqsNs/wetwQlBvfijEqks+N5HKMKSoevf8w0/RGUrDQ7g4cvVzF8WBp9pUw==", "dev": true, "requires": { "@babel/runtime": "^7.17.2" @@ -27937,9 +27936,9 @@ "dev": true }, "lint-staged": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-12.4.0.tgz", - "integrity": "sha512-3X7MR0h9b7qf4iXf/1n7RlVAx+EzpAZXoCEMhVSpaBlgKDfH2ewf+QUm7BddFyq29v4dgPP+8+uYpWuSWx035A==", + "version": "12.4.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-12.4.2.tgz", + "integrity": "sha512-JAJGIzY/OioIUtrRePr8go6qUxij//mL+RGGoFKU3VWQRtIHgWoHizSqH0QVn2OwrbXS9Q6CICQjfj+E5qvrXg==", "dev": true, "requires": { "cli-truncate": "^3.1.0", @@ -30123,9 +30122,9 @@ } }, "react-bootstrap": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.3.0.tgz", - "integrity": "sha512-O8DU/R3CHLqj1IGfqZD1mOm9Jx6tm8wmfIIshNeaIdd5AZnlO7eOPF7UyzijAxNIogN0/U3U4tWIH+9gQYlwVA==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.4.0.tgz", + "integrity": "sha512-dn599jNK1Fg5GGjJH+lQQDwELVzigh/MdusKpB/0el+sCjsO5MZDH5gRMmBjRhC+vb7VlCDr6OXffPIDSkNMLw==", "dev": true, "requires": { "@babel/runtime": "^7.17.2", @@ -30242,9 +30241,9 @@ "dev": true }, "react-i18next": { - "version": "11.16.7", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.16.7.tgz", - "integrity": "sha512-7yotILJLnKfvUfrl/nt9eK9vFpVFjZPLWAwBzWL6XppSZZEvlmlKk0GBGDCAPfLfs8oND7WAbry8wGzdoiW5Nw==", + "version": "11.16.9", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.16.9.tgz", + "integrity": "sha512-euXxWvcEAvsY7ZVkwx9ztCq4butqtsGHEkpkuo0RMj8Ru09IF9o2KxCyN+zyv51Nr0aBh/elaTIiR6fMb8YfVg==", "dev": true, "requires": { "@babel/runtime": "^7.14.5", @@ -32203,9 +32202,9 @@ } }, "typescript": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz", - "integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==", + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.2.tgz", + "integrity": "sha512-Mamb1iX2FDUpcTRzltPxgWMKy3fhg0TN378ylbktPGPK/99KbDtMQ4W1hwgsbPAsG3a0xKa1vmw4VKZQbkvz5A==", "dev": true }, "uglify-js": { diff --git a/package.json b/package.json index a0e010ff6..168112701 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^12.1.4", "@testing-library/user-event": "^13.5.0", - "@types/jest": "^27.4.1", - "@types/node": "^17.0.26", + "@types/jest": "^27.5.1", + "@types/node": "^17.0.35", "@types/react": "^17.0.43", "@types/react-dom": "^17.0.14", "bootstrap": "^5.1.3", @@ -24,24 +24,23 @@ "conventional-changelog": "^3.1.25", "formik": "^2.2.9", "http-proxy-middleware": "^2.0.6", - "husky": "^7.0.4", - "i18next": "^21.6.16", + "husky": "^8.0.1", + "i18next": "^21.8.4", "i18next-browser-languagedetector": "^6.1.4", "jest-watch-typeahead": "^0.6.5", "jest-websocket-mock": "^2.3.0", - "lint-staged": "^12.4.0", + "lint-staged": "^12.4.2", "moving-letters": "^1.0.1", - "nth-check": ">=2.0.1", "prettier": "^2.6.2", "qrcode": "^1.5.0", "react": "^17.0.2", - "react-bootstrap": "^2.3.0", + "react-bootstrap": "^2.4.0", "react-dom": "^17.0.2", - "react-i18next": "^11.16.7", + "react-i18next": "^11.16.9", "react-router-bootstrap": "^0.26.1", "react-router-dom": "^6.3.0", "react-scripts": "^5.0.1", - "typescript": "^4.6.3", + "typescript": "^4.7.2", "web-vitals": "^2.1.4" }, "dependencies": { diff --git a/src/components/App.jsx b/src/components/App.jsx index 0937dae62..9003184da 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -100,7 +100,7 @@ export default function App() { )} - + {sessionConnectionError && ( {t('app.alert_no_connection', { connectionError: sessionConnectionError.message })}. diff --git a/src/components/Cheatsheet.module.css b/src/components/Cheatsheet.module.css new file mode 100644 index 000000000..4a1a360ee --- /dev/null +++ b/src/components/Cheatsheet.module.css @@ -0,0 +1,52 @@ +.cheatsheet { + height: auto !important; + /* page height - navbar height - some spacing*/ + max-height: calc(100vh - 76px - 1rem) !important; + margin: 0px auto !important; + border: none !important; + border-top-left-radius: 1rem; + border-top-right-radius: 1rem; + width: 528px; + + box-shadow: 6px -3px 12px 3px rgba(0, 0, 0, 0.1); +} + +:root[data-theme='dark'] .cheatsheet { + box-shadow: 6px -3px 12px 3px rgba(0, 0, 0, 0.5); +} + +.cheatsheet button[aria-label='Close'] { + margin-bottom: auto; +} + +.cheatsheet a { + color: inherit !important; +} + +.cheatsheet .cheatsheet-list-item { + align-items: start; +} + +.cheatsheet-list-item.upcoming-feature { + opacity: 0.25; +} + +.cheatsheet-list-item h6 { + margin-bottom: 0.1rem; +} + +.numbered { + display: flex; + justify-content: center; + align-items: center; + min-width: 2rem; + height: 2rem; + border-radius: 50%; + background-color: rgb(0, 0, 0); + color: white; +} + +:root[data-theme='dark'] .numbered { + color: rgb(0, 0, 0); + background-color: white; +} diff --git a/src/components/Cheatsheet.tsx b/src/components/Cheatsheet.tsx index f7dc0c4f9..b8e3993cf 100644 --- a/src/components/Cheatsheet.tsx +++ b/src/components/Cheatsheet.tsx @@ -3,6 +3,8 @@ import * as rb from 'react-bootstrap' import { Link } from 'react-router-dom' import { Trans, useTranslation } from 'react-i18next' import { routes } from '../constants/routes' +import Sprite from './Sprite' +import styles from './Cheatsheet.module.css' interface CheatsheetProps { show: boolean @@ -10,17 +12,27 @@ interface CheatsheetProps { } type NumberedProps = { - number: number + number: number | 'last' className?: string } -function Numbered({ number }: { number: number }) { - return
{number}
+function Numbered({ number }: NumberedProps) { + return ( +
+ {number === 'last' ? ( + <> + + + ) : ( + <>{number} + )} +
+ ) } function ListItem({ number, children, ...props }: PropsWithChildren) { return ( - + {children} @@ -31,7 +43,7 @@ export default function Cheatsheet({ show = false, onHide }: CheatsheetProps) { const { t } = useTranslation() return ( - + {t('cheatsheet.title')} @@ -83,7 +95,7 @@ export default function Cheatsheet({ show = false, onHide }: CheatsheetProps) {
{t('cheatsheet.item_2.description')}
- +
Optional: Lock funds in a fidelity bond. @@ -105,9 +117,17 @@ export default function Cheatsheet({ show = false, onHide }: CheatsheetProps) {
{t('cheatsheet.item_4.description')}
-
{t('cheatsheet.item_5.title')}
+
+ + Send a collaborative transaction to yourself. + +
+
{t('cheatsheet.item_5.description')}
+
+ +
{t('cheatsheet.item_last.title')}
- + Still confused?{' '} { )} + {isSubmitting && ( +
+

{t('create_wallet.hint_duration_text')}

+
+ )} )} diff --git a/src/components/Send.jsx b/src/components/Send.jsx index 6f885c8d0..17b680f83 100644 --- a/src/components/Send.jsx +++ b/src/components/Send.jsx @@ -8,6 +8,7 @@ import Sprite from './Sprite' import Balance from './Balance' import { useReloadCurrentWalletInfo, useCurrentWallet, useCurrentWalletInfo } from '../context/WalletContext' import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext' +import { useLoadConfigValue } from '../context/ServiceConfigContext' import { useSettings } from '../context/SettingsContext' import { useBalanceSummary } from '../hooks/BalanceSummary' import * as Api from '../libs/JmWalletApi' @@ -135,15 +136,23 @@ const enhanceDirectPaymentErrorMessageIfNecessary = async (httpStatus, errorMess } const enhanceTakerErrorMessageIfNecessary = async ( - requestContext, + loadConfigValue, httpStatus, errorMessage, onMaxFeeSettingsMissing ) => { - const configExists = (section, field) => Api.postConfigGet(requestContext, { section, field }).then((res) => res.ok) - const tryEnhanceMessage = httpStatus === 409 if (tryEnhanceMessage) { + const abortCtrl = new AbortController() + + const configExists = (section, field) => + loadConfigValue({ + signal: abortCtrl.signal, + key: { section, field }, + }) + .then((val) => val.value !== null) + .catch(() => false) + const maxFeeSettingsPresent = await Promise.all([ configExists('POLICY', 'max_cj_fee_rel'), configExists('POLICY', 'max_cj_fee_abs'), @@ -176,6 +185,7 @@ export default function Send() { const reloadCurrentWalletInfo = useReloadCurrentWalletInfo() const serviceInfo = useServiceInfo() const reloadServiceInfo = useReloadServiceInfo() + const loadConfigValue = useLoadConfigValue() const settings = useSettings() const location = useLocation() @@ -325,11 +335,12 @@ export default function Send() { !abortCtrl.signal.aborted && setAlert({ variant: 'danger', message }) }) - const requestContext = { walletName: wallet.name, token: wallet.token, signal: abortCtrl.signal } - const loadingMinimumMakerConfig = Api.postConfigGet(requestContext, { section: 'POLICY', field: 'minimum_makers' }) - .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res, t('send.error_loading_min_makers_failed')))) + const loadingMinimumMakerConfig = loadConfigValue({ + signal: abortCtrl.signal, + key: { section: 'POLICY', field: 'minimum_makers' }, + }) .then((data) => { - const minimumMakers = parseInt(data.configvalue, 10) + const minimumMakers = parseInt(data.value, 10) setMinNumCollaborators(minimumMakers) setNumCollaborators(initialNumCollaborators(minimumMakers)) }) @@ -410,7 +421,7 @@ export default function Send() { } else { const message = await Api.Helper.extractErrorMessage(res) const displayMessage = await enhanceTakerErrorMessageIfNecessary( - requestContext, + loadConfigValue, res.status, message, (errorMessage) => `${errorMessage} ${t('send.taker_error_message_max_fees_config_missing')}` diff --git a/src/context/ServiceConfigContext.tsx b/src/context/ServiceConfigContext.tsx new file mode 100644 index 000000000..80d5a65e2 --- /dev/null +++ b/src/context/ServiceConfigContext.tsx @@ -0,0 +1,143 @@ +import React, { createContext, useCallback, useContext, useEffect, useRef } from 'react' +// @ts-ignore +import { CurrentWallet, useCurrentWallet } from './WalletContext' + +import * as Api from '../libs/JmWalletApi' + +interface JmConfigData { + configvalue: string +} + +type SectionKey = string + +interface ServiceConfig { + [key: SectionKey]: Record +} + +interface ConfigKey { + section: SectionKey + field: string +} + +interface ServiceConfigUpdate { + key: ConfigKey + value: string +} + +type LoadConfigValueProps = { + signal: AbortSignal + key: ConfigKey +} + +const configReducer = (state: ServiceConfig, obj: ServiceConfigUpdate): ServiceConfig => { + const data = { ...state } + data[obj.key.section] = { ...data[obj.key.section], [obj.key.field]: obj.value } + return data +} + +const fetchConfigValues = async ({ + signal, + wallet, + configKeys, +}: { + signal: AbortSignal + wallet: CurrentWallet + configKeys: ConfigKey[] +}) => { + const { name: walletName, token } = wallet + const fetches: Promise[] = configKeys.map((configKey) => { + return Api.postConfigGet( + { walletName, token, signal }, + { section: configKey.section.toString(), field: configKey.field } + ) + .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res))) + .then((data: JmConfigData) => { + return { + key: configKey, + value: data.configvalue, + } as ServiceConfigUpdate + }) + }) + + return Promise.all(fetches) +} + +interface ServiceConfigContextEntry { + loadConfigValue: (props: LoadConfigValueProps) => Promise +} + +const ServiceConfigContext = createContext(undefined) + +const ServiceConfigProvider = ({ children }: React.PropsWithChildren<{}>) => { + const currentWallet = useCurrentWallet() + const serviceConfig = useRef(null) + + const updateServiceConfig = useCallback( + async ({ signal, configKeys }: { signal: AbortSignal; configKeys: ConfigKey[] }) => { + if (!currentWallet) { + throw new Error('Cannot load config: Wallet not present') + } + + const configUpdates = fetchConfigValues({ signal, wallet: currentWallet, configKeys }) + return configUpdates + .then((updates) => updates.reduce(configReducer, serviceConfig.current || {})) + .then((result) => { + if (!signal.aborted) { + serviceConfig.current = result + if (process.env.NODE_ENV === 'development') { + console.debug('service config updated', serviceConfig.current) + } + } + return result + }) + }, + [currentWallet] + ) + + const loadConfigValueIfAbsent = useCallback( + async ({ signal, key }: LoadConfigValueProps) => { + if (serviceConfig.current) { + const valueAlreadyPresent = + serviceConfig.current[key.section] && serviceConfig.current[key.section][key.field] !== undefined + + if (valueAlreadyPresent) { + return { + key, + value: serviceConfig.current[key.section][key.field], + } as ServiceConfigUpdate + } + } + + return updateServiceConfig({ signal, configKeys: [key] }).then((conf) => { + return { + key, + value: conf[key.section][key.field], + } as ServiceConfigUpdate + }) + }, + [updateServiceConfig] + ) + + useEffect(() => { + if (!currentWallet) { + // reset service config if wallet changed + serviceConfig.current = null + } + }, [currentWallet]) + + return ( + + {children} + + ) +} + +const useLoadConfigValue = () => { + const context = useContext(ServiceConfigContext) + if (context === undefined) { + throw new Error('useLoadConfigValue must be used within a ServiceConfigProvider') + } + return context.loadConfigValue +} + +export { ServiceConfigContext, ServiceConfigProvider, useLoadConfigValue } diff --git a/src/context/ServiceInfoContext.tsx b/src/context/ServiceInfoContext.tsx index 7a3113632..75d8fab00 100644 --- a/src/context/ServiceInfoContext.tsx +++ b/src/context/ServiceInfoContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useCallback, useContext, useReducer, useState, useEffect } from 'react' +import React, { createContext, useCallback, useContext, useReducer, useState, useEffect, useRef } from 'react' // @ts-ignore import { useCurrentWallet, useSetCurrentWallet } from './WalletContext' // @ts-ignore @@ -38,6 +38,8 @@ const ServiceInfoProvider = ({ children }: React.PropsWithChildren<{}>) => { const setCurrentWallet = useSetCurrentWallet() const websocket = useWebsocket() + const fetchSessionInProgress = useRef | null>(null) + const [serviceInfo, dispatchServiceInfo] = useReducer( (state: ServiceInfo | null, obj: ServiceInfoUpdate) => ({ ...state, ...obj } as ServiceInfo | null), null @@ -54,13 +56,25 @@ const ServiceInfoProvider = ({ children }: React.PropsWithChildren<{}>) => { }, [connectionError, setCurrentWallet]) const reloadServiceInfo = useCallback( - ({ signal }: { signal: AbortSignal }) => { + async ({ signal }: { signal: AbortSignal }) => { const resetWalletAndClearSession = () => { setCurrentWallet(null) clearSession() } - return Api.getSession({ signal }) + if (fetchSessionInProgress.current !== null) { + try { + return await fetchSessionInProgress.current + } catch (err: unknown) { + // If a request was in progress but failed, retry! + // This happens e.g. when the in-progress request was aborted. + if (!(err instanceof Error) || err.name !== 'AbortError') { + console.warn('Previous session request resulted in an unexpected error. Retrying!', err) + } + } + } + + const fetch = Api.getSession({ signal }) .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res))) .then((data: JmSessionData) => { const { @@ -70,13 +84,21 @@ const ServiceInfoProvider = ({ children }: React.PropsWithChildren<{}>) => { wallet_name: walletNameOrNoneString, } = data const activeWalletName = walletNameOrNoneString !== 'None' ? walletNameOrNoneString : null + return { sessionActive, makerRunning, coinjoinInProgress, walletName: activeWalletName } as ServiceInfo + }) + + fetchSessionInProgress.current = fetch - const info: ServiceInfo = { sessionActive, makerRunning, coinjoinInProgress, walletName: activeWalletName } + return fetch + .finally(() => { + fetchSessionInProgress.current = null + }) + .then((info: ServiceInfo) => { if (!signal.aborted) { dispatchServiceInfo(info) setConnectionError(undefined) - const activeWalletChanged = currentWallet && (!activeWalletName || currentWallet.name !== activeWalletName) + const activeWalletChanged = currentWallet && (!info.walletName || currentWallet.name !== info.walletName) if (activeWalletChanged) { resetWalletAndClearSession() } diff --git a/src/context/WalletContext.tsx b/src/context/WalletContext.tsx index a01d204b7..14927d167 100644 --- a/src/context/WalletContext.tsx +++ b/src/context/WalletContext.tsx @@ -1,9 +1,9 @@ -import React, { createContext, useEffect, useCallback, useState, useContext, PropsWithChildren } from 'react' +import React, { createContext, useEffect, useCallback, useState, useContext, PropsWithChildren, useRef } from 'react' import { getSession } from '../session' import * as Api from '../libs/JmWalletApi' -interface CurrentWallet { +export interface CurrentWallet { name: string token: string } @@ -118,22 +118,43 @@ const loadWalletInfoData = async ({ const WalletProvider = ({ children }: PropsWithChildren) => { const [currentWallet, setCurrentWallet] = useState(restoreWalletFromSession()) const [currentWalletInfo, setCurrentWalletInfo] = useState(null) + const fetchWalletInfoInProgress = useRef | null>(null) const reloadCurrentWalletInfo = useCallback( async ({ signal }: { signal: AbortSignal }) => { if (!currentWallet) { throw new Error('Cannot load wallet info: Wallet not present') } else { - const { name: walletName, token } = currentWallet - return loadWalletInfoData({ walletName, token, signal }).then((walletInfo) => { - if (!signal.aborted) { - setCurrentWalletInfo(walletInfo) + if (fetchWalletInfoInProgress.current !== null) { + try { + return await fetchWalletInfoInProgress.current + } catch (err: unknown) { + // If a previous wallet info request was in progress but failed, retry! + // This happens e.g. when the in-progress request was aborted. + if (!(err instanceof Error) || err.name !== 'AbortError') { + console.warn('Previous wallet info request resulted in an unexpected error. Retrying!', err) + } } - return walletInfo - }) + } + + const { name: walletName, token } = currentWallet + const fetch = loadWalletInfoData({ walletName, token, signal }) + + fetchWalletInfoInProgress.current = fetch + + return fetch + .finally(() => { + fetchWalletInfoInProgress.current = null + }) + .then((walletInfo) => { + if (!signal.aborted) { + setCurrentWalletInfo(walletInfo) + } + return walletInfo + }) } }, - [currentWallet] + [currentWallet, fetchWalletInfoInProgress] ) useEffect(() => { diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 9a8515252..61747f4db 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -102,7 +102,8 @@ "back_button": "Back", "skip_button": "Skip", "next_button": "Next", - "placeholder_seed_word_input": "Word" + "placeholder_seed_word_input": "Word", + "hint_duration_text": "Please be patient, this may take a few minutes." }, "current_wallet": { "text_loading": "Loading", @@ -252,6 +253,10 @@ "description": "Offer your sats to the marketplace. No trust or custody required—you are always in full control of your funds." }, "item_5": { + "title": "<0>Send a collaborative transaction to yourself.", + "description": "Collaborative transactions increase the privacy of yourself and others." + }, + "item_last": { "title": "Go to step one and repeat.", "description": "Still confused? Dig into the <2>documentation." } diff --git a/src/index.css b/src/index.css index 6f4fa89de..d06242275 100644 --- a/src/index.css +++ b/src/index.css @@ -357,59 +357,14 @@ main { } } -/* Cheatsheet Styles */ -.cheatsheet { - height: auto; - /* page height - navbar height - some spacing*/ - max-height: calc(100vh - 76px - 1rem); - border-top-left-radius: 1rem; - border-top-right-radius: 1rem; - margin: 0px auto; - width: 528px; -} - -.cheatsheet .offcanvas-header .btn-close { - margin-bottom: auto; -} - -.cheatsheet .cheatsheet-list-item { - align-items: start; -} - -.cheatsheet .cheatsheet-list-item.upcoming-feature { - opacity: 0.25; -} - -.cheatsheet .cheatsheet-list-item h6 { - margin-bottom: 0.1rem; -} -.cheatsheet a { - color: inherit; -} - -.cheatsheet .numbered { - display: flex; - justify-content: center; - align-items: center; - min-width: 2rem; - height: 2rem; - border-radius: 50%; - background-color: rgb(0, 0, 0); - color: white; -} - -:root[data-theme='dark'] .cheatsheet .numbered { - color: rgb(0, 0, 0); - background-color: white; -} - -.cheatsheet-link.nav-link { +footer .cheatsheet-link.nav-link { color: var(--bs-gray-900); } -:root[data-theme='dark'] .cheatsheet-link.nav-link { +:root[data-theme='dark'] footer .cheatsheet-link.nav-link { color: var(--bs-gray-400); } + /* Blurred Text Styles */ .blurred-text { diff --git a/src/index.tsx b/src/index.tsx index b63edb40e..8c02eed23 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,11 +6,11 @@ import App from './components/App' // @ts-ignore import { SettingsProvider } from './context/SettingsContext' // @ts-ignore -import { WalletProvider } from './context/WalletContext' -// @ts-ignore import { WebsocketProvider } from './context/WebsocketContext' -// @ts-ignore import { ServiceInfoProvider } from './context/ServiceInfoContext' +import { WalletProvider } from './context/WalletContext' +import { ServiceConfigProvider } from './context/ServiceConfigContext' + import reportWebVitals from './reportWebVitals' import 'bootstrap/dist/css/bootstrap.min.css' import './index.css' @@ -31,11 +31,13 @@ ReactDOM.render( - - - - - + + + + + + + diff --git a/src/libs/JmWalletApi.ts b/src/libs/JmWalletApi.ts index b3729d50d..d7d2ddc34 100644 --- a/src/libs/JmWalletApi.ts +++ b/src/libs/JmWalletApi.ts @@ -5,9 +5,9 @@ * * See OpenAPI spec: https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/docs/api/wallet-rpc.yaml * - * Because we forward all requests through a proxy, additional functionality + * Because all requests are forwarded through a proxy, additional functionality * can be provided. One adaptation is to send the Authorization header as - * 'x-jm-authorization' so that the reverse proxy can apply its own + * 'x-jm-authorization' so that any reverse proxy can apply its own * authentication mechanism. */ const basePath = () => `${window.JM.PUBLIC_PATH}/api` diff --git a/src/setupTests.js b/src/setupTests.js index 2a29d310b..234a1f024 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -34,3 +34,19 @@ global.__DEV__.addToAppSettings = () => { global.__DEV__.JM_WEBSOCKET_SERVER_MOCK.close() }) })() + +beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated, still needed by @restart/hooks (last check: 2022-05-25) + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + }), + }) +}) diff --git a/src/testUtils.tsx b/src/testUtils.tsx index 3ef140ef8..83823ab51 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom' import { I18nextProvider } from 'react-i18next' import { WalletProvider } from './context/WalletContext' import { ServiceInfoProvider } from './context/ServiceInfoContext' +import { ServiceConfigProvider } from './context/ServiceConfigContext' // @ts-ignore import { SettingsProvider } from './context/SettingsContext' // @ts-ignore @@ -17,9 +18,11 @@ const AllTheProviders = ({ children }: { children: React.ReactElement }) => { - - {children} - + + + {children} + +