Repository: joinmarket-webui/joinmarket-webui
Pull request #305 (2022-06-01 21:10:59)

feat: enable report overlay
Before this commit, only the "developer mode" had the ability to open the earn report.
After this commit, the earn report button will always be shown, hence enabling it for all users by default.


Patch:
diff --git a/package-lock.json b/package-lock.json
index ff73e75..15e7041 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 a0e010f..1681127 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/Cheatsheet.module.css b/src/components/Cheatsheet.module.css
new file mode 100644
index 0000000..4a1a360
--- /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 f7dc0c4..b8e3993 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 <div className="numbered">{number}</div>
+function Numbered({ number }: NumberedProps) {
+  return (
+    <div className={styles.numbered}>
+      {number === 'last' ? (
+        <>
+          <Sprite symbol="checkmark" width="24" height="24" />
+        </>
+      ) : (
+        <>{number}</>
+      )}
+    </div>
+  )
 }
 
 function ListItem({ number, children, ...props }: PropsWithChildren<NumberedProps>) {
   return (
-    <rb.Stack className={`cheatsheet-list-item ${props.className || ''}`} direction="horizontal" gap={3}>
+    <rb.Stack className={`${styles['cheatsheet-list-item']} ${props.className || ''}`} direction="horizontal" gap={3}>
       <Numbered number={number} />
       <rb.Stack gap={0}>{children}</rb.Stack>
     </rb.Stack>
@@ -31,7 +43,7 @@ export default function Cheatsheet({ show = false, onHide }: CheatsheetProps) {
   const { t } = useTranslation()
 
   return (
-    <rb.Offcanvas className="cheatsheet" show={show} onHide={onHide} placement="bottom" onClick={onHide}>
+    <rb.Offcanvas className={styles.cheatsheet} show={show} onHide={onHide} placement="bottom" onClick={onHide}>
       <rb.Offcanvas.Header closeButton>
         <rb.Stack>
           <rb.Offcanvas.Title>{t('cheatsheet.title')}</rb.Offcanvas.Title>
@@ -83,7 +95,7 @@ export default function Cheatsheet({ show = false, onHide }: CheatsheetProps) {
             </h6>
             <div className="small text-secondary">{t('cheatsheet.item_2.description')}</div>
           </ListItem>
-          <ListItem number={3} className="upcoming-feature">
+          <ListItem number={3} className={styles['upcoming-feature']}>
             <h6>
               <Trans i18nKey="cheatsheet.item_3.title">
                 Optional: <Link to={routes.earn}>Lock</Link> funds in a fidelity bond.
@@ -105,9 +117,17 @@ export default function Cheatsheet({ show = false, onHide }: CheatsheetProps) {
             <div className="small text-secondary">{t('cheatsheet.item_4.description')}</div>
           </ListItem>
           <ListItem number={5}>
-            <h6>{t('cheatsheet.item_5.title')}</h6>
+            <h6>
+              <Trans i18nKey="cheatsheet.item_5.title">
+                <Link to={routes.send}>Send</Link> a collaborative transaction to yourself.
+              </Trans>
+            </h6>
+            <div className="small text-secondary">{t('cheatsheet.item_5.description')}</div>
+          </ListItem>
+          <ListItem number={'last'}>
+            <h6>{t('cheatsheet.item_last.title')}</h6>
             <div className="small text-secondary">
-              <Trans i18nKey="cheatsheet.item_5.description">
+              <Trans i18nKey="cheatsheet.item_last.description">
                 Still confused?{' '}
                 <a
                   href="https://github.com/openoms/bitcoin-tutorials/blob/master/joinmarket/joinmarket_private_flow.md#a-private-flow-through-joinmarket"
diff --git a/src/components/CreateWallet.jsx b/src/components/CreateWallet.jsx
index ca4587b..8564d6c 100644
--- a/src/components/CreateWallet.jsx
+++ b/src/components/CreateWallet.jsx
@@ -144,6 +144,11 @@ const WalletCreationForm = ({ createWallet }) => {
               )}
             </rb.Button>
           </rb.Form>
+          {isSubmitting && (
+            <div className="text-center text-muted small mt-4">
+              <p>{t('create_wallet.hint_duration_text')}</p>
+            </div>
+          )}
         </>
       )}
     </Formik>
diff --git a/src/components/Earn.jsx b/src/components/Earn.jsx
index 3897f96..25dfbf1 100644
--- a/src/components/Earn.jsx
+++ b/src/components/Earn.jsx
@@ -413,24 +413,20 @@ export default function Earn() {
         </rb.Col>
       </rb.Row>
 
-      {settings.useAdvancedWalletMode && (
-        <>
-          <rb.Row className="mt-5 mb-3">
-            <rb.Col className="d-flex justify-content-center">
-              <rb.Button
-                variant="outline-dark"
-                className="border-0 mb-2 d-inline-flex align-items-center"
-                onClick={() => setIsShowReport(true)}
-              >
-                <Sprite symbol="show" width="24" height="24" className="me-2" />
-                {t('earn.button_show_report')}
-              </rb.Button>
-            </rb.Col>
-          </rb.Row>
-
+      <rb.Row className="mt-5 mb-3">
+        <rb.Col className="d-flex justify-content-center">
           <EarnReportOverlay show={isShowReport} onHide={() => setIsShowReport(false)} />
-        </>
-      )}
+
+          <rb.Button
+            variant="outline-dark"
+            className="border-0 mb-2 d-inline-flex align-items-center"
+            onClick={() => setIsShowReport(true)}
+          >
+            <Sprite symbol="show" width="24" height="24" className="me-2" />
+            {t('earn.button_show_report')}
+          </rb.Button>
+        </rb.Col>
+      </rb.Row>
     </div>
   )
 }
diff --git a/src/components/Send.jsx b/src/components/Send.jsx
index e8a3e66..9ddc482 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,19 +185,14 @@ export default function Send() {
   const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
   const serviceInfo = useServiceInfo()
   const reloadServiceInfo = useReloadServiceInfo()
+  const loadConfigValue = useLoadConfigValue()
   const settings = useSettings()
   const location = useLocation()
 
   const isCoinjoinInProgress = useMemo(() => serviceInfo && serviceInfo.coinjoinInProgress, [serviceInfo])
   const isMakerRunning = useMemo(() => serviceInfo && serviceInfo.makerRunning, [serviceInfo])
-  const waitForTakerToFinish = useMemo(() => isCoinjoinInProgress, [isCoinjoinInProgress])
-  const isOperationDisabled = useMemo(
-    () => isCoinjoinInProgress || isMakerRunning,
-    [isCoinjoinInProgress, isMakerRunning]
-  )
 
   const [alert, setAlert] = useState(null)
-  const [isLoading, setIsLoading] = useState(true)
   const [isSending, setIsSending] = useState(false)
   const [isCoinjoin, setIsCoinjoin] = useState(IS_COINJOIN_DEFAULT_VAL)
   const [minNumCollaborators, setMinNumCollaborators] = useState(MINIMUM_MAKERS_DEFAULT_VAL)
@@ -198,6 +202,16 @@ export default function Send() {
   const [paymentSuccessfulInfoAlert, setPaymentSuccessfulInfoAlert] = useState(null)
   const [takerStartedInfoAlert, setTakerStartedInfoAlert] = useState(null)
 
+  const isOperationDisabled = useMemo(
+    () => isCoinjoinInProgress || isMakerRunning || waitForUtxosToBeSpent.length > 0,
+    [isCoinjoinInProgress, isMakerRunning, waitForUtxosToBeSpent]
+  )
+  const [isInitializing, setIsInitializing] = useState(!isOperationDisabled)
+  const isLoading = useMemo(
+    () => isInitializing || waitForUtxosToBeSpent.length > 0,
+    [isInitializing, waitForUtxosToBeSpent]
+  )
+
   useEffect(() => {
     setTakerStartedInfoAlert((current) => (isCoinjoinInProgress ? current : null))
   }, [isCoinjoinInProgress])
@@ -263,7 +277,6 @@ export default function Send() {
   useEffect(() => {
     if (waitForUtxosToBeSpent.length === 0) return
 
-    setIsLoading(true)
     const abortCtrl = new AbortController()
 
     // Delaying the poll requests gives the wallet some time to synchronize
@@ -279,7 +292,6 @@ export default function Send() {
           const outputs = data.data.utxos.utxos.map((it) => it.utxo)
           const utxosStillPresent = waitForUtxosToBeSpent.filter((it) => outputs.includes(it))
           setWaitForUtxosToBeSpent([...utxosStillPresent])
-          setIsLoading(utxosStillPresent.length > 0)
         })
         .catch((err) => {
           if (abortCtrl.signal.aborted) return
@@ -287,7 +299,6 @@ export default function Send() {
           // Stop waiting for wallet synchronization on errors, but inform
           // the user that loading the wallet info failed
           setWaitForUtxosToBeSpent([])
-          setIsLoading(false)
 
           const message = err.message || t('send.error_loading_wallet_failed')
           setAlert({ variant: 'danger', message })
@@ -301,13 +312,15 @@ export default function Send() {
   }, [waitForUtxosToBeSpent, reloadCurrentWalletInfo, t])
 
   useEffect(() => {
-    if (waitForTakerToFinish) return
-    if (waitForUtxosToBeSpent.length > 0) return
+    if (isOperationDisabled) {
+      setIsInitializing(false)
+      return
+    }
 
     const abortCtrl = new AbortController()
 
     setAlert(null)
-    setIsLoading(true)
+    setIsInitializing(true)
 
     // reloading service info is important, is it must be known as soon as possible
     // if the operation is even allowed, i.e. if no other service is running
@@ -322,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))
       })
@@ -335,11 +349,11 @@ export default function Send() {
       })
 
     Promise.all([loadingServiceInfo, loadingWalletInfoAndUtxos, loadingMinimumMakerConfig]).finally(
-      () => !abortCtrl.signal.aborted && setIsLoading(false)
+      () => !abortCtrl.signal.aborted && setIsInitializing(false)
     )
 
     return () => abortCtrl.abort()
-  }, [waitForTakerToFinish, waitForUtxosToBeSpent, wallet, reloadCurrentWalletInfo, reloadServiceInfo, t])
+  }, [isOperationDisabled, wallet, reloadCurrentWalletInfo, reloadServiceInfo, loadConfigValue, t])
 
   const sendPayment = async (account, destination, amount_sats) => {
     setAlert(null)
@@ -407,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')}`
@@ -552,9 +566,7 @@ export default function Send() {
 
   return (
     <>
-      <div
-        className={`${isMakerRunning ? styles['maker-running'] : ''} ${isCoinjoinInProgress ? 'taker-running' : ''}`}
-      >
+      <div className={`${isMakerRunning || isCoinjoinInProgress ? styles['service-running'] : ''}`}>
         <PageTitle title={t('send.title')} subtitle={t('send.subtitle')} />
         <rb.Fade in={isOperationDisabled} mountOnEnter={true} unmountOnExit={true}>
           <>
@@ -591,7 +603,7 @@ export default function Send() {
           <rb.Alert variant={takerStartedInfoAlert.variant}>{takerStartedInfoAlert.message}</rb.Alert>
         )}
 
-        <rb.Form id="send-form" onSubmit={onSubmit} noValidate className={styles['maker-running-form']}>
+        <rb.Form id="send-form" onSubmit={onSubmit} noValidate className={styles['send-form']}>
           <rb.Form.Group className="mb-4 flex-grow-1" controlId="account">
             <rb.Form.Label>
               {settings.useAdvancedWalletMode ? t('send.label_account_dev_mode') : t('send.label_account')}
diff --git a/src/components/Send.module.css b/src/components/Send.module.css
index 5775b9f..cb7a7e3 100644
--- a/src/components/Send.module.css
+++ b/src/components/Send.module.css
@@ -118,15 +118,15 @@ input[type='number'] {
   border-radius: 0.25rem;
 }
 
-.maker-running .maker-running-form {
+.service-running .send-form {
   filter: blur(2px);
 }
 
-.maker-running .collaborators-selector {
+.service-running .collaborators-selector {
   filter: blur(2px);
 }
 
-.maker-running .send-button {
+.service-running .send-button {
   filter: blur(2px);
 }
 
diff --git a/src/context/ServiceConfigContext.tsx b/src/context/ServiceConfigContext.tsx
new file mode 100644
index 0000000..80d5a65
--- /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<string, string | null>
+}
+
+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<ServiceConfigUpdate>[] = 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<ServiceConfigUpdate>
+}
+
+const ServiceConfigContext = createContext<ServiceConfigContextEntry | undefined>(undefined)
+
+const ServiceConfigProvider = ({ children }: React.PropsWithChildren<{}>) => {
+  const currentWallet = useCurrentWallet()
+  const serviceConfig = useRef<ServiceConfig | null>(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 (
+    <ServiceConfigContext.Provider value={{ loadConfigValue: loadConfigValueIfAbsent }}>
+      {children}
+    </ServiceConfigContext.Provider>
+  )
+}
+
+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 7a31136..75d8fab 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<Promise<ServiceInfo> | 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 a01d204..14927d1 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<any>) => {
   const [currentWallet, setCurrentWallet] = useState(restoreWalletFromSession())
   const [currentWalletInfo, setCurrentWalletInfo] = useState<WalletInfo | null>(null)
+  const fetchWalletInfoInProgress = useRef<Promise<WalletInfo> | 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 9a85152..61747f4 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</0> 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</2>."
     }
diff --git a/src/index.css b/src/index.css
index 6f4fa89..d062422 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 b63edb4..8c02eed 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(
     <BrowserRouter basename={window.JM.PUBLIC_PATH}>
       <SettingsProvider>
         <WalletProvider>
-          <WebsocketProvider>
-            <ServiceInfoProvider>
-              <App />
-            </ServiceInfoProvider>
-          </WebsocketProvider>
+          <ServiceConfigProvider>
+            <WebsocketProvider>
+              <ServiceInfoProvider>
+                <App />
+              </ServiceInfoProvider>
+            </WebsocketProvider>
+          </ServiceConfigProvider>
         </WalletProvider>
       </SettingsProvider>
     </BrowserRouter>
diff --git a/src/libs/JmWalletApi.ts b/src/libs/JmWalletApi.ts
index b3729d5..d7d2ddc 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`


Test patch:
diff --git a/docker/regtest/readme.md b/docker/regtest/readme.md
index 0ef192c..f17be80 100644
--- a/docker/regtest/readme.md
+++ b/docker/regtest/readme.md
@@ -1,16 +1,46 @@
-# 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 in secondary container
+npm run regtest:init
+
+# mine blocks in regtest periodically
+npm run regtest:mine
+
+# start jam in development mode
+npm start
+
+[...]
+
+# stop the regtest environment
+npm run regtest:down
+
+# (optional) wipe all test data and start from scratch next time
+npm run regtest:clear
+```
 
 ## Commands
 
-### Run
+### Start
 
 Start the regtest environment with:
 
 ```sh
 npm run regtest:up
+
+# (optional) fund wallets and start maker in secondary container
+npm run regtest:init
 ```
 
 Once the regtest environment is up and running you can start Jam with:
@@ -31,6 +61,14 @@ If you want to start from scratch (removing all volumes):
 npm run regtest:clear
 ```
 
+### 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.
+```sh
+npm run regtest:mine
+```
+
 ## Images
 
 The [Docker setup](dockerfile-deps/joinmarket/latest/Dockerfile) is an adaption of [joinmarket-webui-standalone](https://github.com/joinmarket-webui/joinmarket-webui-docker/tree/master/standalone) with as little adaptations as possible.
@@ -41,6 +79,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.
@@ -74,6 +114,7 @@ Some helper scripts are included to make recurring tasks and interaction with th
 This script helps in providing both JoinMarket containers a wallet with spendable coins and starting the Maker Service in the secondary container.
 Its main goal is to make CoinJoin transactions possible in the regtest environment.
 It should be run immediately after the Docker setup is successfully started so you can start developing right away.
+A wallet named `funded.jmdat` with password `test` will be created if it does not exist.
 
 ```sh
 # fund wallets and start maker service in secondary container
@@ -181,5 +222,3 @@ joinmarket_1  | 2009-01-03 00:02:44,907 INFO success: ob-watcher entered RUNNING
 
 - [JoinMarket Server (GitHub)](https://github.com/JoinMarket-Org/joinmarket-clientserver)
 - [JoinMarket Server Testing Docs (GitHub)](https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/docs/TESTING.md)
-- [BTCPay Server JoinMarket Docker Setup (GitHub)](https://github.com/btcpayserver/dockerfile-deps/tree/master/JoinMarket)
-- [BTCPay Server JoinMarket Image (DockerHub)](https://hub.docker.com/r/btcpayserver/joinmarket)
diff --git a/src/setupTests.js b/src/setupTests.js
index 2a29d31..234a1f0 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 3ef140e..83823ab 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 }) => {
         <I18nextProvider i18n={i18n}>
           <SettingsProvider>
             <WalletProvider>
-              <WebsocketProvider>
-                <ServiceInfoProvider>{children}</ServiceInfoProvider>
-              </WebsocketProvider>
+              <ServiceConfigProvider>
+                <WebsocketProvider>
+                  <ServiceInfoProvider>{children}</ServiceInfoProvider>
+                </WebsocketProvider>
+              </ServiceConfigProvider>
             </WalletProvider>
           </SettingsProvider>
         </I18nextProvider>