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..85ed3e0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,21 @@
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
+
+# bv (beads viewer) local config and caches
+.bv/
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-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/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
new file mode 100644
index 0000000..a1f1840
--- /dev/null
+++ b/client/scripts/umbrel-dev-server.mjs
@@ -0,0 +1,94 @@
+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 tls from 'node:tls'
+import { fileURLToPath } from 'node:url'
+
+const port = Number(process.env.PORT || 5001)
+const host = process.env.HOST || '127.0.0.1'
+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 = {
+ '.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',
+ 'cache-control': 'no-store',
+ })
+ 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 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.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 -> ${wsTarget.origin}`)
+ console.log(` /api -> ${apiTarget.origin}`)
+})
diff --git a/client/src/components/BlockInfo.svelte b/client/src/components/BlockInfo.svelte
index dd8943d..634b01a 100644
--- a/client/src/components/BlockInfo.svelte
+++ b/client/src/components/BlockInfo.svelte
@@ -5,10 +5,10 @@
import { createEventDispatcher } from 'svelte'
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 { shortBtcFormat, longBtcFormat, dateFormat, numberFormat, feeRateFormat } from '../utils/format.js'
+ import { exchangeRates, settings, blocksEnabled, latestBlockHeight, blockTransitionDirection, loading, freezeResize, fullscreenActive, pageWidth, pageHeight, overlay, explorerBlock, urlPath } from '../stores.js'
import { formatCurrency } from '../utils/fx.js'
- import { searchBlockHeight } from '../utils/search.js'
+ import { fetchBlockByHeight } from '../utils/search.js'
const dispatch = createEventDispatcher()
@@ -17,8 +17,15 @@
export let block
export let visible
const newBlockDelay = 2000
+ const blockNavigationAnimationMs = 2700 // 200ms delay + 2000ms duration + 500ms jitter
+ const blockNavigationQueueDelayMs = 500
let restoring = false
let formattedBlockValue = ''
+ let navigationQueue = []
+ let navigationRunning = false
+ let activeNavigationHeight = null
+ let navigationAnimationUntil = 0
+ let escapePending = false
let compactView
let landscape
@@ -117,7 +124,7 @@
function formatFee (n) {
if (n) {
- return numberFormat.format(n.toFixed(2))
+ return feeRateFormat.format(n)
} else return '0'
}
@@ -130,25 +137,113 @@
}
}
- async function explorePrevBlock (e) {
- e.preventDefault()
- if (!$loading && block) {
- loading.increment()
- await searchBlockHeight(block.height - 1)
+ async function loadNavigationBlock (height) {
+ loading.increment()
+ try {
+ return await fetchBlockByHeight(height)
+ } catch (error) {
+ console.log('error fetching block ', error)
+ return null
+ } finally {
loading.decrement()
}
}
- async function exploreNextBlock (e) {
- e.preventDefault()
- if (!$loading && block) {
- if (block.height + 1 < $latestBlockHeight) {
- loading.increment()
- await searchBlockHeight(block.height + 1)
- loading.decrement()
- } else {
- dispatch('quitExploring')
+ function queueBlockNavigation (direction) {
+ if (!block || escapePending) return
+
+ const lastQueued = navigationQueue[navigationQueue.length - 1]
+ const fromHeight = lastQueued ? lastQueued.height : (activeNavigationHeight != null ? activeNavigationHeight : block.height)
+ const height = fromHeight + (direction === 'prev' ? -1 : 1)
+ if (height < 0 || height > $latestBlockHeight) return
+
+ navigationQueue.push({
+ height,
+ blockPromise: height === $latestBlockHeight ? null : loadNavigationBlock(height)
+ })
+ runNavigationQueue()
+ }
+
+ function queueEscape () {
+ navigationQueue = []
+ escapePending = true
+ runNavigationQueue()
+ }
+
+ async function runNavigationQueue () {
+ if (navigationRunning) return
+ navigationRunning = true
+
+ try {
+ while (navigationQueue.length || escapePending) {
+ if (escapePending) {
+ const remainingAnimationMs = navigationAnimationUntil - Date.now()
+ if (remainingAnimationMs > 0) await new Promise(resolve => setTimeout(resolve, remainingAnimationMs))
+ escapePending = false
+ navigationAnimationUntil = 0
+ hideBlock()
+ break
+ }
+
+ const navigation = navigationQueue.shift()
+ activeNavigationHeight = navigation.height
+ const isLatest = navigation.height === $latestBlockHeight
+ const nextBlock = isLatest ? null : await (navigation.blockPromise || loadNavigationBlock(navigation.height))
+
+ if (escapePending) {
+ activeNavigationHeight = null
+ continue
+ }
+
+ if (isLatest) {
+ dispatch('quitExploring')
+ } else if (nextBlock && nextBlock.id) {
+ urlPath.set(`/block/height/${navigation.height}`)
+ overlay.set(null)
+ explorerBlock.set(nextBlock)
+ } else {
+ activeNavigationHeight = null
+ continue
+ }
+
+ navigationAnimationUntil = Date.now() + blockNavigationAnimationMs
+ await new Promise(resolve => setTimeout(resolve, blockNavigationQueueDelayMs))
+ activeNavigationHeight = null
}
+ } finally {
+ activeNavigationHeight = null
+ navigationRunning = false
+ if (navigationQueue.length || escapePending) runNavigationQueue()
+ }
+ }
+
+ function explorePrevBlock (e) {
+ e.preventDefault()
+ queueBlockNavigation('prev')
+ }
+
+ function exploreNextBlock (e) {
+ e.preventDefault()
+ queueBlockNavigation('next')
+ }
+
+ function isEditableTarget (target) {
+ if (!target) return false
+ const tag = target.tagName
+ return target.isContentEditable || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
+ }
+
+ function handleKeydown (e) {
+ if (!visible || !block || !$blocksEnabled || $overlay || e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || isEditableTarget(e.target)) return
+ if (e.key === 'Escape') {
+ e.preventDefault()
+ queueEscape()
+ } else if (e.key === 'ArrowLeft' && hasPrevBlock) {
+ e.preventDefault()
+ queueBlockNavigation('prev')
+ } else if (e.key === 'ArrowRight' && hasNextBlock) {
+ e.preventDefault()
+ queueBlockNavigation('next')
}
}
@@ -167,6 +262,8 @@
cursor: pointer;
pointer-events: all;
font-size: 1.2em;
+ opacity: var(--block-control-opacity, 1);
+ transition: opacity 250ms, top 1000ms, bottom 1000ms, left 1000ms, right 1000ms, margin 1000ms, transform 1000ms;
&.standalone {
display: none;
@@ -183,7 +280,7 @@
.block-info {
position: absolute;
- bottom: calc(100% + 0.25rem);
+ bottom: calc(100% + 1.15rem);
left: 50%;
min-width: 100%;
transform: translateX(-50%);
@@ -191,6 +288,7 @@
color: var(--palette-x);
font-size: 1rem;
+ transition: top 1000ms, bottom 1000ms, left 1000ms, right 1000ms, transform 1000ms;
@media (max-width: 360px) {
font-size: 4.4vw;
@@ -234,7 +332,7 @@
margin: 0;
padding: 0;
cursor: pointer;
- margin-top: -5px;
+ transform: translateY(-.7rem);
}
&:first-child {
@@ -249,14 +347,16 @@
.explore-button {
position: absolute;
bottom: 10%;
- padding: .75em;
+ padding: .5em;
pointer-events: all;
+ opacity: var(--block-control-opacity, 1);
+ transition: opacity 250ms, bottom 1000ms, left 1000ms, right 1000ms;
&.prev {
- right: 100%
+ right: calc(100% + .7rem);
}
&.next {
- left: 100%;
+ left: calc(100% + .7rem);
}
.chevron {
@@ -291,11 +391,12 @@
.block-info.landscape {
bottom: unset;
left: unset;
- top: 0;
- right: 100%;
- padding-right: .5rem;
+ top: -4px;
+ right: calc(100% + 1.2rem);
+ padding-right: 0;
- min-width: 0;
+ min-width: 13rem;
+ text-align: right;
transform: translateX(0);
.data-row {
@@ -310,7 +411,7 @@
}
.data-field {
- white-space: wrap;
+ white-space: nowrap;
margin-left: 0;
margin-right: 5px;
@@ -323,31 +424,40 @@
}
}
}
-
.standalone.landscape.close-button {
display: block;
position: absolute;
- bottom: 100%;
- left: 100%;
- margin: 5px;
+ top: -2px;
+ bottom: unset;
+ left: calc(100% + 1.15rem);
+ margin: 0;
}
-
.standalone.tinyscreen.close-button {
top: 0;
bottom: unset;
margin-top: 0;
}
+
+ .block-info-container.ambient-mode {
+ .close-button,
+ .explore-button {
+ opacity: 0;
+ pointer-events: none;
+ }
+ }
+
TxID: { tx.id }
@@ -164,7 +139,7 @@ function highlight () { {/if}Size: { numberFormat.format(tx.vbytes) } vbytes
{#if !tx.coinbase && tx.fee != null } -Fee rate: { numberFormat.format(tx.feerate.toFixed(2)) } sats/vbyte
+Fee rate: { feeRateFormat.format(tx.feerate) } sats/vbyte
Fee: { numberFormat.format(tx.fee) } sats
{:else if !tx.coinbase && tx.fee == null}Fee rate: unavailable
diff --git a/client/src/components/TxViz.svelte b/client/src/components/TxViz.svelte index 6eabf4a..87191dd 100644 --- a/client/src/components/TxViz.svelte +++ b/client/src/components/TxViz.svelte @@ -4,25 +4,27 @@ import TxRender from './TxRender.svelte' import getTxStream from '../controllers/TxStream.js' import { settings, overlay, serverConnected, serverDelay, txCount, mempoolCount, - mempoolScreenHeight, blockVisible, tinyScreen, + mempoolScreenHeight, mempoolScreenLeft, blockVisible, tinyScreen, compactScreen, currentBlock, latestBlockHeight, selectedTx, blockAreaSize, - 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' import Sidebar from '../components/Sidebar.svelte' import TransactionOverlay from '../components/TransactionOverlay.svelte' import AboutOverlay from '../components/AboutOverlay.svelte' + import PriceChartBackground from '../components/PriceChartBackground.svelte' import DonationOverlay from '../components/DonationOverlay.svelte' 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 { exchangeRates, lastBlockId, haveSupporters, sidebarToggle } from '../stores.js' + import { formatMempoolBlockEstimate, numberFormat } from '../utils/format.js' + import { exchangeRates, lastBlockId, haveSupporters, priceChartChange } from '../stores.js' import { formatCurrency } from '../utils/fx.js' import { fade } from 'svelte/transition' import config from '../config.js' + const clickToDrop = new URLSearchParams(window.location.search).has('dropOnClick') let width = window.innerWidth - 20 let height = window.innerHeight - 20 let txController @@ -30,6 +32,31 @@ let running = false let lastFrameUpdate = 0 + const blockFullOpacityMs = 21000 + const blockFreshStartDelayMs = 6900 + const blockReturnFreshStartDelayMs = 2000 + const blockFreshDurationMs = (blockFullOpacityMs - blockFreshStartDelayMs) / 3 + const blockDimOpacity = 0.21 + let blockOpacityTimeout + let blockFreshTimeout + let blockFreshEndTimeout + let blockOpacityBlockId + let blockFullOpacityUntil = 0 + let blockFresh = false + let rehideBlockAfterAuraId = null + let nextBlockFreshStartDelayMs = blockFreshStartDelayMs + let blockHover = false + let blockDisplayOpacity = blockDimOpacity + let lastReplayBlockTrigger = 0 + let roundedMempoolCount = 0 + let mempoolVbytes = 0 + let mempoolBlockEstimate = null + let mempoolReady = config.noTxFeed + let startupLoading = true + + $: roundedMempoolCount = Math.round($mempoolCount) + $: mempoolBlockEstimate = formatMempoolBlockEstimate(mempoolVbytes) + $: if (mempoolReady && (config.noBlockFeed || $currentBlock)) startupLoading = false let txStream if (!config.noTxFeed || !config.noBlockFeed) txStream = getTxStream() @@ -42,6 +69,20 @@ } } + $: { + if (txController && $currentBlock && $currentBlock.id !== blockOpacityBlockId) { + showNewBlockAtFullOpacity($currentBlock, nextBlockFreshStartDelayMs) + nextBlockFreshStartDelayMs = blockFreshStartDelayMs + } + } + + $: { + if (txController && $replayBlockTrigger > lastReplayBlockTrigger) { + lastReplayBlockTrigger = $replayBlockTrigger + replayBlock() + } + } + let modeLoaded = false let currentMode $: { @@ -79,14 +120,25 @@ if (!config.noBlockFeed) { txStream.subscribe('block', ({block, realtime}) => { if (block) { + const rehideAfterAura = $currentBlock && !$blockVisible const added = txController.addBlock(block, realtime) - if (added && added.id) $lastBlockId = added.id + if (added && added.id) { + rehideBlockAfterAuraId = rehideAfterAura ? added.id : null + $lastBlockId = added.id + } } }) } if (!config.noTxFeed || !config.noBlockFeed) { - txStream.subscribe('mempool_count', count => { - $mempoolCount = count + txStream.subscribe('mempool_count', mempool => { + mempoolReady = true + if (typeof mempool === 'number') { + $mempoolCount = mempool + mempoolVbytes = 0 + } else { + $mempoolCount = mempool.count + mempoolVbytes = mempool.vbytes || 0 + } }) } @@ -119,11 +171,79 @@ } function hideBlock () { + stopFreshBlockAura() + rehideBlockAfterAuraId = null $blockVisible = false } function quitExploring () { - if (txController) txController.resumeLatest() + if (txController) { + nextBlockFreshStartDelayMs = blockReturnFreshStartDelayMs + txController.resumeLatest() + } + } + + function showNewBlockAtFullOpacity (block, freshStartDelayMs = blockFreshStartDelayMs) { + if (blockOpacityTimeout) clearTimeout(blockOpacityTimeout) + blockOpacityBlockId = block.id + blockFullOpacityUntil = Date.now() + blockFullOpacityMs + scheduleFreshBlockAura(block, freshStartDelayMs) + + if (!blockHover) setBlockOpacity(Date.now() < blockFullOpacityUntil ? 1 : blockDimOpacity, 250) + if (Date.now() < blockFullOpacityUntil) { + blockOpacityTimeout = setTimeout(() => { + if ($currentBlock && $currentBlock.id === block.id && !blockHover) { + setBlockOpacity(blockDimOpacity, 1200) + } + if ($currentBlock && $currentBlock.id === block.id) blockFresh = false + }, blockFullOpacityUntil - Date.now()) + } + } + + function stopFreshBlockAura () { + if (blockFreshTimeout) clearTimeout(blockFreshTimeout) + if (blockFreshEndTimeout) clearTimeout(blockFreshEndTimeout) + blockFresh = false + } + + function scheduleFreshBlockAura (block, startDelayMs) { + stopFreshBlockAura() + + blockFreshTimeout = setTimeout(() => { + if ($currentBlock && $currentBlock.id === block.id && block.height === $latestBlockHeight) { + blockFresh = true + blockFreshEndTimeout = setTimeout(() => { + if ($currentBlock && $currentBlock.id === block.id) { + blockFresh = false + if (rehideBlockAfterAuraId === block.id) hideBlock() + } + }, blockFreshDurationMs) + } + }, startDelayMs) + } + + function setBlockOpacity (opacity, duration=250) { + blockDisplayOpacity = opacity + if (txController) txController.setBlockOpacity(opacity, duration) + } + + function restoreBlockOpacity () { + setBlockOpacity(Date.now() < blockFullOpacityUntil ? 1 : blockDimOpacity, 250) + } + + function replayBlock () { + if (txController) txController.replayLatestBlock() + if ($currentBlock) showNewBlockAtFullOpacity($currentBlock) + } + + function focusBlock () { + blockHover = true + setBlockOpacity(1, 250) + } + + function dimBlock () { + blockHover = false + restoreBlockOpacity() } function fakeBlock () { @@ -159,12 +279,36 @@ $: connectionTitle = ($serverConnected && $serverDelay < 5000) ? ($serverDelay < 500 ? 'Streaming live transactions' : 'Unstable connection') : 'Disconnected' const fxColor = 'good' + const priceChartModes = ['none', '1d', '30d'] let fxLabel = '' + let priceChartModeLabel = '' + let priceChartLabel = '' + let priceChartTrend = 'good' + let priceChartFocused = false $: { const rate = $exchangeRates[$settings.currency] if (rate && rate.last) fxLabel = formatCurrency($settings.currency, rate.last) } + $: { + const chartMode = $settings.priceChartMode || '30d' + const chartCurrency = ($settings.currency || 'USD').toLowerCase() + priceChartModeLabel = chartMode.toUpperCase() + priceChartLabel = priceChartModeLabel + priceChartTrend = 'good' + if ($priceChartChange && $priceChartChange.mode === chartMode && $priceChartChange.currency === chartCurrency) { + const percent = $priceChartChange.percent + const sign = percent > 0 ? '+' : '' + priceChartLabel = `${priceChartLabel} ${sign}${percent}%` + priceChartTrend = $priceChartChange.trend + } + } + + function togglePriceChart () { + const current = $settings.priceChartMode || '30d' + const next = priceChartModes[(priceChartModes.indexOf(current) + 1) % priceChartModes.length] + settings.set({ ...$settings, priceChartMode: next }) + } const debounce = v => { clearTimeout(timer); @@ -184,7 +328,7 @@ x: e.clientX, y: window.innerHeight - e.clientY } - if (txController) txController.mouseClick(position) + if (txController) txController.mouseClick(position, clickToDrop) } function pointerMove (e) { @@ -236,17 +380,18 @@ .mempool-height { position: absolute; + z-index: 2; bottom: calc(25% + 10px); left: 0; right: 0; margin: auto; - padding: 0 .5rem; + padding: 0; transition: bottom 1000ms; .mempool-count { position: absolute; bottom: .5em; - left: 0.5rem; + left: var(--mempool-left); font-size: 0.9rem; color: var(--palette-x); } @@ -254,7 +399,7 @@ .mempool-info { position: absolute; bottom: .5em; - left: 0.5rem; + left: var(--mempool-left); right: 0.5em; font-size: 0.9rem; color: var(--palette-x); @@ -265,10 +410,49 @@ } .height-bar { + position: relative; width: 100%; height: 1px; - border-bottom: dashed 2px var(--palette-x); - opacity: 0.75; + opacity: 0.85; + transform: translateY(-2px); + + &::before, + &::after { + content: ''; + position: absolute; + left: 0; + right: 0; + pointer-events: none; + } + + &::before { + top: -7px; + height: 18px; + background: + linear-gradient(90deg, transparent 0%, rgba(18, 245, 214, 0.26) 50%, transparent 100%), + linear-gradient(90deg, transparent 0%, rgba(18, 245, 214, 0.22) 50%, transparent 100%), + linear-gradient(90deg, transparent 0%, rgba(18, 245, 214, 0.3) 50%, transparent 100%), + linear-gradient(90deg, transparent 0%, rgba(18, 245, 214, 0.18) 50%, transparent 100%); + background-size: 15rem 100%, 22rem 100%, 31rem 100%, 18rem 100%; + filter: blur(5px); + opacity: 0.6; + z-index: 1; + animation: mempool-tide-fancy 18s linear infinite; + } + + &::after { + top: 1px; + height: 2px; + background: + linear-gradient(90deg, transparent 43%, rgba(18, 245, 214, 0.78) 50%, transparent 57%), + linear-gradient(90deg, transparent 46%, rgba(18, 245, 214, 0.62) 50%, transparent 54%), + linear-gradient(90deg, transparent 40%, rgba(18, 245, 214, 0.72) 50%, transparent 60%), + linear-gradient(90deg, transparent 47%, rgba(18, 245, 214, 0.56) 50%, transparent 53%); + background-size: 15rem 100%, 22rem 100%, 31rem 100%, 18rem 100%; + opacity: 0.45; + z-index: 2; + animation: mempool-tide-fancy 18s linear infinite; + } } } @@ -285,6 +469,7 @@ .top-bar { position: absolute; + z-index: 3; top: 0; left: 0; right: 0; @@ -310,6 +495,13 @@ margin-bottom: 5px; } + .status-row { + display: flex; + align-items: center; + gap: 0.35rem; + height: 0.72rem; + } + .status-light { display: inline-block; width: 10px; @@ -347,6 +539,29 @@ color: white; } + .price-chart-mode { + color: var(--palette-good); + display: inline-flex; + align-items: center; + font-size: 0.72rem; + font-weight: bold; + line-height: 1; + opacity: 0.2; + transition: opacity 300ms; + + &.focused { + opacity: 1; + } + + &.bad { + color: var(--palette-bad); + } + + &.good { + color: var(--palette-good); + } + } + &.tiny { width: 100%; .row { @@ -355,6 +570,10 @@ flex-direction: row; justify-content: space-between; } + + .status-row { + justify-content: flex-start; + } } } } @@ -374,6 +593,8 @@ } .block-area-wrapper { + position: relative; + z-index: 2; height: 100%; display: flex; flex-direction: column; @@ -386,14 +607,45 @@ .block-area-outer { position: relative; flex: 0; + pointer-events: auto; // width: 75vw; // max-width: 40vh; margin: auto; .block-area { + position: relative; + z-index: 1; padding-top: 100%; } + &.block-fresh { + --fresh-angle: 0deg; + + &::before, + &::after { + content: ''; + display: block; + position: absolute; + z-index: 2; + inset: -0.35rem; + border-radius: 3px; + box-sizing: border-box; + pointer-events: none; + } + + &::before { + border: 2px solid rgba(247, 147, 26, 0.35); + box-shadow: 0 0 18px rgba(247, 147, 26, 0.75), inset 0 0 10px rgba(247, 147, 26, 0.22); + animation: block-fresh-fade var(--fresh-duration) ease-out 1 forwards; + } + + &::after { + border: 2px solid transparent; + border-image: conic-gradient(from var(--fresh-angle), transparent 0deg 284deg, rgba(247, 147, 26, 0.3) 302deg, #f7931a 326deg, rgba(255, 122, 0, 0.6) 345deg, transparent 360deg) 1; + animation: block-fresh-spin var(--fresh-duration) linear 1 forwards, block-fresh-fade var(--fresh-duration) ease-out 1 forwards; + } + } + .guide-area { background: #00FF00; opacity: 25%; @@ -490,30 +742,105 @@ width: 18em; } } + + .tx-area.ambient-mode { + .top-bar { + 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 { + .height-bar { + opacity: 0.35; + } + + .mempool-count, + .mempool-info { + font-size: 1rem; + text-shadow: 0 0 8px var(--palette-y); + } + } + } + + @property --fresh-angle { + syntax: '