From 387e8322f71f887bd0b18fe53557c753cfce7508 Mon Sep 17 00:00:00 2001 From: Andy Grabow Date: Tue, 7 Jul 2026 11:46:39 +0200 Subject: [PATCH 01/21] Polish bitfeed client display --- .beads/.gitignore | 32 ++++++++++ .beads/config.yaml | 1 + .beads/metadata.json | 7 +++ .gitignore | 17 ++++++ client/package-lock.json | 69 ++++++++++++++------- client/scripts/umbrel-dev-server.mjs | 85 ++++++++++++++++++++++++++ client/src/components/BlockInfo.svelte | 10 ++- client/src/components/Sidebar.svelte | 44 +++++++++++++ client/src/components/TxViz.svelte | 52 +++++++++++++++- client/src/controllers/TxController.js | 5 ++ client/src/models/TxBlockScene.js | 34 +++++++++-- client/src/models/TxPoolScene.js | 73 +++++++++++++++++++--- client/src/stores.js | 14 ++--- 13 files changed, 394 insertions(+), 49 deletions(-) create mode 100644 .beads/.gitignore create mode 100644 .beads/config.yaml create mode 100644 .beads/metadata.json create mode 100644 client/scripts/umbrel-dev-server.mjs diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 0000000..4082fed --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,32 @@ +dolt/ +embeddeddolt/ +proxieddb/ +backup/ +bd.sock +bd.sock.startlock +sync-state.json +last-touched +.exclusive-lock +daemon.* +push-state.json +*.lock +.beads-credential-key +.local_version +proxied_server_client_info.json +redirect +.sync.lock +export-state/ +export-state.json +last_pull +ephemeral.sqlite3* +dolt-server.* +dolt-pprof/ +*.corrupt.backup/ +.env +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm +db.sqlite +bd.db diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 0000000..700e266 --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1 @@ +sync.remote: "git+ssh://git@github.com/kakulukia/bitfeed.git" diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 0000000..5d0943a --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,7 @@ +{ + "database": "dolt", + "backend": "dolt", + "dolt_mode": "embedded", + "dolt_database": "bitfeed", + "project_id": "451af4ca-8ba0-4001-b42c-321c0623977e" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index c0f942f..939fe04 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,18 @@ TIMESTAMP.txt + +# Local agent/tool state +.agents/ +.claude/ +.codex/ +.codegraph/ +AGENTS.md +CLAUDE.md + +# Beads / Dolt files +.dolt/ +*.db +.beads-credential-key +.beads/proxieddb/ +.beads/README.md +.beads/hooks/ +.beads/interactions.jsonl diff --git a/client/package-lock.json b/client/package-lock.json index 364275e..5f362a3 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "bitfeed-client", - "version": "2.2.1", + "version": "2.3.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "bitfeed-client", - "version": "2.2.1", + "version": "2.3.4", "dependencies": { "@babel/core": "^7.16.5", "@babel/preset-env": "^7.16.5", @@ -37,6 +37,9 @@ "svelte": "^3.44.3", "svelte-preprocess": "^4.10.1", "svelte-select": "^4.4.7" + }, + "devDependencies": { + "@rollup/plugin-json": "^4.1.0" } }, "node_modules/@babel/code-frame": { @@ -1595,6 +1598,19 @@ "rollup": "^1.20.0||^2.0.0" } }, + "node_modules/@rollup/plugin-json": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-4.1.0.tgz", + "integrity": "sha512-yfLbTdNS6amI/2OpmbiBoW12vngr5NW2jCJVZSBEz+H5KfUJZ2M7sDjk0U6GOOdCWFVScShte29o9NezJ53TPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^3.0.8" + }, + "peerDependencies": { + "rollup": "^1.20.0 || ^2.0.0" + } + }, "node_modules/@rollup/plugin-node-resolve": { "version": "13.1.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.1.1.tgz", @@ -2521,6 +2537,20 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -3757,19 +3787,6 @@ "rollup": "^2.0.0" } }, - "node_modules/rollup-plugin-terser/node_modules/acorn": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.6.0.tgz", - "integrity": "sha512-U1riIR+lBSNi3IbxtaHOIKdH8sLFv3NYfNv8sg7ZsNhcfl4HF2++BfqqrNAxoCLQW1iiylOj76ecnaUxz+z9yw==", - "optional": true, - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/rollup-plugin-terser/node_modules/source-map": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", @@ -5398,6 +5415,15 @@ "integrity": "sha512-x0qpNXxbmGa9Jnl4OX89AORPe2T/a4DqNK69BGRnEdaPKq6MdiUXSTam/eCkF5DxkQGcRcPq0L4vzr/E3q4mVA==", "requires": {} }, + "@rollup/plugin-json": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-4.1.0.tgz", + "integrity": "sha512-yfLbTdNS6amI/2OpmbiBoW12vngr5NW2jCJVZSBEz+H5KfUJZ2M7sDjk0U6GOOdCWFVScShte29o9NezJ53TPw==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.0.8" + } + }, "@rollup/plugin-node-resolve": { "version": "13.1.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.1.1.tgz", @@ -6125,6 +6151,12 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "optional": true + }, "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -7088,13 +7120,6 @@ "terser": "^5.0.0" }, "dependencies": { - "acorn": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.6.0.tgz", - "integrity": "sha512-U1riIR+lBSNi3IbxtaHOIKdH8sLFv3NYfNv8sg7ZsNhcfl4HF2++BfqqrNAxoCLQW1iiylOj76ecnaUxz+z9yw==", - "optional": true, - "peer": true - }, "source-map": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", diff --git a/client/scripts/umbrel-dev-server.mjs b/client/scripts/umbrel-dev-server.mjs new file mode 100644 index 0000000..85c3da4 --- /dev/null +++ b/client/scripts/umbrel-dev-server.mjs @@ -0,0 +1,85 @@ +import fs from 'node:fs' +import http from 'node:http' +import https from 'node:https' +import net from 'node:net' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const port = Number(process.env.PORT || 5000) +const host = process.env.HOST || '127.0.0.1' +const wsTarget = process.env.BITFEED_WS_TARGET || 'umbrel.local:8314' +// ponytail: Umbrel's host proxy redirects unauthenticated REST calls; use public REST for local display edits. +const apiTarget = new URL(process.env.BITFEED_API_TARGET || 'https://bits.monospace.live') +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../public/build') + +const types = { + '.css': 'text/css', + '.html': 'text/html', + '.ico': 'image/x-icon', + '.js': 'text/javascript', + '.json': 'application/json', + '.map': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.webmanifest': 'application/manifest+json', + '.xml': 'application/xml', +} + +function sendFile(res, file) { + res.writeHead(200, { 'content-type': types[path.extname(file)] || 'application/octet-stream' }) + fs.createReadStream(file).pipe(res) +} + +function serveStatic(req, res) { + const pathname = decodeURIComponent(new URL(req.url, `http://${req.headers.host}`).pathname) + const candidate = path.resolve(root, `.${pathname}`) + const file = candidate.startsWith(root) && fs.existsSync(candidate) && fs.statSync(candidate).isFile() + ? candidate + : path.join(root, 'index.html') + sendFile(res, file) +} + +function proxyApi(req, res) { + const upstreamUrl = new URL(req.url, apiTarget) + const client = upstreamUrl.protocol === 'https:' ? https : http + const upstream = client.request(upstreamUrl, { + method: req.method, + headers: { ...req.headers, host: upstreamUrl.host }, + }, upstreamRes => { + const headers = { ...upstreamRes.headers } + delete headers.connection + delete headers['transfer-encoding'] + res.writeHead(upstreamRes.statusCode || 502, headers) + upstreamRes.pipe(res) + }) + upstream.on('error', error => { + res.writeHead(502, { 'content-type': 'text/plain' }) + res.end(error.message) + }) + req.pipe(upstream) +} + +const server = http.createServer((req, res) => { + if (req.url.startsWith('/api/')) proxyApi(req, res) + else serveStatic(req, res) +}) + +server.on('upgrade', (req, socket, head) => { + if (!req.url.startsWith('/ws/')) return socket.destroy() + const upstream = net.connect(Number(wsTarget.split(':')[1]), wsTarget.split(':')[0], () => { + upstream.write(`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`) + for (const [name, value] of Object.entries({ ...req.headers, host: wsTarget })) { + upstream.write(`${name}: ${value}\r\n`) + } + upstream.write('\r\n') + if (head.length) upstream.write(head) + socket.pipe(upstream).pipe(socket) + }) + upstream.on('error', () => socket.destroy()) +}) + +server.listen(port, host, () => { + console.log(`Bitfeed dev server: http://${host}:${port}`) + console.log(` /ws -> ws://${wsTarget}`) + console.log(` /api -> ${apiTarget.origin}`) +}) diff --git a/client/src/components/BlockInfo.svelte b/client/src/components/BlockInfo.svelte index dd8943d..664e6b5 100644 --- a/client/src/components/BlockInfo.svelte +++ b/client/src/components/BlockInfo.svelte @@ -167,6 +167,8 @@ cursor: pointer; pointer-events: all; font-size: 1.2em; + opacity: var(--block-control-opacity, 1); + transition: opacity 250ms; &.standalone { display: none; @@ -251,6 +253,8 @@ bottom: 10%; padding: .75em; pointer-events: all; + opacity: var(--block-control-opacity, 1); + transition: opacity 250ms; &.prev { right: 100% @@ -295,7 +299,8 @@ right: 100%; padding-right: .5rem; - min-width: 0; + min-width: 13rem; + text-align: right; transform: translateX(0); .data-row { @@ -310,7 +315,7 @@ } .data-field { - white-space: wrap; + white-space: nowrap; margin-left: 0; margin-right: 5px; @@ -323,7 +328,6 @@ } } } - .standalone.landscape.close-button { display: block; position: absolute; diff --git a/client/src/components/Sidebar.svelte b/client/src/components/Sidebar.svelte index 64aaf81..ff389b0 100644 --- a/client/src/components/Sidebar.svelte +++ b/client/src/components/Sidebar.svelte @@ -1,4 +1,5 @@ {#key transitionDirection} @@ -351,7 +365,12 @@
{#if block.height == $latestBlockHeight}Latest {/if}Block: { numberFormat.format(block.height) } - + + {#if block.height == $latestBlockHeight} + + {/if} + +
{ formatDateTime(block.time) } @@ -374,7 +393,12 @@
{#if block.height == $latestBlockHeight}Latest {/if}Block: { numberFormat.format(block.height) } - + + {#if block.height == $latestBlockHeight} + + {/if} + +
{ formatDateTime(block.time) } @@ -408,6 +432,11 @@ + {#if block.height == $latestBlockHeight} + + {/if}
{/each} {/key} diff --git a/client/src/components/Settings.svelte b/client/src/components/Settings.svelte index b054714..a248646 100644 --- a/client/src/components/Settings.svelte +++ b/client/src/components/Settings.svelte @@ -53,6 +53,10 @@ let settingConfig = { trueLabel: 'fee rate', valueType: 'bool' }, + showGhostTrails: { + label: 'Ghost Trails', + valueType: 'bool' + }, showSearch: { label: 'Search Bar', valueType: 'bool' diff --git a/client/src/components/TxViz.svelte b/client/src/components/TxViz.svelte index 9720999..c4faed3 100644 --- a/client/src/components/TxViz.svelte +++ b/client/src/components/TxViz.svelte @@ -165,6 +165,10 @@ setBlockOpacity(Date.now() < blockFullOpacityUntil ? 1 : blockDimOpacity, 250) } + function replayBlock () { + if (txController) txController.replayLatestBlock() + } + function focusBlock () { blockHover = true setBlockOpacity(1, 250) @@ -565,7 +569,7 @@
- +
{#if config.dev && config.debug && $devSettings.guides }
diff --git a/client/src/controllers/TxController.js b/client/src/controllers/TxController.js index 35d1771..071ee2b 100644 --- a/client/src/controllers/TxController.js +++ b/client/src/controllers/TxController.js @@ -6,13 +6,14 @@ import BitcoinBlock from '../models/BitcoinBlock.js' import TxSprite from '../models/TxSprite.js' import { FastVertexArray } from '../utils/memory.js' import { searchTx, fetchSpends, addSpends } from '../utils/search.js' -import { overlay, txCount, mempoolCount, mempoolScreenHeight, blockVisible, currentBlock, selectedTx, detailTx, blockAreaSize, highlight, colorMode, blocksEnabled, latestBlockHeight, explorerBlock, blockTransitionDirection, loading, urlPath } from '../stores.js' +import { overlay, txCount, mempoolCount, mempoolScreenHeight, blockVisible, currentBlock, selectedTx, detailTx, blockAreaSize, highlight, colorMode, settings, blocksEnabled, latestBlockHeight, explorerBlock, blockTransitionDirection, loading, urlPath } from '../stores.js' import config from "../config.js" import { tick } from 'svelte'; export default class TxController { constructor ({ width, height }) { this.vertexArray = new FastVertexArray(2048, TxSprite.dataSize, txCount) + this.trailVertexArray = new FastVertexArray(512, TxSprite.dataSize) this.debugVertexArray = new FastVertexArray(1024, TxSprite.dataSize) this.txs = {} this.expiredTxs = {} @@ -33,8 +34,13 @@ export default class TxController { this.lastTxTime = 0 this.txDelay = 0 + this.showGhostTrails = true this.blocksEnabled = true + settings.subscribe(value => { + this.showGhostTrails = value.showGhostTrails !== false + if (!this.showGhostTrails) this.poolScene.clearEntryTrails() + }) blocksEnabled.subscribe(enabled => { this.blocksEnabled = enabled }) @@ -58,7 +64,14 @@ export default class TxController { } getVertexData () { - return this.vertexArray.getVertexData() + const vertexData = this.vertexArray.getVertexData() + if (!this.trailVertexArray.count) return vertexData + + const trailData = this.trailVertexArray.getVertexData() + const combined = new Float32Array(vertexData.length + trailData.length) + combined.set(vertexData) + combined.set(trailData, vertexData.length) + return combined } getDebugVertexData () { @@ -365,6 +378,14 @@ export default class TxController { } } + replayLatestBlock () { + if (this.blockScene && !this.explorerBlockScene) { + blockTransitionDirection.set(null) + blockVisible.set(true) + this.blockScene.replayBuild() + } + } + setBlockOpacity (opacity, duration) { if (this.blockScene) this.blockScene.setOpacity(opacity, duration) if (this.explorerBlockScene) this.explorerBlockScene.setOpacity(opacity, duration) diff --git a/client/src/models/TxBlockScene.js b/client/src/models/TxBlockScene.js index 51ddf2b..2fc812e 100644 --- a/client/src/models/TxBlockScene.js +++ b/client/src/models/TxBlockScene.js @@ -20,6 +20,8 @@ export default class TxBlockScene extends TxMondrianPoolScene { this.inverted = true this.hidden = false this.opacity = 0.21 + this.building = false + this.buildTimers = [] this.sceneType = 'block' } @@ -100,7 +102,7 @@ export default class TxBlockScene extends TxMondrianPoolScene { position: tx.screenPosition, color: { ...tx.getColor('block', this.colorMode).color, - alpha: this.opacity + alpha: this.building ? 1 : this.opacity } }, duration: this.laidOut ? 1000 : 2000, @@ -112,7 +114,7 @@ export default class TxBlockScene extends TxMondrianPoolScene { } } - prepareTxOnScreen (tx, now) { + prepareTxOnScreen (tx, now, replay=false) { const oldRadius = tx.pixelPosition.r this.saveGridToPixelPosition(tx) if (!tx.view.initialised) { @@ -132,6 +134,23 @@ export default class TxBlockScene extends TxMondrianPoolScene { state: 'ready' }) } else { + const replayPosition = replay ? this.replayStartPosition(tx) : null + if (replay && replayPosition) { + tx.view.update({ + display: { + position: replayPosition, + color: { + ...ice(tx.colors[this.colorMode].block.color), + alpha: 1 + } + }, + start: now, + delay: 0, + duration: 750, + smooth: true, + state: 'ready' + }) + } const jitter = (Math.random() * 1500) tx.view.update({ display: { @@ -156,14 +175,26 @@ export default class TxBlockScene extends TxMondrianPoolScene { } } - prepareTx (tx, sequence) { + replayStartPosition (tx) { + const radius = Math.max(1, tx.pixelPosition.r || tx.screenPosition.r || 1) + const minY = radius + 24 + const maxY = Math.max(minY, Math.min(window.innerHeight * 0.32, this.scene.offset.y - radius - 24)) + + return { + x: radius + (Math.random() * Math.max(1, window.innerWidth - (radius * 2))), + y: minY + (Math.random() * Math.max(1, maxY - minY)), + r: radius + } + } + + prepareTx (tx, now, replay=false) { this.place(tx) - this.prepareTxOnScreen(tx) + this.prepareTxOnScreen(tx, now, replay) } setOpacity (opacity, duration=250) { this.opacity = opacity - if (this.hidden) return + if (this.hidden || this.building) return const ids = this.getActiveTxList() for (let i = 0; i < ids.length; i++) { @@ -307,7 +338,7 @@ export default class TxBlockScene extends TxMondrianPoolScene { }) } - prepareAll () { + prepareAll (replay=false) { const now = performance.now() this.resize({}) this.scene.count = 0 @@ -318,7 +349,7 @@ export default class TxBlockScene extends TxMondrianPoolScene { } ids = this.getActiveTxList() for (let i = 0; i < ids.length; i++) { - this.prepareTx(this.txs[ids[i]], now) + this.prepareTx(this.txs[ids[i]], now, replay) } } @@ -329,14 +360,43 @@ export default class TxBlockScene extends TxMondrianPoolScene { // } } - initialLayout (exited) { - this.prepareAll() - setTimeout(() => { + clearBuildTimers () { + this.buildTimers.forEach(timer => clearTimeout(timer)) + this.buildTimers = [] + } + + setBuildTimer (callback, delay) { + const timer = setTimeout(() => { + this.buildTimers = this.buildTimers.filter(item => item !== timer) + callback() + }, delay) + this.buildTimers.push(timer) + } + + build (exited=false, replay=false) { + this.clearBuildTimers() + this.hidden = false + this.laidOut = false + this.building = true + this.prepareAll(replay) + this.setBuildTimer(() => { this.layoutAll() if (exited) this.exitRight() + this.setBuildTimer(() => { + this.building = false + this.setOpacity(this.opacity, 1200) + }, 3900) }, 3000) } + initialLayout (exited) { + this.build(exited, false) + } + + replayBuild () { + this.build(false, true) + } + resetScroll () { return } @@ -362,6 +422,7 @@ export default class TxBlockScene extends TxMondrianPoolScene { } expire (delay=3000) { + this.clearBuildTimers() this.expired = true const txIds = this.getTxList() for (let i = 0; i < txIds.length; i++) { diff --git a/client/src/models/TxPoolScene.js b/client/src/models/TxPoolScene.js index efcb99d..d9aabd0 100644 --- a/client/src/models/TxPoolScene.js +++ b/client/src/models/TxPoolScene.js @@ -1,4 +1,5 @@ import config from '../config.js' +import TxSprite from './TxSprite.js' export default class TxPoolScene { constructor ({ width, height, unit, padding, controller, heightStore, colorMode }) { @@ -208,6 +209,74 @@ export default class TxPoolScene { } } + clearEntryTrail (tx) { + if (!tx || !tx.entryTrail) return + tx.entryTrail.timers.forEach(timer => clearTimeout(timer)) + tx.entryTrail.sprites.forEach(sprite => sprite.destroy()) + tx.entryTrail = null + } + + clearEntryTrails () { + Object.values(this.txs).forEach(tx => this.clearEntryTrail(tx)) + Object.values(this.hiddenTxs).forEach(tx => this.clearEntryTrail(tx)) + } + + createEntryTrail (tx, txColor, entry) { + if (!this.controller.showGhostTrails) return + const vertexArray = this.controller.trailVertexArray + if (!vertexArray || !tx.screenPosition.r) return + + this.clearEntryTrail(tx) + tx.entryTrail = { sprites: [], timers: [] } + + const schedule = (callback, delay) => { + const timer = setTimeout(() => { + if (!tx.entryTrail) return + tx.entryTrail.timers = tx.entryTrail.timers.filter(item => item !== timer) + callback() + }, delay) + tx.entryTrail.timers.push(timer) + } + + for (let i = 0; i < 3; i++) { + const lag = 90 + (i * 105) + const alpha = 0.16 - (i * 0.035) + const radius = Math.max(1, tx.screenPosition.r * (0.9 - (i * 0.12))) + + schedule(() => { + if (!this.txs[tx.id] || !tx.view || !tx.view.initialised) return + const sprite = new TxSprite({ + x: entry.grow.x, + y: entry.grow.y, + r: Math.max(1, entry.grow.r * 0.8), + h: txColor.color.h, + l: txColor.color.l, + alpha + }, vertexArray) + tx.entryTrail.sprites.push(sprite) + sprite.update({ + x: tx.screenPosition.x, + y: tx.screenPosition.y, + r: radius, + duration: entry.duration + (i * 140), + delay: 0, + smooth: true + }) + schedule(() => { + if (!tx.entryTrail || !tx.entryTrail.sprites.includes(sprite)) return + sprite.update({ + alpha: 0, + duration: 420, + delay: 0, + smooth: true + }) + }, Math.max(300, entry.duration * 0.55)) + }, entry.delay + entry.growDuration + lag) + } + + schedule(() => this.clearEntryTrail(tx), entry.delay + entry.growDuration + entry.duration + 1000) + } + setTxOnScreen (tx, insertDelay=0) { this.saveGridToPixelPosition(tx) this.savePixelsToScreenPosition(tx) @@ -226,6 +295,7 @@ export default class TxPoolScene { delay: 0, state: 'ready' }) + this.createEntryTrail(tx, txColor, entry) tx.view.update({ display: { position: entry.grow @@ -339,7 +409,9 @@ export default class TxPoolScene { } remove (id) { - let exists = !!this.txs[id] + const tx = this.txs[id] + let exists = !!tx + this.clearEntryTrail(tx) delete this.txs[id] return exists } diff --git a/client/src/stores.js b/client/src/stores.js index 59b3b53..1417254 100644 --- a/client/src/stores.js +++ b/client/src/stores.js @@ -100,6 +100,7 @@ const defaultSettings = { showFX: true, vbytes: false, colorByFee: true, + showGhostTrails: true, showMessages: false, showSearch: false, noTrack: false, From f62e062c6d8a0ad6156a37427f8b1c6443b8ca36 Mon Sep 17 00:00:00 2001 From: Andy Grabow Date: Tue, 7 Jul 2026 14:25:58 +0200 Subject: [PATCH 03/21] replay button moved to the toolbar on the right --- client/src/components/BlockInfo.svelte | 70 ++++++++------------------ client/src/components/Sidebar.svelte | 16 +++++- client/src/components/TxViz.svelte | 12 ++++- client/src/stores.js | 1 + 4 files changed, 46 insertions(+), 53 deletions(-) diff --git a/client/src/components/BlockInfo.svelte b/client/src/components/BlockInfo.svelte index 9e4cbd7..f86eebb 100644 --- a/client/src/components/BlockInfo.svelte +++ b/client/src/components/BlockInfo.svelte @@ -5,7 +5,6 @@ import { createEventDispatcher } from 'svelte' import Icon from '../components/Icon.svelte' import closeIcon from '../assets/icon/cil-x-circle.svg' - import replayIcon from '../assets/icon/cil-reload.svg' import { shortBtcFormat, longBtcFormat, dateFormat, numberFormat } from '../utils/format.js' import { exchangeRates, settings, blocksEnabled, latestBlockHeight, blockTransitionDirection, loading, freezeResize, pageWidth, pageHeight } from '../stores.js' import { formatCurrency } from '../utils/fx.js' @@ -131,13 +130,6 @@ } } - function replayBlock () { - if (block && block.height == $latestBlockHeight) { - analytics.trackEvent('viz', 'block', 'replay') - dispatch('replayBlock') - } - } - async function explorePrevBlock (e) { e.preventDefault() if (!$loading && block) { @@ -162,8 +154,7 @@ {#key transitionDirection} @@ -365,12 +350,7 @@
{#if block.height == $latestBlockHeight}Latest {/if}Block: { numberFormat.format(block.height) } - - {#if block.height == $latestBlockHeight} - - {/if} - - +
{ formatDateTime(block.time) } @@ -393,12 +373,7 @@
{#if block.height == $latestBlockHeight}Latest {/if}Block: { numberFormat.format(block.height) } - - {#if block.height == $latestBlockHeight} - - {/if} - - +
{ formatDateTime(block.time) } @@ -432,11 +407,6 @@ - {#if block.height == $latestBlockHeight} - - {/if}
{/each} {/key} diff --git a/client/src/components/Sidebar.svelte b/client/src/components/Sidebar.svelte index ff389b0..4f927ce 100644 --- a/client/src/components/Sidebar.svelte +++ b/client/src/components/Sidebar.svelte @@ -19,11 +19,12 @@ import giftIcon from '../assets/icon/cil-gift.svg' import bookmarkIcon from '../assets/icon/cil-bookmark.svg' import fullscreenIcon from '../assets/icon/cil-fullscreen.svg' import fullscreenExitIcon from '../assets/icon/cil-fullscreen-exit.svg' +import replayIcon from '../assets/icon/cil-reload.svg' import MempoolLegend from '../components/MempoolLegend.svelte' import ContactTab from '../components/ContactTab.svelte' import SearchTab from '../components/SearchTab.svelte' -import { sidebarToggle, overlay, currentBlock, blockVisible, haveSupporters, freezeResize } from '../stores.js' +import { sidebarToggle, overlay, currentBlock, latestBlockHeight, blockVisible, replayBlockTrigger, haveSupporters, freezeResize } from '../stores.js' let searchTabComponent let fullscreen = false @@ -31,6 +32,7 @@ let fullscreenTarget = null let blockHidden = false $: blockHidden = ($currentBlock && !$blockVisible) +$: canReplayBlock = ($currentBlock && $currentBlock.height == $latestBlockHeight) onMount(() => { syncFullscreen() @@ -57,6 +59,11 @@ function showBlock () { $blockVisible = true } +function replayBlock () { + analytics.trackEvent('viz', 'block', 'replay') + replayBlockTrigger.increment() +} + function syncFullscreen () { const actualFullscreen = !!document.fullscreenElement if (fullscreenTarget != null && actualFullscreen !== fullscreenTarget) return @@ -174,6 +181,13 @@ async function toggleFullscreen () { + {#if canReplayBlock} + + + + + + {/if} {settings('settings')}} tooltip="Settings"> diff --git a/client/src/components/TxViz.svelte b/client/src/components/TxViz.svelte index c4faed3..6dfeeec 100644 --- a/client/src/components/TxViz.svelte +++ b/client/src/components/TxViz.svelte @@ -6,7 +6,7 @@ import { settings, overlay, serverConnected, serverDelay, txCount, mempoolCount, mempoolScreenHeight, blockVisible, tinyScreen, compactScreen, currentBlock, latestBlockHeight, selectedTx, blockAreaSize, - devEvents, devSettings, pageWidth, pageHeight, loading, freezeResize } from '../stores.js' + replayBlockTrigger, devEvents, devSettings, pageWidth, pageHeight, loading, freezeResize } from '../stores.js' import BlockInfo from '../components/BlockInfo.svelte' import SearchBar from '../components/SearchBar.svelte' import TxInfo from '../components/TxInfo.svelte' @@ -38,6 +38,7 @@ let blockHover = false let blockDisplayOpacity = blockDimOpacity let firstBlockOpacity = true + let lastReplayBlockTrigger = 0 let txStream if (!config.noTxFeed || !config.noBlockFeed) txStream = getTxStream() @@ -56,6 +57,13 @@ } } + $: { + if (txController && $replayBlockTrigger > lastReplayBlockTrigger) { + lastReplayBlockTrigger = $replayBlockTrigger + replayBlock() + } + } + let modeLoaded = false let currentMode $: { @@ -569,7 +577,7 @@
- +
{#if config.dev && config.debug && $devSettings.guides }
diff --git a/client/src/stores.js b/client/src/stores.js index 1417254..bed937d 100644 --- a/client/src/stores.js +++ b/client/src/stores.js @@ -83,6 +83,7 @@ export const devEvents = writable({ export const txCount = createCounter() export const lastBlockId = writable(null) +export const replayBlockTrigger = createCounter() export const mempoolCount = tweened(0) export const mempoolScreenHeight = writable(0) export const blockVisible = writable(false) From faae1b0579d22064e23eff82b209d3e8a9afcf41 Mon Sep 17 00:00:00 2001 From: Andy Grabow Date: Tue, 7 Jul 2026 19:48:57 +0200 Subject: [PATCH 04/21] Fix Umbrel backend mempool and block startup --- .mise.toml | 11 ++ client/package.json | 3 +- client/scripts/umbrel-dev-server.mjs | 22 ++-- client/src/components/TxViz.svelte | 22 +++- client/src/controllers/TxStream.js | 9 +- client/src/utils/format.js | 9 ++ server/lib/block_data.ex | 52 +++++++- server/lib/bridge_block.ex | 3 +- server/lib/bridge_sequence.ex | 6 +- server/lib/bridge_tx.ex | 3 +- server/lib/mempool.ex | 138 ++++++++++++++++------ server/lib/mempool_sync.ex | 9 +- server/lib/socket_handler.ex | 3 +- server/scripts/run-on-umbrel.sh | 53 +++++++++ server/scripts/test-elixir11-container.sh | 24 ++++ server/test/block_data_test.exs | 32 +++++ server/test/mempool_test.exs | 53 +++++++++ 17 files changed, 388 insertions(+), 64 deletions(-) create mode 100644 .mise.toml create mode 100755 server/scripts/run-on-umbrel.sh create mode 100755 server/scripts/test-elixir11-container.sh create mode 100644 server/test/block_data_test.exs create mode 100644 server/test/mempool_test.exs diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..3fc358a --- /dev/null +++ b/.mise.toml @@ -0,0 +1,11 @@ +[tasks."server:compile"] +description = "Compile the server with the Dockerfile toolchain, Elixir 1.11 / OTP 23" +run = "container build --tag bitfeed-server:local server" + +[tasks."server:test"] +description = "Run server tests inside the Elixir 1.11 / OTP 23 container toolchain" +run = "server/scripts/test-elixir11-container.sh" + +[tasks."server:test-mempool"] +description = "Run the mempool regression test inside the Elixir 1.11 / OTP 23 container toolchain" +run = "server/scripts/test-elixir11-container.sh test/mempool_test.exs" diff --git a/client/package.json b/client/package.json index 710e91c..99a9996 100644 --- a/client/package.json +++ b/client/package.json @@ -4,7 +4,8 @@ "scripts": { "build": "rollup -c", "dev": "rollup -c -w", - "start": "sirv public --single" + "start": "sirv public --single", + "start:proxy": "node scripts/umbrel-dev-server.mjs" }, "dependencies": { "@babel/core": "^7.16.5", diff --git a/client/scripts/umbrel-dev-server.mjs b/client/scripts/umbrel-dev-server.mjs index 85c3da4..ab55338 100644 --- a/client/scripts/umbrel-dev-server.mjs +++ b/client/scripts/umbrel-dev-server.mjs @@ -3,13 +3,15 @@ import http from 'node:http' import https from 'node:https' import net from 'node:net' import path from 'node:path' +import tls from 'node:tls' import { fileURLToPath } from 'node:url' -const port = Number(process.env.PORT || 5000) +const port = Number(process.env.PORT || 5001) const host = process.env.HOST || '127.0.0.1' -const wsTarget = process.env.BITFEED_WS_TARGET || 'umbrel.local:8314' -// ponytail: Umbrel's host proxy redirects unauthenticated REST calls; use public REST for local display edits. -const apiTarget = new URL(process.env.BITFEED_API_TARGET || 'https://bits.monospace.live') +const targetUrl = (value, fallbackProtocol) => + new URL(value.includes('://') ? value : `${fallbackProtocol}://${value}`) +const wsTarget = targetUrl(process.env.BITFEED_WS_TARGET || 'wss://bits.monospace.live', 'ws') +const apiTarget = targetUrl(process.env.BITFEED_API_TARGET || 'https://bits.monospace.live', 'https') const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../public/build') const types = { @@ -66,20 +68,24 @@ const server = http.createServer((req, res) => { server.on('upgrade', (req, socket, head) => { if (!req.url.startsWith('/ws/')) return socket.destroy() - const upstream = net.connect(Number(wsTarget.split(':')[1]), wsTarget.split(':')[0], () => { + const upstreamPort = Number(wsTarget.port || (wsTarget.protocol === 'wss:' ? 443 : 80)) + const writeUpgrade = () => { upstream.write(`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`) - for (const [name, value] of Object.entries({ ...req.headers, host: wsTarget })) { + for (const [name, value] of Object.entries({ ...req.headers, host: wsTarget.host })) { upstream.write(`${name}: ${value}\r\n`) } upstream.write('\r\n') if (head.length) upstream.write(head) socket.pipe(upstream).pipe(socket) - }) + } + const upstream = wsTarget.protocol === 'wss:' + ? tls.connect({ host: wsTarget.hostname, port: upstreamPort, servername: wsTarget.hostname }, writeUpgrade) + : net.connect({ host: wsTarget.hostname, port: upstreamPort }, writeUpgrade) upstream.on('error', () => socket.destroy()) }) server.listen(port, host, () => { console.log(`Bitfeed dev server: http://${host}:${port}`) - console.log(` /ws -> ws://${wsTarget}`) + console.log(` /ws -> ${wsTarget.origin}`) console.log(` /api -> ${apiTarget.origin}`) }) diff --git a/client/src/components/TxViz.svelte b/client/src/components/TxViz.svelte index 6dfeeec..b6bdbc5 100644 --- a/client/src/components/TxViz.svelte +++ b/client/src/components/TxViz.svelte @@ -17,7 +17,7 @@ import SupportersOverlay from '../components/SupportersOverlay.svelte' import LoadingAnimation from '../components/util/LoadingAnimation.svelte' import Alerts from '../components/alert/Alerts.svelte' - import { numberFormat } from '../utils/format.js' + import { formatMempoolBlockEstimate, numberFormat } from '../utils/format.js' import { exchangeRates, lastBlockId, haveSupporters, sidebarToggle } from '../stores.js' import { formatCurrency } from '../utils/fx.js' import { fade } from 'svelte/transition' @@ -39,6 +39,12 @@ let blockDisplayOpacity = blockDimOpacity let firstBlockOpacity = true let lastReplayBlockTrigger = 0 + let roundedMempoolCount = 0 + let mempoolVbytes = 0 + let mempoolBlockEstimate = null + + $: roundedMempoolCount = Math.round($mempoolCount) + $: mempoolBlockEstimate = formatMempoolBlockEstimate(mempoolVbytes) let txStream if (!config.noTxFeed || !config.noBlockFeed) txStream = getTxStream() @@ -107,8 +113,14 @@ }) } if (!config.noTxFeed || !config.noBlockFeed) { - txStream.subscribe('mempool_count', count => { - $mempoolCount = count + txStream.subscribe('mempool_count', mempool => { + if (typeof mempool === 'number') { + $mempoolCount = mempool + mempoolVbytes = 0 + } else { + $mempoolCount = mempool.count + mempoolVbytes = mempool.vbytes || 0 + } }) } @@ -566,10 +578,10 @@ {#if $tinyScreen}
Mempool - { numberFormat.format(Math.round($mempoolCount)) } + { numberFormat.format(roundedMempoolCount) }
{:else} - Mempool: { numberFormat.format(Math.round($mempoolCount)) } unconfirmed + Mempool: { numberFormat.format(roundedMempoolCount) } tx{#if mempoolBlockEstimate}{' / '}{mempoolBlockEstimate}{/if} unconfirmed {/if}
diff --git a/client/src/controllers/TxStream.js b/client/src/controllers/TxStream.js index a688075..7207fdf 100644 --- a/client/src/controllers/TxStream.js +++ b/client/src/controllers/TxStream.js @@ -96,6 +96,10 @@ class TxStream { this.websocket.send("block_id") } + sendMempoolCountRequest () { + this.websocket.send("count") + } + disconnect () { console.log('disconnecting websocket') if (this.websocket) { @@ -117,6 +121,7 @@ class TxStream { this.setDelay(0) this.reconnectBackoff = 128 this.sendHeartbeat() + this.sendMempoolCountRequest() this.sendBlockRequest() } @@ -170,8 +175,8 @@ class TxStream { break; } - // all events can include a count field, with the latest mempool size - if (msg.count) window.dispatchEvent(new CustomEvent('bitcoin_mempool_count', { detail: msg.count })) + // all events can include count/vbytes fields, with the latest mempool size + if (msg.count != null) window.dispatchEvent(new CustomEvent('bitcoin_mempool_count', { detail: { count: msg.count, vbytes: msg.vbytes || 0 } })) } catch (err) { console.log('error parsing msg json: ', err) } diff --git a/client/src/utils/format.js b/client/src/utils/format.js index ba710a0..ea8a56e 100644 --- a/client/src/utils/format.js +++ b/client/src/utils/format.js @@ -48,3 +48,12 @@ export const durationFormat = { else return relativeTimeFormat.format(Math.round(seconds / 60), Math.abs(Math.round(seconds / 60)) == 1 ? 'minute' : 'minutes') } } + +const blockVbytes = 1000000 + +export function formatMempoolBlockEstimate (vbytes) { + if (vbytes <= 0) return null + if (vbytes < blockVbytes) return '<1 block' + const blocks = Math.ceil(vbytes / blockVbytes) + return `~${numberFormat.format(blocks)} block${blocks === 1 ? '' : 's'}` +} diff --git a/server/lib/block_data.ex b/server/lib/block_data.ex index b906696..0c9007c 100644 --- a/server/lib/block_data.ex +++ b/server/lib/block_data.ex @@ -12,18 +12,60 @@ defmodule BitcoinStream.BlockData do use GenServer use Task, restart: :transient + alias BitcoinStream.RPC, as: RPC + def start_link(opts) do Logger.info("Starting block data link"); - # load block + GenServer.start_link(__MODULE__, load_state(), opts) + end + + @doc false + def load_state(read_cache \\ &read_cached_block/0, fetch_hash \\ &fetch_tip_hash/0) do + case read_cache.() do + {:ok, json} -> + case state_from_json(json) do + {:ok, state} -> state + _ -> state_from_tip(fetch_hash) + end + + _ -> state_from_tip(fetch_hash) + end + end - with {:ok, json} <- File.read("data/last_block.json"), - {:ok, %{"id" => id}} <- Jason.decode(json) do - GenServer.start_link(__MODULE__, {id, json}, opts) + @doc false + def state_from_json(json) do + with {:ok, block} <- Jason.decode(json), + id when is_binary(id) and id != "" <- block_id(block) do + {:ok, {id, json}} else - _ -> GenServer.start_link(__MODULE__, {nil, "null"}, opts) + _ -> :error end end + defp read_cached_block do + File.read("data/last_block.json") + end + + defp state_from_tip(fetch_hash) do + case fetch_hash.() do + {:ok, hash} -> {hash, "null"} + _ -> {nil, "null"} + end + end + + defp fetch_tip_hash do + case RPC.request(:rpc, "getbestblockhash", []) do + {:ok, 200, hash} when is_binary(hash) -> {:ok, hash} + err -> + Logger.info("Starting without cached block id: #{inspect(err)}") + :error + end + end + + defp block_id(%{"id" => id}), do: id + defp block_id([_, id | _]), do: id + defp block_id(_), do: nil + @impl true def init(state) do {:ok, state} diff --git a/server/lib/bridge_block.ex b/server/lib/bridge_block.ex index 7425214..18b8982 100644 --- a/server/lib/bridge_block.ex +++ b/server/lib/bridge_block.ex @@ -68,7 +68,8 @@ defmodule BitcoinStream.Bridge.Block do end defp send_block(block, count) do - case Jason.encode(%{type: "block", block: %{id: block.id}, drop: count}) do + vbytes = Mempool.get_vbytes(:mempool) + case Jason.encode(%{type: "block", block: %{id: block.id}, drop: count, count: count, vbytes: vbytes}) do {:ok, payload} -> Registry.dispatch(Registry.BitcoinStream, "txs", fn(entries) -> for {pid, _} <- entries do diff --git a/server/lib/bridge_sequence.ex b/server/lib/bridge_sequence.ex index 8e32f89..8ef38bc 100644 --- a/server/lib/bridge_sequence.ex +++ b/server/lib/bridge_sequence.ex @@ -68,7 +68,8 @@ defmodule BitcoinStream.Bridge.Sequence do defp send_txn(txn, count) do # Logger.info("Forwarding transaction to websocket clients") - case Jason.encode(%{type: "txn", txn: txn, count: count}) do + vbytes = Mempool.get_vbytes(:mempool) + case Jason.encode(%{type: "txn", txn: txn, count: count, vbytes: vbytes}) do {:ok, payload} -> Registry.dispatch(Registry.BitcoinStream, "txs", fn(entries) -> for {pid, _} <- entries do @@ -80,7 +81,8 @@ defmodule BitcoinStream.Bridge.Sequence do end defp send_drop_tx(txid, count) do - case Jason.encode(%{type: "drop", txid: txid, count: count}) do + vbytes = Mempool.get_vbytes(:mempool) + case Jason.encode(%{type: "drop", txid: txid, count: count, vbytes: vbytes}) do {:ok, payload} -> Registry.dispatch(Registry.BitcoinStream, "txs", fn(entries) -> for {pid, _} <- entries do diff --git a/server/lib/bridge_tx.ex b/server/lib/bridge_tx.ex index 3843e8b..b6c26a7 100644 --- a/server/lib/bridge_tx.ex +++ b/server/lib/bridge_tx.ex @@ -67,7 +67,8 @@ defmodule BitcoinStream.Bridge.Tx do end defp send_txn(txn, count) do - case Jason.encode(%{type: "txn", txn: txn, count: count}) do + vbytes = Mempool.get_vbytes(:mempool) + case Jason.encode(%{type: "txn", txn: txn, count: count, vbytes: vbytes}) do {:ok, payload} -> Registry.dispatch(Registry.BitcoinStream, "txs", fn(entries) -> for {pid, _} <- entries do diff --git a/server/lib/mempool.ex b/server/lib/mempool.ex index 8faade9..9824693 100644 --- a/server/lib/mempool.ex +++ b/server/lib/mempool.ex @@ -25,7 +25,7 @@ defmodule BitcoinStream.Mempool do """ def start_link(opts) do Logger.info("Starting Mempool Tracker"); - # cache of all transactions in the node mempool, mapped to {inputs, total_input_value} + # cache of all transactions in the node mempool, mapped to {inputs, total_input_value, inflated, vbytes} :ets.new(:mempool_cache, [:set, :public, :named_table]); # cache of transactions ids in the mempool, but not yet synchronized with the :mempool_cache :ets.new(:sync_cache, [:set, :public, :named_table]); @@ -33,8 +33,8 @@ defmodule BitcoinStream.Mempool do # used to avoid allowing confirmed transactions back into the mempool if rawtx events arrive late :ets.new(:block_cache, [:set, :public, :named_table]); - # state: {count, sequence_number, queue, done, blocklock} - GenServer.start_link(__MODULE__, {0, :infinity, [], false, false}, opts) + # state: {count, vbytes, sequence_number, queue, done, blocklock} + GenServer.start_link(__MODULE__, {0, 0, :infinity, [], false, false}, opts) end @impl true @@ -43,78 +43,106 @@ defmodule BitcoinStream.Mempool do end @impl true - def handle_call(:get_count, _from, {count, seq, queue, done, blocklock}) do - {:reply, count, {count, seq, queue, done, blocklock}} + def handle_call(:get_count, _from, {count, vbytes, seq, queue, done, blocklock}) do + {:reply, count, {count, vbytes, seq, queue, done, blocklock}} end @impl true - def handle_call({:set_count, n}, _from, {_count, seq, queue, done, blocklock}) do - {:reply, :ok, {n, seq, queue, done, blocklock}} + def handle_call(:get_vbytes, _from, {count, vbytes, seq, queue, done, blocklock}) do + {:reply, vbytes, {count, vbytes, seq, queue, done, blocklock}} end @impl true - def handle_call(:increment_count, _from, {count, seq, queue, done, blocklock}) do - {:reply, :ok, {count + 1, seq, queue, done, blocklock}} + def handle_call({:set_count, n}, _from, {_count, _vbytes, seq, queue, done, blocklock}) do + {:reply, :ok, {n, 0, seq, queue, done, blocklock}} end @impl true - def handle_call(:decrement_count, _from, {count, seq, queue, done, blocklock}) do - {:reply, :ok, {count - 1, seq, queue, done, blocklock}} + def handle_call({:set_stats, n, vbytes}, _from, {_count, _vbytes, seq, queue, done, blocklock}) do + {:reply, :ok, {n, vbytes, seq, queue, done, blocklock}} end @impl true - def handle_call(:get_seq, _from, {count, seq, queue, done, blocklock}) do - {:reply, seq, {count, seq, queue, done, blocklock}} + def handle_call({:add_vbytes, n}, _from, {count, vbytes, seq, queue, done, blocklock}) do + {:reply, :ok, {count, vbytes + n, seq, queue, done, blocklock}} end @impl true - def handle_call({:set_seq, seq}, _from, {count, _seq, queue, done, blocklock}) do - {:reply, :ok, {count, seq, queue, done, blocklock}} + def handle_call({:subtract_vbytes, n}, _from, {count, vbytes, seq, queue, done, blocklock}) do + {:reply, :ok, {count, max(vbytes - n, 0), seq, queue, done, blocklock}} end @impl true - def handle_call(:get_queue, _from, {count, seq, queue, done, blocklock}) do - {:reply, queue, {count, seq, queue, done, blocklock}} + def handle_call(:increment_count, _from, {count, vbytes, seq, queue, done, blocklock}) do + {:reply, :ok, {count + 1, vbytes, seq, queue, done, blocklock}} end @impl true - def handle_call({:set_queue, queue}, _from, {count, seq, _queue, done, blocklock}) do - {:reply, :ok, {count, seq, queue, done, blocklock}} + def handle_call(:decrement_count, _from, {count, vbytes, seq, queue, done, blocklock}) do + {:reply, :ok, {count - 1, vbytes, seq, queue, done, blocklock}} end @impl true - def handle_call({:enqueue, txid}, _from, {count, seq, queue, done, blocklock}) do - {:reply, :ok, {count, seq, [txid | queue], done, blocklock}} + def handle_call(:get_seq, _from, {count, vbytes, seq, queue, done, blocklock}) do + {:reply, seq, {count, vbytes, seq, queue, done, blocklock}} end @impl true - def handle_call(:is_done, _from, {count, seq, queue, done, blocklock}) do - {:reply, done, {count, seq, queue, done, blocklock}} + def handle_call({:set_seq, seq}, _from, {count, vbytes, _seq, queue, done, blocklock}) do + {:reply, :ok, {count, vbytes, seq, queue, done, blocklock}} end @impl true - def handle_call(:set_done, _from, {count, seq, queue, _done, blocklock}) do - {:reply, :ok, {count, seq, queue, true, blocklock}} + def handle_call(:get_queue, _from, {count, vbytes, seq, queue, done, blocklock}) do + {:reply, queue, {count, vbytes, seq, queue, done, blocklock}} end @impl true - def handle_call(:is_block_locked, _from, {count, seq, queue, done, blocklock}) do - {:reply, blocklock, {count, seq, queue, done, blocklock}} + def handle_call({:set_queue, queue}, _from, {count, vbytes, seq, _queue, done, blocklock}) do + {:reply, :ok, {count, vbytes, seq, queue, done, blocklock}} end @impl true - def handle_call({:set_block_locked, lock}, _from, {count, seq, queue, done, _blocklock}) do - {:reply, :ok, {count, seq, queue, done, lock}} + def handle_call({:enqueue, txid}, _from, {count, vbytes, seq, queue, done, blocklock}) do + {:reply, :ok, {count, vbytes, seq, [txid | queue], done, blocklock}} + end + + @impl true + def handle_call(:is_done, _from, {count, vbytes, seq, queue, done, blocklock}) do + {:reply, done, {count, vbytes, seq, queue, done, blocklock}} + end + + @impl true + def handle_call(:set_done, _from, {count, vbytes, seq, queue, _done, blocklock}) do + {:reply, :ok, {count, vbytes, seq, queue, true, blocklock}} + end + + @impl true + def handle_call(:is_block_locked, _from, {count, vbytes, seq, queue, done, blocklock}) do + {:reply, blocklock, {count, vbytes, seq, queue, done, blocklock}} + end + + @impl true + def handle_call({:set_block_locked, lock}, _from, {count, vbytes, seq, queue, done, _blocklock}) do + {:reply, :ok, {count, vbytes, seq, queue, done, lock}} end def set(pid, n) do GenServer.call(pid, {:set_count, n}) end + def set(pid, n, vbytes) do + GenServer.call(pid, {:set_stats, n, vbytes}) + end + def get(pid) do GenServer.call(pid, :get_count) end + def get_vbytes(pid) do + GenServer.call(pid, :get_vbytes) + end + defp increment(pid) do GenServer.call(pid, :increment_count) end @@ -123,6 +151,20 @@ defmodule BitcoinStream.Mempool do GenServer.call(pid, :decrement_count) end + defp add_vbytes(pid, n) when is_number(n) do + GenServer.call(pid, {:add_vbytes, n}) + end + defp add_vbytes(_pid, _n) do + :ok + end + + defp subtract_vbytes(pid, n) when is_number(n) do + GenServer.call(pid, {:subtract_vbytes, n}) + end + defp subtract_vbytes(_pid, _n) do + :ok + end + defp get_seq(pid) do GenServer.call(pid, :get_seq) end @@ -159,6 +201,17 @@ defmodule BitcoinStream.Mempool do GenServer.call(pid, {:set_block_locked, lock}) end + defp mempool_data(txn) do + {txn.inputs, txn.value + txn.fee, txn.inflated, txn.vbytes || 0} + end + + defp mempool_data_vbytes({_inputs, _value, _inflated, vbytes}) do + vbytes + end + defp mempool_data_vbytes(_data) do + 0 + end + def get_tx_status(_pid, txid) do case :ets.lookup(:mempool_cache, txid) do # new transaction, not yet registered @@ -190,8 +243,10 @@ defmodule BitcoinStream.Mempool do # new transaction, id already registered :registered -> with [] <- :ets.lookup(:block_cache, txid) do # double check tx isn't included in the last block - :ets.insert(:mempool_cache, {txid, { txn.inputs, txn.value + txn.fee, txn.inflated }, nil}); + data = mempool_data(txn); + :ets.insert(:mempool_cache, {txid, data, nil}); cache_spends(txid, txn.inputs); + add_vbytes(pid, mempool_data_vbytes(data)); get(pid) else _ -> @@ -247,12 +302,14 @@ defmodule BitcoinStream.Mempool do # data already received, but tx not registered [{_txid, _, txn}] when txn != nil -> - :ets.insert(:mempool_cache, {txid, { txn.inputs, txn.value + txn.fee, txn.inflated }, nil}); + data = mempool_data(txn); + :ets.insert(:mempool_cache, {txid, data, nil}); :ets.delete(:sync_cache, txid); cache_spends(txid, txn.inputs); if do_count do increment(pid); end + add_vbytes(pid, mempool_data_vbytes(data)); {txn, get(pid)} # some other invalid state (should never happen) @@ -299,9 +356,10 @@ defmodule BitcoinStream.Mempool do # tx fully processed and not already dropped [{txid, data, _status}] when data != nil -> :ets.delete(:mempool_cache, txid); - {inputs, _value, _inflated} = data; + {inputs, _value, _inflated, _vbytes} = data; uncache_spends(inputs); decrement(pid); + subtract_vbytes(pid, mempool_data_vbytes(data)); get(pid) _ -> false @@ -310,7 +368,8 @@ defmodule BitcoinStream.Mempool do defp send_mempool_count(pid) do count = get(pid) - case Jason.encode(%{type: "count", count: count}) do + vbytes = get_vbytes(pid) + case Jason.encode(%{type: "count", count: count, vbytes: vbytes}) do {:ok, payload} -> Registry.dispatch(Registry.BitcoinStream, "txs", fn(entries) -> for {pid, _} <- entries do @@ -360,11 +419,24 @@ defmodule BitcoinStream.Mempool do def do_sync(pid, txns) do Logger.info("Syncing #{length(txns)} mempool transactions"); sync_mempool(pid, txns); + sync_stats(pid); Logger.info("MEMPOOL SYNC FINISHED"); set_done(pid); + send_mempool_count(pid); :ok end + def sync_stats(pid) do + with {:ok, 200, %{"size" => count, "bytes" => vbytes}} when is_integer(count) and is_integer(vbytes) <- RPC.request(:rpc, "getmempoolinfo", []) do + set(pid, count, vbytes) + else + err -> + Logger.error("mempool stats sync failed"); + Logger.error("#{inspect(err)}"); + :error + end + end + defp sync_mempool(pid, txns) do sync_mempool_txns(pid, txns) end diff --git a/server/lib/mempool_sync.ex b/server/lib/mempool_sync.ex index 57d0e69..6659cde 100644 --- a/server/lib/mempool_sync.ex +++ b/server/lib/mempool_sync.ex @@ -70,15 +70,14 @@ defmodule BitcoinStream.Mempool.Sync do Process.send_after(self(), :resync, 5 * 1000) false -> - with {:ok, 200, %{"size" => size}} when is_integer(size) <- RPC.request(:rpc, "getmempoolinfo", []), + with {:ok, 200, %{"size" => size, "bytes" => bytes}} when is_integer(size) and is_integer(bytes) <- RPC.request(:rpc, "getmempoolinfo", []), count when is_integer(count) <- Mempool.get(:mempool) do Logger.debug("Mempool health check - Core count: #{size} | Bitfeed count: #{count}"); - # if we've diverged from the true count by more than 50 txs, then fix - # ensures our count doesn't stray too far due to missed events & unexpected errors. - if (abs(size - count) > 50) do + # If we've diverged from Core, repair both count and total virtual size. + if (abs(size - count) > 50 or abs(bytes - Mempool.get_vbytes(:mempool)) > 50_000) do Logger.debug("resync"); - Mempool.set(:mempool, size); + Mempool.set(:mempool, size, bytes); newcount = Mempool.get(:mempool); Logger.debug("updated to #{newcount}"); end diff --git a/server/lib/socket_handler.ex b/server/lib/socket_handler.ex index 9d71ef5..a3414c8 100644 --- a/server/lib/socket_handler.ex +++ b/server/lib/socket_handler.ex @@ -35,7 +35,8 @@ defmodule BitcoinStream.SocketHandler do def get_mempool_count_msg() do count = Mempool.get(:mempool); - "{ \"type\": \"count\", \"count\": #{count}}" + vbytes = Mempool.get_vbytes(:mempool); + "{ \"type\": \"count\", \"count\": #{count}, \"vbytes\": #{vbytes}}" end def get_block_id_msg() do diff --git a/server/scripts/run-on-umbrel.sh b/server/scripts/run-on-umbrel.sh new file mode 100755 index 0000000..32e78e1 --- /dev/null +++ b/server/scripts/run-on-umbrel.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +source_tar="${1:-/home/umbrel/bitfeed-server-src.tar.gz}" +build_dir="${BITFEED_BUILD_DIR:-/home/umbrel/bitfeed-server-build}" +bitcoin_container="${BITCOIN_CONTAINER:-bitcoin_app_1}" +name="${BITFEED_CONTAINER:-bitfeed-server}" +port="${BITFEED_PORT:-5001}" + +sudo rm -rf "$build_dir" +mkdir -p "$build_dir" +tar -xzf "$source_tar" -C "$build_dir" +sudo docker build -t bitfeed-server:umbrel "$build_dir" + +network="$( + sudo docker inspect "$bitcoin_container" \ + --format '{{range $name, $_ := .NetworkSettings.Networks}}{{$name}}{{"\n"}}{{end}}' \ + | head -n 1 +)" + +cookie="$( + sudo docker exec "$bitcoin_container" sh -lc ' + for path in /data/.bitcoin/.cookie /bitcoin/.bitcoin/.cookie /home/bitcoin/.bitcoin/.cookie /data/bitcoin/.cookie; do + [ -f "$path" ] && printf "%s\n" "$path" && exit 0 + done + find / -name .cookie 2>/dev/null | head -n 1 + ' +)" + +if [ -z "$network" ] || [ -z "$cookie" ]; then + echo "Could not find bitcoin docker network or RPC cookie path" >&2 + exit 1 +fi + +sudo docker rm -f "$name" >/dev/null 2>&1 || true + +sudo docker run -d \ + --name "$name" \ + --restart unless-stopped \ + --network "$network" \ + --volumes-from "$bitcoin_container":ro \ + -p "$port:$port" \ + -e PORT="$port" \ + -e BITCOIN_HOST="$bitcoin_container" \ + -e BITCOIN_RPC_PORT=8332 \ + -e BITCOIN_RPC_COOKIE="$cookie" \ + -e BITCOIN_ZMQ_RAWBLOCK_PORT=28332 \ + -e BITCOIN_ZMQ_RAWTX_PORT=28333 \ + -e BITCOIN_ZMQ_SEQUENCE_PORT=28335 \ + -e LOG_LEVEL=info \ + bitfeed-server:umbrel + +sudo docker logs --tail 80 "$name" diff --git a/server/scripts/test-elixir11-container.sh b/server/scripts/test-elixir11-container.sh new file mode 100755 index 0000000..f0ef2f9 --- /dev/null +++ b/server/scripts/test-elixir11-container.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +name="bitfeed-server-test-$$" + +cleanup() { + container stop "$name" >/dev/null 2>&1 || true + container delete "$name" >/dev/null 2>&1 || true +} + +trap cleanup EXIT INT TERM + +container create --name "$name" --workdir /app elixir:1.11-slim sleep 600 >/dev/null +container start "$name" >/dev/null +container cp "$root/server" "$name:/app" + +container exec "$name" sh -lc ' + mix local.hex --force && + mix local.rebar --force && + cd /app/server && + mix deps.get && + mix test "$@" +' sh "$@" diff --git a/server/test/block_data_test.exs b/server/test/block_data_test.exs new file mode 100644 index 0000000..718a824 --- /dev/null +++ b/server/test/block_data_test.exs @@ -0,0 +1,32 @@ +defmodule BitcoinStream.BlockDataTest do + use ExUnit.Case + + alias BitcoinStream.BlockData + + test "loads cached block id from map json" do + json = ~s({"id":"abc"}) + + assert BlockData.state_from_json(json) == {:ok, {"abc", json}} + end + + test "loads cached block id from compact block json" do + json = ~s([1,"abc",957000]) + + assert BlockData.state_from_json(json) == {:ok, {"abc", json}} + end + + test "falls back to tip hash when cache is missing" do + assert BlockData.load_state(fn -> {:error, :enoent} end, fn -> {:ok, "tip"} end) == + {"tip", "null"} + end + + test "falls back to tip hash when cache has no usable id" do + assert BlockData.load_state(fn -> {:ok, ~s({"id":""})} end, fn -> {:ok, "tip"} end) == + {"tip", "null"} + end + + test "starts without block id when cache and tip lookup fail" do + assert BlockData.load_state(fn -> {:error, :enoent} end, fn -> :error end) == + {nil, "null"} + end +end diff --git a/server/test/mempool_test.exs b/server/test/mempool_test.exs new file mode 100644 index 0000000..9a0b517 --- /dev/null +++ b/server/test/mempool_test.exs @@ -0,0 +1,53 @@ +defmodule BitcoinStream.MempoolTest do + use ExUnit.Case + + alias BitcoinStream.Mempool + + setup do + for table <- [:mempool_cache, :sync_cache, :block_cache], :ets.whereis(table) != :undefined do + :ets.delete(table) + end + + {:ok, pid} = Mempool.start_link([]) + {:ok, pid: pid} + end + + test "tracks vbytes when raw tx arrives after the sequence event", %{pid: pid} do + Mempool.set(pid, 1) + :ets.insert(:mempool_cache, {"tx1", nil, :ready}) + + assert Mempool.get_vbytes(pid) == 0 + assert Mempool.insert(pid, "tx1", tx("tx1", 250)) == 1 + assert Mempool.get_vbytes(pid) == 250 + assert Mempool.drop(pid, "tx1") == 0 + assert Mempool.get_vbytes(pid) == 0 + end + + test "sets count and vbytes from node mempool stats", %{pid: pid} do + assert Mempool.set(pid, 55_703, 31_000_000) == :ok + assert Mempool.get(pid) == 55_703 + assert Mempool.get_vbytes(pid) == 31_000_000 + end + + test "tracks vbytes when raw tx arrives before the sequence event", %{pid: pid} do + mark_synced(pid) + + assert Mempool.insert(pid, "tx1", tx("tx1", 400)) == false + assert Mempool.get_vbytes(pid) == 0 + + {txn, count} = Mempool.register(pid, "tx1", 1, true) + assert txn.id == "tx1" + assert count == 1 + assert Mempool.get_vbytes(pid) == 400 + end + + defp mark_synced(pid) do + :sys.replace_state(pid, fn {count, vbytes, _seq, queue, done, blocklock} -> + {count, vbytes, 0, queue, done, blocklock} + end) + end + + defp tx(id, vbytes) do + %{id: id, inputs: [], value: 1000, fee: 10, inflated: true, vbytes: vbytes} + end +end From cd8121fb7beda57e45aec4d081b2fe7a59d7fdc3 Mon Sep 17 00:00:00 2001 From: Andy Grabow Date: Tue, 7 Jul 2026 19:59:48 +0200 Subject: [PATCH 05/21] Keep displayed blocks visible for 21 seconds --- client/src/components/TxViz.svelte | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/src/components/TxViz.svelte b/client/src/components/TxViz.svelte index b6bdbc5..65e20d7 100644 --- a/client/src/components/TxViz.svelte +++ b/client/src/components/TxViz.svelte @@ -37,7 +37,6 @@ let blockFullOpacityUntil = 0 let blockHover = false let blockDisplayOpacity = blockDimOpacity - let firstBlockOpacity = true let lastReplayBlockTrigger = 0 let roundedMempoolCount = 0 let mempoolVbytes = 0 @@ -163,8 +162,7 @@ function showNewBlockAtFullOpacity (block) { if (blockOpacityTimeout) clearTimeout(blockOpacityTimeout) blockOpacityBlockId = block.id - blockFullOpacityUntil = firstBlockOpacity ? Date.now() + blockFullOpacityMs : block.time + blockFullOpacityMs - firstBlockOpacity = false + blockFullOpacityUntil = Date.now() + blockFullOpacityMs if (!blockHover) setBlockOpacity(Date.now() < blockFullOpacityUntil ? 1 : blockDimOpacity, 250) if (Date.now() < blockFullOpacityUntil) { From afab36346d4d825b29012a3f6c1fca69fe04b372 Mon Sep 17 00:00:00 2001 From: Andy Grabow Date: Tue, 7 Jul 2026 20:08:45 +0200 Subject: [PATCH 06/21] Add ambient fullscreen mode --- client/src/components/BlockInfo.svelte | 12 ++++- client/src/components/Sidebar.svelte | 63 +++++++++++++++++++++++++- client/src/components/TxViz.svelte | 23 +++++++++- client/src/stores.js | 1 + 4 files changed, 93 insertions(+), 6 deletions(-) diff --git a/client/src/components/BlockInfo.svelte b/client/src/components/BlockInfo.svelte index f86eebb..84ab86e 100644 --- a/client/src/components/BlockInfo.svelte +++ b/client/src/components/BlockInfo.svelte @@ -6,7 +6,7 @@ import Icon from '../components/Icon.svelte' import closeIcon from '../assets/icon/cil-x-circle.svg' import { shortBtcFormat, longBtcFormat, dateFormat, numberFormat } from '../utils/format.js' - import { exchangeRates, settings, blocksEnabled, latestBlockHeight, blockTransitionDirection, loading, freezeResize, pageWidth, pageHeight } from '../stores.js' + import { exchangeRates, settings, blocksEnabled, latestBlockHeight, blockTransitionDirection, loading, freezeResize, fullscreenActive, pageWidth, pageHeight } from '../stores.js' import { formatCurrency } from '../utils/fx.js' import { searchBlockHeight } from '../utils/search.js' @@ -340,11 +340,19 @@ bottom: unset; margin-top: 0; } + + .block-info-container.ambient-mode { + .close-button, + .explore-button { + opacity: 0; + pointer-events: none; + } + } {#key transitionDirection} {#each ((block != null && visible && $blocksEnabled) ? [block] : []) as block (block.id)} -
+
diff --git a/client/src/components/Sidebar.svelte b/client/src/components/Sidebar.svelte index 4f927ce..5e1ce1d 100644 --- a/client/src/components/Sidebar.svelte +++ b/client/src/components/Sidebar.svelte @@ -24,11 +24,13 @@ import MempoolLegend from '../components/MempoolLegend.svelte' import ContactTab from '../components/ContactTab.svelte' import SearchTab from '../components/SearchTab.svelte' -import { sidebarToggle, overlay, currentBlock, latestBlockHeight, blockVisible, replayBlockTrigger, haveSupporters, freezeResize } from '../stores.js' +import { sidebarToggle, overlay, currentBlock, latestBlockHeight, blockVisible, replayBlockTrigger, haveSupporters, freezeResize, fullscreenActive } from '../stores.js' let searchTabComponent let fullscreen = false let fullscreenTarget = null +let showFullscreenExit = false +let fullscreenExitTimer let blockHidden = false $: blockHidden = ($currentBlock && !$blockVisible) @@ -70,6 +72,9 @@ function syncFullscreen () { fullscreenTarget = null fullscreen = actualFullscreen + fullscreenActive.set(actualFullscreen) + if (actualFullscreen) revealFullscreenExit() + else hideFullscreenExit() } async function toggleFullscreen () { @@ -91,6 +96,19 @@ async function toggleFullscreen () { console.warn('fullscreen unavailable', e) } } + +function revealFullscreenExit () { + if (!fullscreen) return + showFullscreenExit = true + if (fullscreenExitTimer) clearTimeout(fullscreenExitTimer) + fullscreenExitTimer = setTimeout(hideFullscreenExit, 2400) +} + +function hideFullscreenExit () { + showFullscreenExit = false + if (fullscreenExitTimer) clearTimeout(fullscreenExitTimer) + fullscreenExitTimer = null +} - + +{#if $fullscreenActive} + +{/if} diff --git a/client/src/components/TxViz.svelte b/client/src/components/TxViz.svelte index 65e20d7..837885c 100644 --- a/client/src/components/TxViz.svelte +++ b/client/src/components/TxViz.svelte @@ -6,7 +6,7 @@ import { settings, overlay, serverConnected, serverDelay, txCount, mempoolCount, mempoolScreenHeight, blockVisible, tinyScreen, compactScreen, currentBlock, latestBlockHeight, selectedTx, blockAreaSize, - replayBlockTrigger, devEvents, devSettings, pageWidth, pageHeight, loading, freezeResize } from '../stores.js' + replayBlockTrigger, devEvents, devSettings, pageWidth, pageHeight, loading, freezeResize, fullscreenActive } from '../stores.js' import BlockInfo from '../components/BlockInfo.svelte' import SearchBar from '../components/SearchBar.svelte' import TxInfo from '../components/TxInfo.svelte' @@ -562,12 +562,31 @@ width: 18em; } } + + .tx-area.ambient-mode { + .top-bar { + opacity: 0; + pointer-events: none; + } + + .mempool-height { + .height-bar { + opacity: 0.35; + } + + .mempool-count, + .mempool-info { + font-size: 1rem; + text-shadow: 0 0 8px var(--palette-y); + } + } + } -
+
diff --git a/client/src/stores.js b/client/src/stores.js index bed937d..071eda4 100644 --- a/client/src/stores.js +++ b/client/src/stores.js @@ -144,6 +144,7 @@ export const highlightingFull = writable(false) export const pageWidth = writable(window.innerWidth) export const pageHeight = writable(window.innerHeight) export const freezeResize = writable(false) +export const fullscreenActive = writable(false) let lastTinyScreen export const tinyScreen = derived([pageWidth, pageHeight, freezeResize], ([$pageWidth, $pageHeight, $freezeResize]) => { From 60a346a4c22eabd90b24d8881b5db1e48b4baf3b Mon Sep 17 00:00:00 2001 From: Andy Grabow Date: Tue, 7 Jul 2026 20:17:16 +0200 Subject: [PATCH 07/21] Keep bitcoin price visible in fullscreen --- client/src/components/TxViz.svelte | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/client/src/components/TxViz.svelte b/client/src/components/TxViz.svelte index 837885c..34de401 100644 --- a/client/src/components/TxViz.svelte +++ b/client/src/components/TxViz.svelte @@ -565,8 +565,18 @@ .tx-area.ambient-mode { .top-bar { - opacity: 0; pointer-events: none; + + .search-bar-wrapper, + .alert-bar-wrapper, + .block-height, + .status .row:not(:first-child) { + opacity: 0; + } + + .fx-ticker { + text-shadow: 0 0 8px var(--palette-y); + } } .mempool-height { From 51f58762c93cdc1965f1bae2b70115be869a1ddc Mon Sep 17 00:00:00 2001 From: Andy Grabow Date: Tue, 7 Jul 2026 21:07:32 +0200 Subject: [PATCH 08/21] Add toggleable bitcoin price chart --- .../components/PriceChartBackground.svelte | 114 ++++++++++++++++++ client/src/components/Settings.svelte | 16 ++- client/src/components/SidebarMenuItem.svelte | 1 + client/src/components/TxViz.svelte | 44 ++++++- client/src/stores.js | 1 + 5 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 client/src/components/PriceChartBackground.svelte diff --git a/client/src/components/PriceChartBackground.svelte b/client/src/components/PriceChartBackground.svelte new file mode 100644 index 0000000..695bbeb --- /dev/null +++ b/client/src/components/PriceChartBackground.svelte @@ -0,0 +1,114 @@ + + + + +{#if mode !== 'none' && path} + +{/if} diff --git a/client/src/components/Settings.svelte b/client/src/components/Settings.svelte index a248646..ed9cccc 100644 --- a/client/src/components/Settings.svelte +++ b/client/src/components/Settings.svelte @@ -12,8 +12,8 @@ function toggle(setting) { } function onChange(setting, value) { - $settings[setting] = value - analytics.trackEvent('settings', setting, $settings[setting]) + settings.set({ ...$settings, [setting]: value }) + analytics.trackEvent('settings', setting, value) } const currencyOptions = Object.keys(currencies).map(code => { @@ -24,6 +24,12 @@ const currencyOptions = Object.keys(currencies).map(code => { } }) +const priceChartOptions = [ + { value: 'none', label: 'Off', tags: ['none', 'off'] }, + { value: '1d', label: '1D', tags: ['day', '24h'] }, + { value: '30d', label: '30D', tags: ['month', '30 days'] } +] + let settingConfig = { showNetworkStatus: { label: 'Network Status', @@ -60,6 +66,12 @@ let settingConfig = { showSearch: { label: 'Search Bar', valueType: 'bool' + }, + priceChartMode: { + label: 'Price Chart', + type: 'dropdown', + valueType: 'string', + options: priceChartOptions } } $: { diff --git a/client/src/components/SidebarMenuItem.svelte b/client/src/components/SidebarMenuItem.svelte index 6fc8177..05d334c 100644 --- a/client/src/components/SidebarMenuItem.svelte +++ b/client/src/components/SidebarMenuItem.svelte @@ -87,6 +87,7 @@ async function focusOut(e) { { label } {:else if type === 'dropdown'} + { label }