Repository: joinmarket-webui/joinmarket-webui
Pull request #254 (2022-05-11 08:05:52)

build(deps): bump ejs from 3.1.6 to 3.1.7
Bumps [ejs](https://github.com/mde/ejs) from 3.1.6 to 3.1.7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/mde/ejs/releases">ejs's releases</a>.</em></p>
<blockquote>
<h2>v3.1.7</h2>
<p>Version 3.1.7</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/mde/ejs/commit/820855ad75034e303be82c482c5eb8c6616da5c5"><code>820855a</code></a> Version 3.1.7</li>
<li><a href="https://github.com/mde/ejs/commit/076dcb643c5aed2e10c2847639c98fd923b72854"><code>076dcb6</code></a> Don't use template literal</li>
<li><a href="https://github.com/mde/ejs/commit/faf8b849a4e46db21fa813a08a7cbe00256a188a"><code>faf8b84</code></a> Skip test -- error message vary depending on JS runtime</li>
<li><a href="https://github.com/mde/ejs/commit/c028c343c127859f7189c3feee1e5239c199fec9"><code>c028c34</code></a> Update packages</li>
<li><a href="https://github.com/mde/ejs/commit/e4180b4fa2dd0e06d811f2c155f9d993ee9d8edd"><code>e4180b4</code></a> Merge pull request <a href="https://github-redirect.dependabot.com/mde/ejs/issues/629">#629</a> from markbrouwer96/main</li>
<li><a href="https://github.com/mde/ejs/commit/d5404d6e68d64c165580d238e7562ea6532c2541"><code>d5404d6</code></a> Updated jsdoc to 3.6.7</li>
<li><a href="https://github.com/mde/ejs/commit/7b0845d6aab044d244e8ec3818f0f70d05ef13c1"><code>7b0845d</code></a> Merge pull request <a href="https://github-redirect.dependabot.com/mde/ejs/issues/609">#609</a> from mde/dependabot/npm_and_yarn/glob-parent-5.1.2</li>
<li><a href="https://github.com/mde/ejs/commit/32fb8ee387383c67eaa5feff05347ef0504f3b15"><code>32fb8ee</code></a> Bump glob-parent from 5.1.1 to 5.1.2</li>
<li><a href="https://github.com/mde/ejs/commit/f21a9e464337032af5e61352c54b9dd8dae8fd1b"><code>f21a9e4</code></a> Merge pull request <a href="https://github-redirect.dependabot.com/mde/ejs/issues/603">#603</a> from mde/mde-null-proto-where-possible</li>
<li><a href="https://github.com/mde/ejs/commit/a50e46f002a78544e10982a037742cfb67fc7b80"><code>a50e46f</code></a> Merge pull request <a href="https://github-redirect.dependabot.com/mde/ejs/issues/606">#606</a> from akash-55/main</li>
<li>Additional commits viewable in <a href="https://github.com/mde/ejs/compare/v3.1.6...v3.1.7">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ejs&package-manager=npm_and_yarn&previous-version=3.1.6&new-version=3.1.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
- `@dependabot use these labels` will set the current labels as the default for future PRs for this repo and language
- `@dependabot use these reviewers` will set the current reviewers as the default for future PRs for this repo and language
- `@dependabot use these assignees` will set the current assignees as the default for future PRs for this repo and language
- `@dependabot use this milestone` will set the current milestone as the default for future PRs for this repo and language

You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/joinmarket-webui/joinmarket-webui/network/alerts).

</details>


Patch:
diff --git a/package-lock.json b/package-lock.json
index 257e7ed..ace8868 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7058,12 +7058,12 @@
       "dev": true
     },
     "node_modules/ejs": {
-      "version": "3.1.6",
-      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.6.tgz",
-      "integrity": "sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw==",
+      "version": "3.1.7",
+      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.7.tgz",
+      "integrity": "sha512-BIar7R6abbUxDA3bfXrO4DSgwo8I+fB5/1zgujl3HLLjwd6+9iOnrT+t3grn2qbk9vOgBubXOFwX2m9axoFaGw==",
       "dev": true,
       "dependencies": {
-        "jake": "^10.6.1"
+        "jake": "^10.8.5"
       },
       "bin": {
         "ejs": "bin/cli.js"
@@ -8344,12 +8344,33 @@
       }
     },
     "node_modules/filelist": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.2.tgz",
-      "integrity": "sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.3.tgz",
+      "integrity": "sha512-LwjCsruLWQULGYKy7TX0OPtrL9kLpojOFKc5VCTxdFTV7w5zbsgqVKfnkKG7Qgjtq50gKfO56hJv88OfcGb70Q==",
       "dev": true,
       "dependencies": {
-        "minimatch": "^3.0.4"
+        "minimatch": "^5.0.1"
+      }
+    },
+    "node_modules/filelist/node_modules/brace-expansion": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+      "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/filelist/node_modules/minimatch": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz",
+      "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=10"
       }
     },
     "node_modules/filesize": {
@@ -10151,13 +10172,13 @@
       }
     },
     "node_modules/jake": {
-      "version": "10.8.2",
-      "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.2.tgz",
-      "integrity": "sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A==",
+      "version": "10.8.5",
+      "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
+      "integrity": "sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==",
       "dev": true,
       "dependencies": {
-        "async": "0.9.x",
-        "chalk": "^2.4.2",
+        "async": "^3.2.3",
+        "chalk": "^4.0.2",
         "filelist": "^1.0.1",
         "minimatch": "^3.0.4"
       },
@@ -10165,62 +10186,15 @@
         "jake": "bin/cli.js"
       },
       "engines": {
-        "node": "*"
-      }
-    },
-    "node_modules/jake/node_modules/ansi-styles": {
-      "version": "3.2.1",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
-      "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
-      "dev": true,
-      "dependencies": {
-        "color-convert": "^1.9.0"
-      },
-      "engines": {
-        "node": ">=4"
+        "node": ">=10"
       }
     },
     "node_modules/jake/node_modules/async": {
-      "version": "0.9.2",
-      "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz",
-      "integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=",
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz",
+      "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==",
       "dev": true
     },
-    "node_modules/jake/node_modules/chalk": {
-      "version": "2.4.2",
-      "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
-      "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
-      "dev": true,
-      "dependencies": {
-        "ansi-styles": "^3.2.1",
-        "escape-string-regexp": "^1.0.5",
-        "supports-color": "^5.3.0"
-      },
-      "engines": {
-        "node": ">=4"
-      }
-    },
-    "node_modules/jake/node_modules/escape-string-regexp": {
-      "version": "1.0.5",
-      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
-      "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
-      "dev": true,
-      "engines": {
-        "node": ">=0.8.0"
-      }
-    },
-    "node_modules/jake/node_modules/supports-color": {
-      "version": "5.5.0",
-      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
-      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
-      "dev": true,
-      "dependencies": {
-        "has-flag": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=4"
-      }
-    },
     "node_modules/jest": {
       "version": "27.4.5",
       "resolved": "https://registry.npmjs.org/jest/-/jest-27.4.5.tgz",
@@ -24293,12 +24267,12 @@
       "dev": true
     },
     "ejs": {
-      "version": "3.1.6",
-      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.6.tgz",
-      "integrity": "sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw==",
+      "version": "3.1.7",
+      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.7.tgz",
+      "integrity": "sha512-BIar7R6abbUxDA3bfXrO4DSgwo8I+fB5/1zgujl3HLLjwd6+9iOnrT+t3grn2qbk9vOgBubXOFwX2m9axoFaGw==",
       "dev": true,
       "requires": {
-        "jake": "^10.6.1"
+        "jake": "^10.8.5"
       }
     },
     "electron-to-chromium": {
@@ -25283,12 +25257,32 @@
       }
     },
     "filelist": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.2.tgz",
-      "integrity": "sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.3.tgz",
+      "integrity": "sha512-LwjCsruLWQULGYKy7TX0OPtrL9kLpojOFKc5VCTxdFTV7w5zbsgqVKfnkKG7Qgjtq50gKfO56hJv88OfcGb70Q==",
       "dev": true,
       "requires": {
-        "minimatch": "^3.0.4"
+        "minimatch": "^5.0.1"
+      },
+      "dependencies": {
+        "brace-expansion": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+          "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+          "dev": true,
+          "requires": {
+            "balanced-match": "^1.0.0"
+          }
+        },
+        "minimatch": {
+          "version": "5.0.1",
+          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz",
+          "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==",
+          "dev": true,
+          "requires": {
+            "brace-expansion": "^2.0.1"
+          }
+        }
       }
     },
     "filesize": {
@@ -26591,57 +26585,22 @@
       }
     },
     "jake": {
-      "version": "10.8.2",
-      "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.2.tgz",
-      "integrity": "sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A==",
+      "version": "10.8.5",
+      "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
+      "integrity": "sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==",
       "dev": true,
       "requires": {
-        "async": "0.9.x",
-        "chalk": "^2.4.2",
+        "async": "^3.2.3",
+        "chalk": "^4.0.2",
         "filelist": "^1.0.1",
         "minimatch": "^3.0.4"
       },
       "dependencies": {
-        "ansi-styles": {
-          "version": "3.2.1",
-          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
-          "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
-          "dev": true,
-          "requires": {
-            "color-convert": "^1.9.0"
-          }
-        },
         "async": {
-          "version": "0.9.2",
-          "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz",
-          "integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=",
-          "dev": true
-        },
-        "chalk": {
-          "version": "2.4.2",
-          "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
-          "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
-          "dev": true,
-          "requires": {
-            "ansi-styles": "^3.2.1",
-            "escape-string-regexp": "^1.0.5",
-            "supports-color": "^5.3.0"
-          }
-        },
-        "escape-string-regexp": {
-          "version": "1.0.5",
-          "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
-          "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
+          "version": "3.2.3",
+          "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz",
+          "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==",
           "dev": true
-        },
-        "supports-color": {
-          "version": "5.5.0",
-          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
-          "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
-          "dev": true,
-          "requires": {
-            "has-flag": "^3.0.0"
-          }
         }
       }
     },
diff --git a/src/components/ActivityIndicators.jsx b/src/components/ActivityIndicators.jsx
index 8654a5b..f3d0461 100644
--- a/src/components/ActivityIndicators.jsx
+++ b/src/components/ActivityIndicators.jsx
@@ -19,7 +19,7 @@ export function JoiningIndicator({ isOn, size = 30, className, ...props }) {
   )
 }
 
-export function EarnIndicator({ isOn }) {
+export function TabActivityIndicator({ isOn }) {
   return (
     <span className="earn-indicator">
       <ActivityIndicator isOn={isOn} />
diff --git a/src/components/Balance.jsx b/src/components/Balance.jsx
index cc7b002..fba1374 100644
--- a/src/components/Balance.jsx
+++ b/src/components/Balance.jsx
@@ -68,13 +68,25 @@ const BalanceComponent = ({ symbol, value, symbolIsPrefix }) => {
  * @param {showBalance}: A flag indicating whether to render or hide the balance.
  * Hidden balances are masked with `*****`.
  * @param {loading}: A loading flag that renders a placeholder while true.
+ * @param {enableVisibilityToggle}: A flag that controls whether the balance can mask/unmask when clicked
  */
-export default function Balance({ valueString, convertToUnit, showBalance = false, loading = false }) {
+export default function Balance({
+  valueString,
+  convertToUnit,
+  showBalance = false,
+  loading = false,
+  enableVisibilityToggle = !showBalance,
+}) {
   const [displayMode, setDisplayMode] = useState(DISPLAY_MODE_HIDDEN)
+  const [isBalanceVisible, setIsBalanceVisible] = useState(showBalance)
 
   useEffect(() => {
-    setDisplayMode(getDisplayMode(convertToUnit, showBalance))
-  }, [convertToUnit, showBalance])
+    setIsBalanceVisible(showBalance)
+  }, [showBalance])
+
+  useEffect(() => {
+    setDisplayMode(getDisplayMode(convertToUnit, isBalanceVisible))
+  }, [convertToUnit, isBalanceVisible])
 
   if (loading) {
     return (
@@ -87,43 +99,62 @@ export default function Balance({ valueString, convertToUnit, showBalance = fals
     )
   }
 
-  if (displayMode === DISPLAY_MODE_HIDDEN) {
-    return (
-      <BalanceComponent
-        symbol={
-          <span className="d-inline-flex align-items-center text-muted">
-            <Sprite symbol="hide" width="1.2em" height="1.2em" className="ps-1" />
-          </span>
-        }
-        value={'*****'}
-        symbolIsPrefix={false}
-      />
-    )
-  }
+  const toggleVisibility = (e) => {
+    e.preventDefault()
+    e.stopPropagation()
 
-  if (typeof valueString !== 'string') {
-    console.warn('<Balance /> component expects string input')
-    return <BalanceComponent symbol="" value={valueString} symbolIsPrefix={false} />
+    setIsBalanceVisible((current) => !current)
   }
 
-  // Treat integers as sats.
-  const valueIsSats = valueString === Number.parseInt(valueString).toString()
-  // Treat decimal numbers as btc.
-  const valueIsBtc = !valueIsSats && !Number.isNaN(Number.parseFloat(valueString)) && valueString.indexOf('.') > -1
-
-  const btcSymbol = <span style={{ paddingRight: '0.1em' }}>{'\u20BF'}</span>
-  const satSymbol = <Sprite symbol="sats" width="1.2em" height="1.2em" />
-
-  if (valueIsBtc && displayMode === DISPLAY_MODE_BTC)
-    return <BalanceComponent symbol={btcSymbol} value={formatBtc(valueString)} symbolIsPrefix={true} />
-  if (valueIsSats && displayMode === DISPLAY_MODE_SATS)
-    return <BalanceComponent symbol={satSymbol} value={formatSats(valueString)} symbolIsPrefix={false} />
-
-  if (valueIsBtc && displayMode === DISPLAY_MODE_SATS)
-    return <BalanceComponent symbol={satSymbol} value={formatSats(btcToSats(valueString))} symbolIsPrefix={false} />
-  if (valueIsSats && displayMode === DISPLAY_MODE_BTC)
-    return <BalanceComponent symbol={btcSymbol} value={formatBtc(satsToBtc(valueString))} symbolIsPrefix={true} />
+  const balanceComponent = (() => {
+    if (displayMode === DISPLAY_MODE_HIDDEN) {
+      return (
+        <BalanceComponent
+          symbol={
+            <span className="d-inline-flex align-items-center text-muted">
+              <Sprite symbol="hide" width="1.2em" height="1.2em" className="ps-1" />
+            </span>
+          }
+          value={'*****'}
+          symbolIsPrefix={false}
+        />
+      )
+    }
+
+    if (typeof valueString !== 'string') {
+      console.warn('<Balance /> component expects string input')
+      return <BalanceComponent symbol="" value={valueString} symbolIsPrefix={false} />
+    }
+
+    // Treat integers as sats.
+    const valueIsSats = valueString === Number.parseInt(valueString).toString()
+    // Treat decimal numbers as btc.
+    const valueIsBtc = !valueIsSats && !Number.isNaN(Number.parseFloat(valueString)) && valueString.indexOf('.') > -1
+
+    const btcSymbol = <span style={{ paddingRight: '0.1em' }}>{'\u20BF'}</span>
+    const satSymbol = <Sprite symbol="sats" width="1.2em" height="1.2em" />
+
+    if (valueIsBtc && displayMode === DISPLAY_MODE_BTC)
+      return <BalanceComponent symbol={btcSymbol} value={formatBtc(valueString)} symbolIsPrefix={true} />
+    if (valueIsSats && displayMode === DISPLAY_MODE_SATS)
+      return <BalanceComponent symbol={satSymbol} value={formatSats(valueString)} symbolIsPrefix={false} />
+
+    if (valueIsBtc && displayMode === DISPLAY_MODE_SATS)
+      return <BalanceComponent symbol={satSymbol} value={formatSats(btcToSats(valueString))} symbolIsPrefix={false} />
+    if (valueIsSats && displayMode === DISPLAY_MODE_BTC)
+      return <BalanceComponent symbol={btcSymbol} value={formatBtc(satsToBtc(valueString))} symbolIsPrefix={true} />
+
+    console.warn('<Balance /> component cannot determine balance format')
+    return <BalanceComponent symbol={''} value={valueString} symbolIsPrefix={false} />
+  })()
+
+  if (!enableVisibilityToggle) {
+    return <>{balanceComponent}</>
+  }
 
-  console.warn('<Balance /> component cannot determine balance format')
-  return <BalanceComponent symbol={''} value={valueString} symbolIsPrefix={false} />
+  return (
+    <span onClick={toggleVisibility} style={{ cursor: 'pointer' }}>
+      {balanceComponent}
+    </span>
+  )
 }
diff --git a/src/components/CurrentWalletMagic.jsx b/src/components/CurrentWalletMagic.jsx
index 454d572..1d9907f 100644
--- a/src/components/CurrentWalletMagic.jsx
+++ b/src/components/CurrentWalletMagic.jsx
@@ -26,7 +26,12 @@ const WalletHeader = ({ name, balance, unit, showBalance, loading }) => {
       )}
       {!loading && (
         <h2>
-          <Balance valueString={balance} convertToUnit={unit} showBalance={showBalance || false} />
+          <Balance
+            valueString={balance}
+            convertToUnit={unit}
+            showBalance={showBalance || false}
+            enableVisibilityToggle={false}
+          />
         </h2>
       )}
     </div>
@@ -150,16 +155,6 @@ export default function CurrentWalletMagic() {
           />
         </rb.Row>
         <rb.Row className="mt-4">
-          <rb.Col>
-            {/* Todo: Withdrawing needs to factor in the privacy levels as well.
-              Depending on the mixdepth/account there will be different amounts available. */}
-            <ExtendedLink to={routes.send} className="btn btn-outline-dark w-100" disabled={isLoading}>
-              <div className="d-flex justify-content-center align-items-center">
-                <Sprite symbol="send" width="24" height="24" />
-                <div className="ps-1">{t('current_wallet.button_withdraw')}</div>
-              </div>
-            </ExtendedLink>
-          </rb.Col>
           <rb.Col>
             {/* Always receive on first mixdepth. */}
             <ExtendedLink
@@ -174,6 +169,16 @@ export default function CurrentWalletMagic() {
               </div>
             </ExtendedLink>
           </rb.Col>
+          <rb.Col>
+            {/* Todo: Withdrawing needs to factor in the privacy levels as well.
+              Depending on the mixdepth/account there will be different amounts available. */}
+            <ExtendedLink to={routes.send} className="btn btn-outline-dark w-100" disabled={isLoading}>
+              <div className="d-flex justify-content-center align-items-center">
+                <Sprite symbol="send" width="24" height="24" />
+                <div className="ps-1">{t('current_wallet.button_withdraw')}</div>
+              </div>
+            </ExtendedLink>
+          </rb.Col>
         </rb.Row>
         <rb.Row>
           <hr className="my-4" />
diff --git a/src/components/Earn.jsx b/src/components/Earn.jsx
index 357fd5e..3897f96 100644
--- a/src/components/Earn.jsx
+++ b/src/components/Earn.jsx
@@ -258,6 +258,11 @@ export default function Earn() {
 
           {serviceInfoAlert && <rb.Alert variant={serviceInfoAlert.variant}>{serviceInfoAlert.message}</rb.Alert>}
 
+          {!serviceInfo?.coinjoinInProgress &&
+            !serviceInfo?.makerRunning &&
+            !isWaitingMakerStart &&
+            !isWaitingMakerStop && <p className="text-secondary mb-4">{t('earn.market_explainer')}</p>}
+
           {!serviceInfo?.coinjoinInProgress && (
             <Formik initialValues={initialValues} validate={validate} onSubmit={onSubmit}>
               {({ handleSubmit, setFieldValue, handleChange, handleBlur, values, touched, errors, isSubmitting }) => (
@@ -312,7 +317,16 @@ export default function Earn() {
                         </rb.Form.Group>
                       ) : (
                         <rb.Form.Group className="mb-3" controlId="feeAbs">
-                          <rb.Form.Label className="mb-0">{t('earn.label_abs_fee')}</rb.Form.Label>
+                          <rb.Form.Label className="mb-0">
+                            {t('earn.label_abs_fee', {
+                              fee:
+                                '(' +
+                                (values.feeAbs === '' ? 0 : values.feeAbs) +
+                                ' ' +
+                                (values.feeAbs === 1 ? 'sat' : 'sats') +
+                                ')',
+                            })}
+                          </rb.Form.Label>
                           <div className="mb-2">
                             <rb.Form.Text className="text-secondary">{t('earn.description_abs_fee')}</rb.Form.Text>
                           </div>
diff --git a/src/components/Jam.jsx b/src/components/Jam.jsx
index f23f6fb..c065fb0 100644
--- a/src/components/Jam.jsx
+++ b/src/components/Jam.jsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react'
+import React, { useState, useEffect, useCallback, useMemo } from 'react'
 import * as rb from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { Formik, useFormikContext } from 'formik'
@@ -11,6 +11,13 @@ import PageTitle from './PageTitle'
 import ToggleSwitch from './ToggleSwitch'
 import Sprite from './Sprite'
 import Balance from './Balance'
+import ScheduleProgress from './ScheduleProgress'
+
+// Todo: Discuss if we should hardcode this or let the user pick an account.
+const INTERNAL_DEST_ACCOUNT = 0
+// Interval in milliseconds between requests to reload the schedule.
+const SCHEDULE_REQUEST_INTERVAL = process.env.NODE_ENV === 'development' ? 10_000 : 60_000
+const SCHEDULER_STOP_RESPONSE_DELAY_MS = 2_000
 
 const ValuesListener = ({ handler }) => {
   const { values } = useFormikContext()
@@ -35,14 +42,65 @@ export default function Jam() {
 
   const [alert, setAlert] = useState(null)
   const [isLoading, setIsLoading] = useState(true)
-  const [destinationIsInternal, setDestinationIsInternal] = useState(false)
+  const [destinationIsExternal, setDestinationIsExternal] = useState(false)
   const [collaborativeOperationRunning, setCollaborativeOperationRunning] = useState(false)
+  const [schedule, setSchedule] = useState(null)
+
+  const getNewAddresses = useCallback(
+    (count, mixdepth) => {
+      if (!walletInfo) {
+        throw new Error('Wallet info is not available.')
+      }
+      const externalBranch = walletInfo.data.display.walletinfo.accounts[mixdepth].branches.find((branch) => {
+        return branch.branch.split('\t')[0] === 'external addresses'
+      })
+
+      const newAddresses = []
+
+      externalBranch.entries.every((entry) => {
+        if (entry.status === 'new') {
+          newAddresses.push(entry.address)
+        }
+
+        if (newAddresses.length >= count) {
+          return false
+        }
+
+        return true
+      })
+
+      if (newAddresses.length !== count) {
+        throw new Error(`Cannot find requested amount of addresses: found ${newAddresses.length} of ${count}`)
+      }
+
+      return newAddresses
+    },
+    [walletInfo]
+  )
 
   // Todo: Testing toggle is deactivated until https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1260 is merged.
-  // const [useInsecureTestingSettings, setUseInsecureTestingSettings] = useState(false)
+  const deactivateTestingToggle = true
+  const [useInsecureTestingSettings, setUseInsecureTestingSettings] = useState(false)
+
+  const initialFormValues = useMemo(() => {
+    const addressCount = 3
+
+    let destinationAddresses = []
+    if (destinationIsExternal) {
+      // prefill with empty addresses
+      destinationAddresses = Array(addressCount).fill('')
+    } else {
+      try {
+        // prefill with addresses marked as "new"
+        destinationAddresses = getNewAddresses(addressCount, INTERNAL_DEST_ACCOUNT)
+      } catch (e) {
+        // on error initialize with empty addresses - form validation will do the rest
+        destinationAddresses = Array(addressCount).fill('')
+      }
+    }
 
-  // Todo: Discuss if we should hardcode this or let the user pick an account.
-  const INTERNAL_DEST_ACCOUNT = 0
+    return destinationAddresses.reduce((obj, addr, index) => ({ ...obj, [`dest${index + 1}`]: addr }), {})
+  }, [destinationIsExternal, getNewAddresses])
 
   useEffect(() => {
     const abortCtrl = new AbortController()
@@ -72,30 +130,53 @@ export default function Jam() {
     setCollaborativeOperationRunning(coinjoinInProgress || makerRunning)
   }, [serviceInfo])
 
-  const getNewAddresses = (count, mixdepth) => {
-    const externalBranch = walletInfo.data.display.walletinfo.accounts[mixdepth].branches.find((branch) => {
-      return branch.branch.split('\t')[0] === 'external addresses'
-    })
+  const reloadSchedule = useCallback(
+    ({ signal }) => {
+      return Api.getSchedule({ walletName: wallet.name, token: wallet.token, signal })
+        .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res)))
+        .then((data) => {
+          if (!signal.aborted) {
+            process.env.NODE_ENV === 'development' && console.log(data.schedule)
+            setSchedule(data.schedule)
+          }
+        })
+        .catch((err) => {
+          if (err.response?.status === 404) {
+            // Not finding a schedule is not an error.
+            // It means a single collaborative transaction is running.
+            // Those have no schedule.
+            return
+          }
 
-    const newAddresses = []
+          const message = err.message || t('scheduler.error_loading_schedule_failed')
+          !signal.aborted && setAlert({ variant: 'danger', message })
+        })
+    },
+    [wallet, t]
+  )
 
-    externalBranch.entries.every((entry) => {
-      if (entry.status === 'new') {
-        newAddresses.push(entry.address)
-      }
+  useEffect(() => {
+    if (!collaborativeOperationRunning) {
+      return
+    }
 
-      if (newAddresses.length >= count) {
-        return false
-      }
+    const abortCtrl = new AbortController()
 
-      return true
-    })
+    const load = () => {
+      reloadSchedule({ signal: abortCtrl.signal }).catch((err) => console.error(err))
+    }
 
-    return newAddresses
-  }
+    load()
+
+    const interval = setInterval(load, SCHEDULE_REQUEST_INTERVAL)
+    return () => {
+      clearInterval(interval)
+      abortCtrl.abort()
+    }
+  }, [collaborativeOperationRunning, reloadSchedule])
 
   const startSchedule = async (values) => {
-    if (collaborativeOperationRunning) {
+    if (isLoading || collaborativeOperationRunning) {
       return
     }
 
@@ -106,225 +187,225 @@ export default function Jam() {
 
     const body = { destination_addresses: destinations }
 
-    // Todo: Testing toggle is deactivated until https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1260 is merged.
-    // if (process.env.NODE_ENV === 'development' && useInsecureTestingSettings) {
-    //   body.tumbler_options = {
-    //     addrcount: 3,
-    //     minmakercount: 1,
-    //     makercountrange: [1, 1],
-    //     mixdepthcount: 3,
-    //     mintxcount: 2,
-    //     txcountparams: [1, 1],
-    //     timelambda: 0.1,
-    //     stage1_timelambda_increase: 1.0,
-    //     liquiditywait: 10,
-    //     waittime: 1.0,
-    //     mixdepthsrc: 0,
-    //   }
-    // }
-
-    try {
-      const res = await Api.postTumblerStart({ walletName: wallet.name, token: wallet.token }, body)
-
-      setIsLoading(false)
-
-      if (!res.ok) {
-        await Api.Helper.throwError(res, t('schedule.error_starting_schedule_failed'))
+    if (process.env.NODE_ENV === 'development' && useInsecureTestingSettings && !deactivateTestingToggle) {
+      body.scheduler_options = {
+        addrcount: 3,
+        minmakercount: 1,
+        makercountrange: [1, 0],
+        mixdepthcount: 3,
+        mintxcount: 2,
+        txcountparams: [1, 1],
+        timelambda: 0.1,
+        stage1_timelambda_increase: 1.0,
+        liquiditywait: 10,
+        waittime: 1.0,
+        mixdepthsrc: 0,
       }
-
-      setCollaborativeOperationRunning(true)
-    } catch (err) {
-      setAlert({ variant: 'danger', message: err.message })
     }
+
+    return Api.postSchedulerStart({ walletName: wallet.name, token: wallet.token }, body)
+      .then((res) => (res.ok ? true : Api.Helper.throwError(res, t('scheduler.error_starting_schedule_failed'))))
+      .then((_) => setCollaborativeOperationRunning(true))
+      .catch((err) => {
+        setAlert({ variant: 'danger', message: err.message })
+      })
+      .finally(() => setIsLoading(false))
   }
 
   const stopSchedule = async () => {
-    if (!collaborativeOperationRunning) {
+    if (isLoading || !collaborativeOperationRunning) {
       return
     }
 
     setAlert(null)
     setIsLoading(true)
 
-    try {
-      const res = await Api.getTumblerStop({ walletName: wallet.name, token: wallet.token })
-
-      setIsLoading(false)
-
-      if (!res.ok) {
-        await Api.Helper.throwError(res, t('schedule.error_stopping_schedule_failed'))
-      }
-
-      setCollaborativeOperationRunning(false)
-    } catch (err) {
-      setAlert({ variant: 'danger', message: err.message })
-    }
+    return Api.getSchedulerStop({ walletName: wallet.name, token: wallet.token })
+      .then((res) => (res.ok ? true : Api.Helper.throwError(res, t('scheduler.error_stopping_schedule_failed'))))
+      .then((_) => setCollaborativeOperationRunning(false))
+      .then((_) => new Promise((r) => setTimeout(r, SCHEDULER_STOP_RESPONSE_DELAY_MS)))
+      .catch((err) => {
+        setAlert({ variant: 'danger', message: err.message })
+      })
+      .finally(() => setIsLoading(false))
   }
 
   return (
     <>
-      <PageTitle title={t('schedule.title')} subtitle={t('schedule.subtitle')} />
-      <rb.Fade in={collaborativeOperationRunning} mountOnEnter={true} unmountOnExit={true}>
+      <PageTitle title={t('scheduler.title')} subtitle={t('scheduler.subtitle')} />
+      {alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
+      {isLoading ? (
+        <rb.Placeholder as="div" animation="wave">
+          <rb.Placeholder xs={12} className={styles['input-loader']} />
+        </rb.Placeholder>
+      ) : (
         <>
-          {collaborativeOperationRunning && (
+          {collaborativeOperationRunning && schedule && (
+            <div className="mb-4">
+              <ScheduleProgress schedule={schedule} />
+            </div>
+          )}
+          {collaborativeOperationRunning && !schedule && (
             <rb.Alert variant="info" className="mb-4">
               {t('send.text_coinjoin_already_running')}
             </rb.Alert>
           )}
-        </>
-      </rb.Fade>
-      {alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
-      {!collaborativeOperationRunning && (
-        <>
-          <div className="d-flex align-items-center justify-content-between mb-4">
-            <div className="d-flex align-items-center gap-2">
-              <Sprite symbol="checkmark" width="25" height="25" className="text-secondary" />
-              <div className="d-flex flex-column">
-                <div>{t('schedule.complete_wallet_title')}</div>
-                <div className={`text-secondary ${styles['small-text']}`}>{t('schedule.complete_wallet_subtitle')}</div>
+          {!collaborativeOperationRunning && wallet && walletInfo && (
+            <>
+              <div className="d-flex align-items-center justify-content-between mb-4">
+                <div className="d-flex align-items-center gap-2">
+                  <Sprite symbol="checkmark" width="25" height="25" className="text-secondary" />
+                  <div className="d-flex flex-column">
+                    <div>{t('scheduler.complete_wallet_title')}</div>
+                    <div className={`text-secondary ${styles['small-text']}`}>
+                      {t('scheduler.complete_wallet_subtitle')}
+                    </div>
+                  </div>
+                </div>
+                <>
+                  {
+                    // Todo: Subtract frozen or locked UTXOs from amount shown.
+                  }
+                  <Balance
+                    valueString={walletInfo.data.display.walletinfo.total_balance}
+                    convertToUnit={settings.unit}
+                    showBalance={settings.showBalance}
+                  />
+                </>
               </div>
-            </div>
-            {!wallet || !walletInfo || isLoading ? (
-              <Balance loading={true} />
-            ) : (
-              // Todo: Subtract frozen or locked UTXOs from amount shown.
-              <Balance
-                valueString={walletInfo.data.display.walletinfo.total_balance}
-                convertToUnit={settings.unit}
-                showBalance={settings.showBalance}
-              />
-            )}
-          </div>
-          <p className="text-secondary mb-4">{t('schedule.description_destination_addresses')}</p>
-        </>
-      )}
-      <Formik
-        initialValues={{ dest1: '', dest2: '', dest3: '' }}
-        validate={(values) => {
-          if (collaborativeOperationRunning) {
-            return {}
-          }
-
-          const errors = {}
-
-          const isValidAddress = (candidate) => {
-            return typeof candidate !== 'undefined' && candidate !== ''
-          }
-
-          if (!isValidAddress(values.dest1)) {
-            errors.dest1 = t('schedule.error_invalid_destionation_address')
-          }
-          if (!isValidAddress(values.dest2)) {
-            errors.dest2 = t('schedule.error_invalid_destionation_address')
-          }
-          if (!isValidAddress(values.dest3)) {
-            errors.dest3 = t('schedule.error_invalid_destionation_address')
-          }
-
-          return errors
-        }}
-        onSubmit={async (values) => {
-          if (collaborativeOperationRunning) {
-            await stopSchedule()
-          } else {
-            await startSchedule(values)
-          }
-        }}
-      >
-        {({
-          values,
-          isSubmitting,
-          handleSubmit,
-          handleBlur,
-          handleChange,
-          setFieldValue,
-          validateForm,
-          isValid,
-          dirty,
-          touched,
-          errors,
-        }) => (
-          <>
-            <ValuesListener handler={validateForm} />
-            <rb.Form onSubmit={handleSubmit} noValidate>
-              {!collaborativeOperationRunning && (
+              <p className="text-secondary mb-4">{t('scheduler.description_destination_addresses')}</p>
+            </>
+          )}
+          {((!collaborativeOperationRunning && walletInfo && serviceInfo) ||
+            (collaborativeOperationRunning && schedule)) && (
+            <Formik
+              initialValues={initialFormValues}
+              validate={(values) => {
+                if (collaborativeOperationRunning) {
+                  return {}
+                }
+
+                const errors = {}
+
+                const isValidAddress = (candidate) => {
+                  return typeof candidate !== 'undefined' && candidate !== ''
+                }
+
+                if (!isValidAddress(values.dest1)) {
+                  errors.dest1 = t('scheduler.error_invalid_destionation_address')
+                }
+                if (!isValidAddress(values.dest2)) {
+                  errors.dest2 = t('scheduler.error_invalid_destionation_address')
+                }
+                if (!isValidAddress(values.dest3)) {
+                  errors.dest3 = t('scheduler.error_invalid_destionation_address')
+                }
+
+                return errors
+              }}
+              onSubmit={async (values) => {
+                if (collaborativeOperationRunning) {
+                  await stopSchedule()
+                } else {
+                  await startSchedule(values)
+                }
+              }}
+            >
+              {({
+                values,
+                isSubmitting,
+                handleSubmit,
+                handleBlur,
+                handleChange,
+                setFieldValue,
+                validateForm,
+                isValid,
+                dirty,
+                touched,
+                errors,
+              }) => (
                 <>
-                  <rb.Form.Group className="mb-4" controlId="offertype">
-                    <ToggleSwitch
-                      label={t('schedule.toggle_internal_destination_title')}
-                      subtitle={t('schedule.toggle_internal_destination_title', { account: INTERNAL_DEST_ACCOUNT })}
-                      initialValue={destinationIsInternal}
-                      onToggle={async (isToggled) => {
-                        setDestinationIsInternal(isToggled)
-
-                        if (isToggled) {
-                          const newAddresses = getNewAddresses(3, INTERNAL_DEST_ACCOUNT)
-                          setFieldValue('dest1', newAddresses[0], true)
-                          setFieldValue('dest2', newAddresses[1], true)
-                          setFieldValue('dest3', newAddresses[2], true)
-                        } else {
-                          setFieldValue('dest1', '', false)
-                          setFieldValue('dest2', '', false)
-                          setFieldValue('dest3', '', false)
-                        }
-                      }}
-                      disabled={isSubmitting}
-                    />
-                  </rb.Form.Group>
-                  {/* Todo: Testing toggle is deactivated until https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1260 is merged. */}
-                  {/*process.env.NODE_ENV === 'development' && (
-                  <rb.Form.Group className="mb-4" controlId="offertype">
-                    <ToggleSwitch
-                      label={'Use insecure testing settings'}
-                      subtitle={
-                        "This is completely insecure but makes testing the schedule much faster. This option won't be available in production."
-                      }
-                      initialValue={useInsecureTestingSettings}
-                      onToggle={(isToggled) => setUseInsecureTestingSettings(isToggled)}
-                      disabled={isSubmitting}
-                    />
-                  </rb.Form.Group>
-                )*/}
+                  <ValuesListener handler={validateForm} />
+                  <rb.Form onSubmit={handleSubmit} noValidate>
+                    {!collaborativeOperationRunning && (
+                      <>
+                        <rb.Form.Group className="mb-4" controlId="offertype">
+                          <ToggleSwitch
+                            label={t('scheduler.toggle_internal_destination_title')}
+                            subtitle={t('scheduler.toggle_internal_destination_subtitle', {
+                              account: INTERNAL_DEST_ACCOUNT,
+                            })}
+                            initialValue={destinationIsExternal}
+                            onToggle={async (isToggled) => {
+                              if (!isToggled) {
+                                const newAddresses = getNewAddresses(3, INTERNAL_DEST_ACCOUNT)
+                                setFieldValue('dest1', newAddresses[0], true)
+                                setFieldValue('dest2', newAddresses[1], true)
+                                setFieldValue('dest3', newAddresses[2], true)
+                              } else {
+                                setFieldValue('dest1', '', false)
+                                setFieldValue('dest2', '', false)
+                                setFieldValue('dest3', '', false)
+                              }
+
+                              setDestinationIsExternal(isToggled)
+                            }}
+                            disabled={isSubmitting}
+                          />
+                        </rb.Form.Group>
+                        {process.env.NODE_ENV === 'development' && !deactivateTestingToggle && (
+                          <rb.Form.Group className="mb-4" controlId="offertype">
+                            <ToggleSwitch
+                              label={'Use insecure testing settings'}
+                              subtitle={
+                                "This is completely insecure but makes testing the schedule much faster. This option won't be available in production."
+                              }
+                              initialValue={useInsecureTestingSettings}
+                              onToggle={(isToggled) => setUseInsecureTestingSettings(isToggled)}
+                              disabled={isSubmitting}
+                            />
+                          </rb.Form.Group>
+                        )}
+                      </>
+                    )}
+                    {!collaborativeOperationRunning &&
+                      destinationIsExternal &&
+                      [1, 2, 3].map((i) => {
+                        return (
+                          <rb.Form.Group className="mb-4" key={i} controlId={`dest${i}`}>
+                            <rb.Form.Label>{t('scheduler.label_destination_input', { destination: i })}</rb.Form.Label>
+                            <rb.Form.Control
+                              name={`dest${i}`}
+                              value={values[`dest${i}`]}
+                              placeholder={t('scheduler.placeholder_destination_input')}
+                              onChange={handleChange}
+                              onBlur={handleBlur}
+                              isInvalid={touched[`dest${i}`] && !!errors[`dest${i}`]}
+                              className={`${styles.input} slashed-zeroes`}
+                            />
+                          </rb.Form.Group>
+                        )
+                      })}
+                    {!collaborativeOperationRunning && (
+                      <p className="text-secondary mb-4">{t('scheduler.description_fees')}</p>
+                    )}
+                    <rb.Button
+                      className={styles.submit}
+                      variant="dark"
+                      type="submit"
+                      disabled={!collaborativeOperationRunning && (isSubmitting || isLoading || !isValid)}
+                    >
+                      <div className="d-flex justify-content-center align-items-center">
+                        {collaborativeOperationRunning ? t('scheduler.button_stop') : t('scheduler.button_start')}
+                      </div>
+                    </rb.Button>
+                  </rb.Form>
                 </>
               )}
-              {!collaborativeOperationRunning &&
-                !destinationIsInternal &&
-                [1, 2, 3].map((i) => {
-                  return (
-                    <rb.Form.Group className="mb-4" key={i} controlId={`dest${i}`}>
-                      <rb.Form.Label>{t('schedule.label_destination_input', { destination: i })}</rb.Form.Label>
-                      {!wallet || !walletInfo || isLoading ? (
-                        <rb.Placeholder as="div" animation="wave">
-                          <rb.Placeholder xs={12} className={styles['input-loader']} />
-                        </rb.Placeholder>
-                      ) : (
-                        <rb.Form.Control
-                          name={`dest${i}`}
-                          value={values[`dest${i}`]}
-                          placeholder={t('schedule.placeholder_destination_input')}
-                          onChange={handleChange}
-                          onBlur={handleBlur}
-                          isInvalid={touched[`dest${i}`] && !!errors[`dest${i}`]}
-                          className={`${styles.input} slashed-zeroes`}
-                        />
-                      )}
-                    </rb.Form.Group>
-                  )
-                })}
-              <rb.Button
-                className={styles.submit}
-                variant="dark"
-                type="submit"
-                disabled={!collaborativeOperationRunning && (isSubmitting || !isValid || !dirty)}
-              >
-                <div className="d-flex justify-content-center align-items-center">
-                  {collaborativeOperationRunning ? t('schedule.button_stop') : t('schedule.button_start')}
-                </div>
-              </rb.Button>
-            </rb.Form>
-          </>
-        )}
-      </Formik>
+            </Formik>
+          )}
+        </>
+      )}
     </>
   )
 }
diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx
index 12d121a..5bfa04e 100644
--- a/src/components/Navbar.jsx
+++ b/src/components/Navbar.jsx
@@ -4,7 +4,7 @@ import * as rb from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import Sprite from './Sprite'
 import Balance from './Balance'
-import { EarnIndicator, JoiningIndicator } from './ActivityIndicators'
+import { TabActivityIndicator, JoiningIndicator } from './ActivityIndicators'
 import { useSettings } from '../context/SettingsContext'
 import { useCurrentWallet, useCurrentWalletInfo } from '../context/WalletContext'
 import { useServiceInfo, useSessionConnectionError } from '../context/ServiceInfoContext'
@@ -24,6 +24,7 @@ const WalletPreview = ({ wallet, walletInfo, unit, showBalance }) => {
               valueString={walletInfo.data.display.walletinfo.total_balance}
               convertToUnit={unit}
               showBalance={showBalance || false}
+              enableVisibilityToggle={false}
             />
           </div>
         ) : (
@@ -34,13 +35,24 @@ const WalletPreview = ({ wallet, walletInfo, unit, showBalance }) => {
   )
 }
 
-const CenterNav = ({ makerRunning, onClick }) => {
+const CenterNav = ({ makerRunning, cjRunning, onClick }) => {
   const { t } = useTranslation()
 
   const scheduleEnabled = isFeatureEnabled('schedule')
 
   return (
     <rb.Nav className="justify-content-center align-items-stretch">
+      <rb.Nav.Item className="d-flex align-items-stretch">
+        <NavLink
+          to={routes.receive}
+          onClick={onClick}
+          className={({ isActive }) =>
+            'center-nav-link nav-link d-flex align-items-center justify-content-center' + (isActive ? ' active' : '')
+          }
+        >
+          {t('navbar.tab_receive')}
+        </NavLink>
+      </rb.Nav.Item>
       {scheduleEnabled && (
         <rb.Nav.Item className="d-flex align-items-stretch">
           <NavLink
@@ -50,44 +62,36 @@ const CenterNav = ({ makerRunning, onClick }) => {
               'center-nav-link nav-link d-flex align-items-center justify-content-center' + (isActive ? ' active' : '')
             }
           >
-            {t('Jam')}
+            <div className="d-flex align-items-start">
+              {t('Jam')}
+              <TabActivityIndicator isOn={cjRunning} />
+            </div>
           </NavLink>
         </rb.Nav.Item>
       )}
       <rb.Nav.Item className="d-flex align-items-stretch">
         <NavLink
-          to={routes.send}
-          onClick={onClick}
-          className={({ isActive }) =>
-            'center-nav-link nav-link d-flex align-items-center justify-content-center' + (isActive ? ' active' : '')
-          }
-        >
-          {t('navbar.tab_send')}
-        </NavLink>
-      </rb.Nav.Item>
-      <rb.Nav.Item className="d-flex align-items-stretch">
-        <NavLink
-          to={routes.receive}
+          to={routes.earn}
           onClick={onClick}
           className={({ isActive }) =>
             'center-nav-link nav-link d-flex align-items-center justify-content-center' + (isActive ? ' active' : '')
           }
         >
-          {t('navbar.tab_receive')}
+          <div className="d-flex align-items-start">
+            {t('navbar.tab_earn')}
+            <TabActivityIndicator isOn={makerRunning} />
+          </div>
         </NavLink>
       </rb.Nav.Item>
       <rb.Nav.Item className="d-flex align-items-stretch">
         <NavLink
-          to={routes.earn}
+          to={routes.send}
           onClick={onClick}
           className={({ isActive }) =>
             'center-nav-link nav-link d-flex align-items-center justify-content-center' + (isActive ? ' active' : '')
           }
         >
-          <div className="d-flex align-items-start">
-            {t('navbar.tab_earn')}
-            <EarnIndicator isOn={makerRunning} />
-          </div>
+          {t('navbar.tab_send')}
         </NavLink>
       </rb.Nav.Item>
     </rb.Nav>
@@ -232,7 +236,11 @@ export default function Navbar() {
                     <rb.Offcanvas.Title>{t('navbar.title')}</rb.Offcanvas.Title>
                   </rb.Offcanvas.Header>
                   <rb.Offcanvas.Body>
-                    <CenterNav makerRunning={serviceInfo?.makerRunning} onClick={() => setIsExpanded(!isExpanded)} />
+                    <CenterNav
+                      makerRunning={serviceInfo?.makerRunning}
+                      cjRunning={serviceInfo?.coinjoinInProgress}
+                      onClick={() => setIsExpanded(!isExpanded)}
+                    />
                     <TrailingNav
                       coinjoinInProgess={serviceInfo?.coinjoinInProgress}
                       onClick={() => setIsExpanded(!isExpanded)}
@@ -240,7 +248,7 @@ export default function Navbar() {
                   </rb.Offcanvas.Body>
                 </rb.Navbar.Offcanvas>
                 <rb.Container className="d-none d-md-flex flex-1 flex-grow-0 align-items-stretch">
-                  <CenterNav makerRunning={serviceInfo?.makerRunning} />
+                  <CenterNav makerRunning={serviceInfo?.makerRunning} cjRunning={serviceInfo?.coinjoinInProgress} />
                 </rb.Container>
                 <rb.Container className="d-none d-md-flex flex-1 align-items-stretch">
                   <div className="ms-auto d-flex align-items-stretch">
diff --git a/src/components/ScheduleProgress.jsx b/src/components/ScheduleProgress.jsx
new file mode 100644
index 0000000..03709f3
--- /dev/null
+++ b/src/components/ScheduleProgress.jsx
@@ -0,0 +1,147 @@
+import React from 'react'
+import * as rb from 'react-bootstrap'
+import { Trans, useTranslation } from 'react-i18next'
+import styles from './ScheduleProgress.module.css'
+
+const scheduleToSteps = (schedule) => {
+  // Example Schedule:
+  //
+  // schedule = [
+  //   [0, 0, 8, 'INTERNAL', 363.52, 16, 0],
+  //   [1, 0.5748023323401029, 9, 'INTERNAL', 59.15, 16, 0],
+  //   [1, 0, 9, 'INTERNAL', 59.89, 16, 0],
+  //   [2, 0.5743766347092694, 8, 'INTERNAL', 14.61, 16, 0],
+  //   [2, 0, 10, 'bcrt1qavv7j2a8staeyj4mp7l46a25u02f4xeler2ngp', 47.85, 16, 0],
+  //   [3, 0.8201219196908677, 9, 'INTERNAL', 7.26, 16, 0],
+  //   [3, 0, 10, 'bcrt1qz5hs2dfq29xnmt6lg68cewqhmam4qchxkrayyz', 220.48, 16, 0],
+  //   [4, 0, 9, 'bcrt1qz336g7kzzhqskzc5k0rsgvxu4l52a8gzkulz4v', 3.48, 16, 0],
+  // ]
+  //
+  // Each array represents a scheduled transaction with the following parameters:
+  //
+  // Index 0: Source mixdepth.
+  // Index 1: Amount. Either a decimal (0.0<x<1.0) representing what proportion of the coins in the mixdepth are to be spent. If 0, a sweep is performed.
+  // Index 2: Number of counterparties requested.
+  // Index 3: Destination address. Either an external Bitcoin address, or "INTERNAL" meaning an address from the next mixdepth.
+  // Index 4: Waittime. A decimal value in minutes to wait, after confirmation of this transaction, before continuing to the next.
+  // Index 5: Rounding. How many significant figures to round the coinjoin amount to. A rounding value of `16` means no rounding.
+  // Index 6: Completion flag. 0 until the transaction is seen on the network. At that point it is changed to the <txid>. Then changes to 1 when tx is confirmed.
+  //
+  // See: https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/scripts/sample-schedule-for-testnet
+
+  // Count how many tx are compelted already.
+  const numCompleted = schedule.reduce((acc, tx) => (acc += tx[6] === 1 ? 1 : 0), 0)
+  // Calculate total wait time. Ignores the last element since there's no reason in waiting after the last element completed.
+  // This is a lower bound estimate for the time the whole scheduler run will take since it doesn't take into account the time
+  // it takes to find and talk to makers and wait for tx confirmations on the timechain.
+  const totalWaitTime = schedule.slice(0, schedule.length - 1).reduce((acc, tx) => acc + tx[4] * 60, 0)
+  // Distribute the tx's on the progress bar relative to the waittime after the previous tx completes.
+  const txs = schedule.map((tx, i) => {
+    const minWidth = 8
+
+    if (i === 0) {
+      return { width: minWidth }
+    }
+
+    const proportionalWidth = ((schedule[i - 1][4] * 60) / totalWaitTime) * 100
+
+    return { width: Math.max(proportionalWidth, minWidth) }
+  })
+
+  return {
+    totalWaitTime: totalWaitTime,
+    completedTxs: numCompleted,
+    txs: txs,
+  }
+}
+
+const ScheduleProgress = ({ schedule }) => {
+  const { t } = useTranslation()
+
+  const steps = scheduleToSteps(schedule)
+
+  const stepsJsx = steps.txs.map((step, i) => {
+    let classes = [styles['progress-step']]
+
+    if (i === 0) {
+      classes.push(styles['is-first'])
+    }
+
+    if (steps.completedTxs > i) {
+      classes.push(styles['is-complete'])
+    }
+
+    if (steps.completedTxs === i) {
+      classes.push(styles['is-active'])
+    }
+
+    if (steps.txs.length - 1 === i) {
+      classes.push(styles['is-last'])
+    }
+
+    return <div key={i} style={{ width: `${step.width}%` }} className={classes.join(' ')}></div>
+  })
+
+  return (
+    <div className="d-flex flex-column gap-3">
+      <div>
+        <p className="mb-1">
+          {Math.ceil(steps.totalWaitTime / 60 / 60) === 1 ? (
+            <Trans i18nKey="scheduler.progress_tldr_seconds">
+              Currently running a schedule of <strong>{{ length: steps.txs.length }}</strong> transactions over
+              <strong>{{ seconds: Math.ceil(steps.totalWaitTime) }}</strong> seconds.
+            </Trans>
+          ) : (
+            <Trans i18nKey="scheduler.progress_tldr_hours">
+              Currently running a schedule of <strong>{{ length: steps.txs.length }}</strong> transactions over
+              <strong>{{ hours: Math.ceil(steps.totalWaitTime / 60 / 60) }}</strong> hours.
+            </Trans>
+          )}
+        </p>
+        <p className={['text-secondary', styles['text-small']].join(' ')}>{t('scheduler.progress_description')}</p>
+      </div>
+      <div className={styles['schedule-progress']}>
+        <div className={styles['progress-container']}>
+          <div className={styles['progress-track']}></div>
+          {stepsJsx}
+        </div>
+      </div>
+      <div className={[styles['text'], 'text-secondary'].join(' ')}>
+        <div>
+          {steps.completedTxs < steps.txs.length ? (
+            <>
+              <div>
+                <rb.Spinner
+                  as="span"
+                  animation="border"
+                  size="sm"
+                  role="status"
+                  aria-hidden="true"
+                  className="ms-1 me-2"
+                />
+                <Trans i18nKey="scheduler.progress_current_state">
+                  Waiting for transaction <strong>{{ current: steps.completedTxs + 1 }}</strong> of
+                  <strong>{{ total: steps.txs.length }}</strong> to process...
+                </Trans>
+              </div>
+            </>
+          ) : (
+            <div>
+              <rb.Spinner
+                as="span"
+                animation="border"
+                size="sm"
+                role="status"
+                aria-hidden="true"
+                className="ms-1 me-2"
+              />
+              {t('scheduler.progress_done')}
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export default ScheduleProgress
diff --git a/src/components/ScheduleProgress.module.css b/src/components/ScheduleProgress.module.css
new file mode 100644
index 0000000..eda1eaa
--- /dev/null
+++ b/src/components/ScheduleProgress.module.css
@@ -0,0 +1,136 @@
+.schedule-progress {
+  --progress-bar-height: 0.3rem;
+  --progress-upcoming-step-size: 0.8rem;
+  --progress-completed-step-size: 1.4rem;
+
+  --progress-track-color: var(--bs-gray-300);
+  --progress-step-upcoming-color: var(--bs-gray-300);
+  --progress-step-completed-color: rgba(39, 174, 96, 1);
+  --progress-step-active-color: rgba(39, 174, 96, 1);
+
+  --progress-bar-corner-radius: calc(
+    var(--progress-upcoming-step-size) + ((var(--progress-bar-height) - var(--progress-upcoming-step-size)) / 2)
+  );
+
+  position: relative;
+}
+
+.progress-container {
+  display: flex;
+  position: relative;
+  min-height: var(--progress-completed-step-size);
+  top: calc((var(--progress-completed-step-size) - var(--progress-bar-height)) / 2);
+}
+
+/* progress bar background */
+.progress-track {
+  position: absolute;
+  width: 100%;
+  height: var(--progress-bar-height);
+  background-color: var(--progress-track-color);
+  border-radius: var(--progress-bar-corner-radius);
+  z-index: -1;
+  top: 0rem;
+}
+
+/* step marker container */
+.progress-step {
+  position: relative;
+  display: flex;
+}
+
+/* step marker future */
+.progress-step:before {
+  content: '';
+  position: relative;
+  top: calc((var(--progress-bar-height) - var(--progress-upcoming-step-size)) / 2);
+  left: calc(0rem - ((var(--progress-bar-height) - var(--progress-upcoming-step-size)) / 2));
+  width: var(--progress-upcoming-step-size);
+  height: var(--progress-upcoming-step-size);
+  background: var(--progress-step-upcoming-color);
+  border-radius: 100%;
+  margin: 0 0 0 auto;
+}
+
+/* step marker active */
+.progress-step.is-active:before {
+  background: var(--progress-step-active-color);
+  animation: pulse 2s infinite;
+}
+
+/* step marker complete */
+.progress-step.is-complete:before {
+  content: '\2713';
+  top: calc(0rem - ((var(--progress-completed-step-size) - var(--progress-bar-height)) / 2));
+  left: calc(
+    (0rem - ((var(--progress-bar-height) - var(--progress-upcoming-step-size)) / 2)) +
+      ((var(--progress-completed-step-size) - var(--progress-upcoming-step-size)) / 2)
+  );
+  color: var(--progress-step-upcoming-color);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: var(--progress-completed-step-size);
+  height: var(--progress-completed-step-size);
+  background: var(--progress-step-completed-color);
+}
+
+/* progress bar */
+.progress-step.is-complete:after {
+  content: '';
+  position: absolute;
+  width: 100%;
+  height: var(--progress-bar-height);
+  background: rgba(39, 174, 96, 0.4);
+  z-index: -1;
+}
+
+/* progress bar left end */
+.progress-step.is-complete.is-first:after {
+  border-radius: var(--progress-bar-corner-radius) 0 0 var(--progress-bar-corner-radius);
+}
+
+.progress-step.is-active.is-first:after {
+  border-radius: var(--progress-bar-corner-radius);
+}
+
+/* progress bar tip */
+.progress-step.is-active:after {
+  content: '';
+  position: absolute;
+  width: 100%;
+  border-radius: 0 var(--progress-bar-corner-radius) var(--progress-bar-corner-radius) 0;
+  height: var(--progress-bar-height);
+  background: rgba(39, 174, 96, 0.4);
+  z-index: -1;
+  animation: nextStep 1s;
+}
+
+.progress-step.is-last.is-complete:after {
+  border-radius: 0 var(--progress-bar-corner-radius) var(--progress-bar-corner-radius) 0;
+}
+
+@keyframes pulse {
+  0% {
+    box-shadow: 0 0 0 0 rgba(39, 174, 96, 0.4);
+  }
+  70% {
+    box-shadow: 0 0 0 10px rgba(39, 174, 96, 0);
+  }
+  100% {
+    box-shadow: 0 0 0 0 rgba(39, 174, 96, 0);
+  }
+}
+
+@keyframes nextStep {
+  0% {
+    width: 0%;
+  }
+  100% {
+    width: 100%;
+  }
+}
+
+.text-small {
+  font-size: 0.8rem;
+}
diff --git a/src/components/Send.jsx b/src/components/Send.jsx
index c434e53..e8c7265 100644
--- a/src/components/Send.jsx
+++ b/src/components/Send.jsx
@@ -717,6 +717,7 @@ export default function Send() {
           <rb.Form.Group controlId="isCoinjoin" className={`${isCoinjoin ? 'mb-3' : ''}`}>
             <ToggleSwitch
               label={t('send.toggle_coinjoin')}
+              subtitle={t('send.toggle_coinjoin_subtitle')}
               initialValue={isCoinjoin}
               onToggle={(isToggled) => setIsCoinjoin(isToggled)}
               disabled={isLoading || isOperationDisabled}
diff --git a/src/components/Send.module.css b/src/components/Send.module.css
index 2afe31a..5775b9f 100644
--- a/src/components/Send.module.css
+++ b/src/components/Send.module.css
@@ -104,8 +104,8 @@ input[type='number'] {
   color: var(--bs-white);
 }
 
-:root[data-theme='dark'] .selected-dark {
-  background-color: var(--bs-gray-dark) !important;
+:root[data-theme='dark'] .collaborators-selector .selected-dark {
+  background-color: var(--bs-gray-dark);
   border-color: var(--bs-gray-dark) !important;
 }
 
diff --git a/src/components/Wallet.jsx b/src/components/Wallet.jsx
index bbe9bdc..e1e5f8d 100644
--- a/src/components/Wallet.jsx
+++ b/src/components/Wallet.jsx
@@ -5,7 +5,7 @@ import * as rb from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { walletDisplayName } from '../utils'
 import * as Api from '../libs/JmWalletApi'
-import { EarnIndicator, JoiningIndicator } from './ActivityIndicators'
+import { TabActivityIndicator, JoiningIndicator } from './ActivityIndicators'
 import { routes } from '../constants/routes'
 import styles from './Wallet.module.css'
 
@@ -292,7 +292,7 @@ export default function Wallet({
                     <Link className="wallet-name" to={routes.wallet}>
                       {walletDisplayName(name)}
                     </Link>
-                    {makerRunning && <EarnIndicator isOn={true} />}
+                    {makerRunning && <TabActivityIndicator isOn={true} />}
                     {coinjoinInProgress && <JoiningIndicator isOn={true} className="text-success" />}
                   </span>
                 ) : (
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index 08c8b65..4a903cc 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -151,11 +151,11 @@
   },
   "send": {
     "loading": "Loading",
-    "title": "Send bitcoin",
-    "subtitle": "Collaborative transactions increase the privacy of yourself and others.",
+    "title": "Send",
+    "subtitle": "Use a collaborative transaction for increased security and privacy.",
     "label_num_collaborators": "Number of collaborators: {{ numCollaborators }}",
     "input_num_collaborators_placeholder": "Other",
-    "description_num_collaborators": "A higher number is better for privacy, but also increases the fee.",
+    "description_num_collaborators": "A higher number is better for privacy, but also increases the transaction fee.",
     "error_invalid_num_collaborators": "Please use between {{ minNumCollaborators }} and {{ maxNumCollaborators }} collaborators.",
     "taker_error_message_max_fees_config_missing": "Config variables 'max_cj_fee_rel' and 'max_cj_fee_abs' must be set in your joinmarket.cfg in order to send collaborative transactions. Consider adding them to your config manually.",
     "direct_payment_error_message_bad_request": "Please verify your inputs carefully.",
@@ -175,7 +175,8 @@
     "label_amount": "Amount in sats",
     "placeholder_amount": "Enter amount...",
     "feedback_invalid_amount": "Please provide a valid amount.",
-    "toggle_coinjoin": "Send as collaborative transaction for improved privacy",
+    "toggle_coinjoin": "Send as collaborative transaction",
+    "toggle_coinjoin_subtitle": "Collaborative transactions improve the privacy of yourself and others.",
     "button_send": "Send",
     "text_sending": "Sending",
     "button_sweep": "Sweep",
@@ -188,12 +189,12 @@
   },
   "receive": {
     "title": "Receive",
-    "subtitle": "Send bitcoin to the address below.",
-    "error_loading_address_failed": "Loading new address failed.",
+    "subtitle": "Send sats to the address below to fund your wallet.",
+    "error_loading_address_failed": "Unable to get new address.",
     "error_copy_address_failed": "Could not copy address.",
     "button_copy_address": "Copy",
     "text_copy_address_confirmed": "Copied",
-    "button_settings": "Settings",
+    "button_settings": "Advanced",
     "label_choose_account": "Choose account",
     "account_selector_option_dev_mode": "Account {{ number }}",
     "label_amount": "Amount to request in sats",
@@ -202,22 +203,23 @@
     "text_getting_address": "Getting new address"
   },
   "earn": {
-    "title": "Earn bitcoin",
-    "subtitle": "By making your bitcoin available for others, you help them improve their privacy and can also earn a yield.",
+    "title": "Earn",
+    "subtitle": "By providing liquidity to the market, you improve the privacy of yourself and others. Your sats always remain under your control and never leave your wallet.",
+    "market_explainer": "You can choose how much you want to charge for your offer. Note that you participate and compete in an open market—there is no guarantee that other market participants will take you up on your offer. Being chosen for a collaborative transaction can take some time. Low fees increase your chances.",
     "alert_empty_report": "The report is empty.",
     "text_report_length": "Showing {{ visibleLines }} of {{ linesWithoutHeader }} entries",
-    "error_loading_report_failed": "Failed to load yield generator report.",
+    "error_loading_report_failed": "Failed to load earnings report.",
     "alert_starting": "Service is starting.",
     "alert_stopping": "Service is stopping.",
-    "alert_running": "Service is running.",
+    "alert_running": "Earning sats...",
     "alert_coinjoin_in_progress": "A collaborative transaction is currently in progress.",
     "toggle_rel_offer": "Relative offer",
     "label_rel_fee": "Relative fee {{ fee }}",
-    "description_rel_fee": "As a percentage of the amounts you help others with improved privacy.",
+    "description_rel_fee": "The fee to charge others, as a percentage of the transaction amount.",
     "feedback_invalid_rel_fee": "Please provide a relative fee between {{feeRelPercentageMin}} and {{feeRelPercentageMax}}.",
-    "label_abs_fee": "Absolute fee in sats",
-    "description_abs_fee": "An absolute amount you get for helping others to improve their privacy.",
-    "feedback_invalid_abs_fee": "Please provide an absolute fee.",
+    "label_abs_fee": "Absolute fee {{ fee }}",
+    "description_abs_fee": "The fee to charge others, as an absolute amount denominated in sats.",
+    "feedback_invalid_abs_fee": "Invalid absolute fee.",
     "label_min_amount": "Minimum amount in sats",
     "feedback_invalid_min_amount": "Please provide a minimum amount.",
     "button_start": "Start",
@@ -257,20 +259,27 @@
       "description": "Still confused? Dig into the <2>documentation</2>."
     }
   },
-  "schedule": {
-    "title": "Jam Schedule (Experimental)",
-    "subtitle": "Jam can schedule and autoamtically execute multiple collaborative transactions over a longer period of time. When finished, your funds will be detached from their history.",
-    "error_starting_schedule_failed": "Starting the schedule failed.",
-    "error_stopping_schedule_failed": "Stopping the schedule failed.",
+  "scheduler": {
+    "title": "Jam Scheduler (Experimental)",
+    "subtitle": "Execute multiple transactions using random amounts and time intervals to increase the privacy of yourself and others. Every scheduled transaction is a collaborative transaction. The scheduler will use all available funds that aren't frozen.",
+    "error_starting_schedule_failed": "Starting the scheduler failed.",
+    "error_stopping_schedule_failed": "Stopping the scheduler failed.",
     "complete_wallet_title": "Complete Wallet",
     "complete_wallet_subtitle": "This will use all of your non-frozen funds.",
-    "description_destination_addresses": "To keep your funds private, they will be randomly split up accross three destination addresses. These could be coming from your cold storage or be deposit addresses of an exchange. Instead, you can also choose to keep your funds in Jam.",
+    "description_destination_addresses": "You can choose to send your funds to an external wallet after completion automatically. However, this feature requires you to provide multiple destination addresses to preserve your privacy.",
     "error_invalid_destionation_address": "Please enter valid a destination address.",
-    "toggle_internal_destination_title": "Keep funds in Jam",
-    "toggle_internal_destination_subtitle": "Use destination addresses from account {{ account }} in your wallet.",
+    "toggle_internal_destination_title": "Send to external wallet",
+    "toggle_internal_destination_subtitle": "Specify destination addresses to send funds to an external wallet.",
     "label_destination_input": "Destination {{ destination }}",
     "placeholder_destination_input": "Enter destination address...",
-    "button_start": "Start Schedule",
-    "button_stop": "Stop Schedule"
+    "description_fees": "The scheduler will pay all occurring fees automatically.",
+    "button_start": "Start Scheduler",
+    "button_stop": "Stop Scheduler",
+    "progress_tldr_seconds": "Scheduled <1>{{ length }}</1> transactions over <3>{{ seconds }}</3> seconds.",
+    "progress_tldr_hours": "Scheduled <1>{{ length }}</1> transactions over <3>{{ hours }}</3> hours.",
+    "progress_description": "This estimate is the minimum waiting time. Additional delays due to network communication or transaction confirmation are not considered.",
+    "progress_current_state": "Waiting for transaction <1>{{ current }}</1> of <3>{{ total }}</3> to process...",
+    "progress_done": "All transactions completed successfully. The scheduler will stop soon.",
+    "error_loading_schedule_failed": "Loading schedule progress failed."
   }
 }
diff --git a/src/index.css b/src/index.css
index 972db92..6f4fa89 100644
--- a/src/index.css
+++ b/src/index.css
@@ -502,6 +502,10 @@ h2 {
     center/1em auto no-repeat;
 }
 
+.placeholder {
+  background-color: var(--bs-gray-400);
+}
+
 /* Privacy Level Styles */
 
 .privacy-level-4 {
@@ -528,6 +532,9 @@ h2 {
   border-top: 1px solid rgba(222, 222, 222, 1);
   opacity: 1;
 }
+:root[data-theme='dark'] .privacy-levels hr {
+  border-color: var(--bs-gray-800);
+}
 
 .privacy-levels .btn.btn-outline-dark {
   padding: 0.25rem;
@@ -607,18 +614,14 @@ h2 {
   --bs-black: #000;
 }
 
-.placeholder {
-  background-color: #e9ecef;
-}
-
-:root[data-theme='dark'] * {
+:root[data-theme='dark'] .border-top,
+:root[data-theme='dark'] .border-bottom,
+:root[data-theme='dark'] .border-left,
+:root[data-theme='dark'] .border-right,
+:root[data-theme='dark'] .border {
   border-color: var(--bs-gray-800) !important;
 }
 
-:root[data-theme='dark'] a.nav-link {
-  border-color: transparent !important;
-}
-
 :root[data-theme='dark'] a.nav-link.active {
   border-color: white !important;
 }
@@ -640,6 +643,7 @@ h2 {
 
 :root[data-theme='dark'] .accordion-item {
   background: transparent !important;
+  border-color: var(--bs-gray-800) !important;
 }
 
 :root[data-theme='dark'] .accordion-button,
@@ -675,10 +679,20 @@ h2 {
   background-color: var(--bs-gray-900);
 }
 
+:root[data-theme='dark'] .modal-header,
+:root[data-theme='dark'] .modal-footer {
+  border-color: var(--bs-gray-800) !important;
+}
+
 :root[data-theme='dark'] .modal-header .btn-close {
   background-color: var(--bs-gray-900) !important;
 }
 
+:root[data-theme='dark'] .table,
+:root[data-theme='dark'] .table > :not(:first-child) {
+  border-color: var(--bs-gray-800) !important;
+}
+
 :root[data-theme='dark'] .form-control:disabled,
 :root[data-theme='dark'] .form-select:disabled,
 .form-control[readonly] {
diff --git a/src/libs/JmWalletApi.ts b/src/libs/JmWalletApi.ts
index 56344bf..8789da2 100644
--- a/src/libs/JmWalletApi.ts
+++ b/src/libs/JmWalletApi.ts
@@ -92,12 +92,12 @@ interface ConfigSetRequest {
   field: string
 }
 
-interface StartTumblerRequest {
+interface StartSchedulerRequest {
   destination_addresses: BitcoinAddress[]
-  tumbler_options?: TumblerOptions
+  scheduler_options?: SchedulerOptions
 }
 
-interface TumblerOptions {
+interface SchedulerOptions {
   mixdepthsrc?: number
   restart?: boolean
   schedulefile?: string
@@ -276,23 +276,26 @@ const postFreeze = async (
   })
 }
 
-const postTumblerStart = async ({ token, signal, walletName }: WalletRequestContext, req: StartTumblerRequest) => {
+const postSchedulerStart = async (
+  { token, signal, walletName }: WalletRequestContext,
+  { destination_addresses, scheduler_options }: StartSchedulerRequest
+) => {
   return await fetch(`${basePath()}/v1/wallet/${walletName}/taker/schedule`, {
     method: 'POST',
     headers: { ...Authorization(token) },
-    body: JSON.stringify({ ...req }),
+    body: JSON.stringify({ destination_addresses: destination_addresses, tumbler_options: scheduler_options }),
     signal,
   })
 }
 
-const getTumblerStop = async ({ token, signal, walletName }: WalletRequestContext) => {
+const getSchedulerStop = async ({ token, signal, walletName }: WalletRequestContext) => {
   return await fetch(`${basePath()}/v1/wallet/${walletName}/taker/stop`, {
     headers: { ...Authorization(token) },
     signal,
   })
 }
 
-const getTumblerSchedule = async ({ token, signal, walletName }: WalletRequestContext) => {
+const getSchedule = async ({ token, signal, walletName }: WalletRequestContext) => {
   return await fetch(`${basePath()}/v1/wallet/${walletName}/taker/schedule`, {
     headers: { ...Authorization(token) },
     signal,
@@ -379,8 +382,8 @@ export {
   postFreeze,
   postConfigGet,
   getWalletSeed,
-  postTumblerStart,
-  getTumblerStop,
-  getTumblerSchedule,
+  postSchedulerStart,
+  getSchedulerStop,
+  getSchedule,
   Helper,
 }


Test patch:
diff --git a/docker/regtest/docker-compose.yml b/docker/regtest/docker-compose.yml
index 0fc0ef2..af072e9 100644
--- a/docker/regtest/docker-compose.yml
+++ b/docker/regtest/docker-compose.yml
@@ -12,11 +12,7 @@ services:
       READY_FILE: /root/.nbxplorer/btc_fully_synched
       ENSURE_WALLET: 1
       jm_rpc_wallet_file: jm_primary
-      jm_max_cj_fee_abs: 42000000 # 0.42 btc
-      jm_max_cj_fee_rel: 0.1337 # 13.37%
       jm_minimum_makers: 1
-      jm_taker_utxo_age: 1
-      jm_taker_utxo_retries: 10
       jm_tx_broadcast: self
       jm_blockchain_source: regtest
       jm_network: testnet
@@ -54,11 +50,7 @@ services:
       APP_USER: joinmarket
       APP_PASSWORD: joinmarket
       jm_rpc_wallet_file: jm_secondary
-      jm_max_cj_fee_abs: 42000000 # 0.42 btc
-      jm_max_cj_fee_rel: 0.1337 # 13.37%
       jm_minimum_makers: 1
-      jm_taker_utxo_age: 1
-      jm_taker_utxo_retries: 10
       jm_tx_broadcast: self
       jm_blockchain_source: regtest
       jm_network: testnet
diff --git a/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg b/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg
index a8bf24c..5ae4c86 100644
--- a/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg
+++ b/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg
@@ -121,13 +121,17 @@ max_sweep_fee_change = 0.8
 # market maker for a transaction. Both the limits given in
 # max_cj_fee_abs and max_cj_fee_rel must be exceeded in order
 # to not consider a certain offer.
-max_cj_fee_abs = 200000 # 0.002 btc
+
+# taken from jam production image
+max_cj_fee_abs = 300000
 
 # Maximum relative coinjoin fee, in fractions of the coinjoin value
 # e.g. if your coinjoin amount is 2 btc (200000000 satoshi) and
 # max_cj_fee_rel = 0.001 (0.1%), the maximum fee allowed would
 # be 0.002 btc (200000 satoshi)
-max_cj_fee_rel = 0.001 # 0.1%
+
+# taken from jam production image
+max_cj_fee_rel = 0.0003
 
 # the range of confirmations passed to the `listunspent` bitcoind RPC call
 # 1st value is the inclusive minimum, defaults to one confirmation
diff --git a/docker/regtest/dockerfile-deps/joinmarket/webui-standalone/default.cfg b/docker/regtest/dockerfile-deps/joinmarket/webui-standalone/default.cfg
index a8bf24c..5ae4c86 100644
--- a/docker/regtest/dockerfile-deps/joinmarket/webui-standalone/default.cfg
+++ b/docker/regtest/dockerfile-deps/joinmarket/webui-standalone/default.cfg
@@ -121,13 +121,17 @@ max_sweep_fee_change = 0.8
 # market maker for a transaction. Both the limits given in
 # max_cj_fee_abs and max_cj_fee_rel must be exceeded in order
 # to not consider a certain offer.
-max_cj_fee_abs = 200000 # 0.002 btc
+
+# taken from jam production image
+max_cj_fee_abs = 300000
 
 # Maximum relative coinjoin fee, in fractions of the coinjoin value
 # e.g. if your coinjoin amount is 2 btc (200000000 satoshi) and
 # max_cj_fee_rel = 0.001 (0.1%), the maximum fee allowed would
 # be 0.002 btc (200000 satoshi)
-max_cj_fee_rel = 0.001 # 0.1%
+
+# taken from jam production image
+max_cj_fee_rel = 0.0003
 
 # the range of confirmations passed to the `listunspent` bitcoind RPC call
 # 1st value is the inclusive minimum, defaults to one confirmation
diff --git a/docker/regtest/init-setup.sh b/docker/regtest/init-setup.sh
index 1e272d4..6b55949 100755
--- a/docker/regtest/init-setup.sh
+++ b/docker/regtest/init-setup.sh
@@ -22,8 +22,8 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)
 # this will get more coins than the primary one in order to successfully run the tumbler.py script
 . "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket2 --unmatured --blocks 50
 
-# make block rewards spendable
-. "$script_dir/mine-block.sh" 110 &>/dev/null
+# make block rewards spendable: 100 + 5 (default of `taker_utxo_age`) + 1 = 106
+. "$script_dir/mine-block.sh" 106 &>/dev/null
 
 
 base_url='https://localhost:29183'
diff --git a/src/components/Balance.test.jsx b/src/components/Balance.test.jsx
index 551c499..8c62461 100644
--- a/src/components/Balance.test.jsx
+++ b/src/components/Balance.test.jsx
@@ -1,4 +1,6 @@
 import React from 'react'
+import { act } from 'react-dom/test-utils'
+import user from '@testing-library/user-event'
 import { render, screen } from '../testUtils'
 import { BTC, SATS } from '../utils'
 
@@ -116,4 +118,70 @@ describe('<Balance />', () => {
     render(<Balance valueString={43000} convertToUnit={SATS} showBalance={true} />)
     expect(screen.getByText(`43000`)).toBeInTheDocument()
   })
+
+  it('should toggle visibility of initially hidden balance on click by default', () => {
+    render(<Balance valueString={`21`} convertToUnit={SATS} showBalance={false} />)
+    expect(screen.queryByText(`21`)).not.toBeInTheDocument()
+    expect(screen.getByText(`*****`)).toBeInTheDocument()
+
+    act(() => {
+      user.click(screen.getByText(`*****`))
+    })
+
+    expect(screen.getByText(`21`)).toBeInTheDocument()
+    expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
+
+    act(() => {
+      user.click(screen.getByText(`21`))
+    })
+
+    expect(screen.queryByText(`21`)).not.toBeInTheDocument()
+    expect(screen.getByText(`*****`)).toBeInTheDocument()
+  })
+
+  it('should NOT toggle visibility of initially hidden balance on click when disabled via flag', () => {
+    render(<Balance valueString={`21`} convertToUnit={SATS} showBalance={false} enableVisibilityToggle={false} />)
+    expect(screen.queryByText(`21`)).not.toBeInTheDocument()
+    expect(screen.getByText(`*****`)).toBeInTheDocument()
+
+    act(() => {
+      user.click(screen.getByText(`*****`))
+    })
+
+    expect(screen.queryByText(`21`)).not.toBeInTheDocument()
+    expect(screen.getByText(`*****`)).toBeInTheDocument()
+  })
+
+  it('should NOT toggle visibility of initially visible balance on click by default', () => {
+    render(<Balance valueString={`21`} convertToUnit={SATS} showBalance={true} />)
+    expect(screen.getByText(`21`)).toBeInTheDocument()
+    expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
+
+    act(() => {
+      user.click(screen.getByText(`21`))
+    })
+
+    expect(screen.getByText(`21`)).toBeInTheDocument()
+    expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
+  })
+
+  it('should toggle visibility of initially visible balance on click when enabled via flag', () => {
+    render(<Balance valueString={`21`} convertToUnit={SATS} showBalance={true} enableVisibilityToggle={true} />)
+    expect(screen.getByText(`21`)).toBeInTheDocument()
+    expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
+
+    act(() => {
+      user.click(screen.getByText(`21`))
+    })
+
+    expect(screen.queryByText(`21`)).not.toBeInTheDocument()
+    expect(screen.getByText(`*****`)).toBeInTheDocument()
+
+    act(() => {
+      user.click(screen.getByText(`*****`))
+    })
+
+    expect(screen.getByText(`21`)).toBeInTheDocument()
+    expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
+  })
 })