diff --git a/backend/package-lock.json b/backend/package-lock.json index 2f1e30d27..0576e27df 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,12 +18,12 @@ "crypto-js": "~4.2.0", "express": "~4.19.2", "maxmind": "~4.3.11", - "mysql2": "~3.9.4", + "mysql2": "~3.9.7", "redis": "^4.6.6", "rust-gbt": "file:./rust-gbt", "socks-proxy-agent": "~7.0.0", "typescript": "~4.9.3", - "ws": "~8.16.0" + "ws": "~8.17.0" }, "devDependencies": { "@babel/code-frame": "^7.18.6", @@ -6197,9 +6197,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/mysql2": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.4.tgz", - "integrity": "sha512-OEESQuwxMza803knC1YSt7NMuc1BrK9j7gZhCSs2WAyxr1vfiI7QLaLOKTh5c9SWGz98qVyQUbK8/WckevNQhg==", + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.7.tgz", + "integrity": "sha512-KnJT8vYRcNAZv73uf9zpXqNbvBG7DJrs+1nACsjZP1HMJ1TgXEy8wnNilXAn/5i57JizXKtrUtwDB7HxT9DDpw==", "dependencies": { "denque": "^2.1.0", "generate-function": "^2.3.1", @@ -7690,9 +7690,9 @@ } }, "node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", + "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", "engines": { "node": ">=10.0.0" }, @@ -12382,9 +12382,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "mysql2": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.4.tgz", - "integrity": "sha512-OEESQuwxMza803knC1YSt7NMuc1BrK9j7gZhCSs2WAyxr1vfiI7QLaLOKTh5c9SWGz98qVyQUbK8/WckevNQhg==", + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.7.tgz", + "integrity": "sha512-KnJT8vYRcNAZv73uf9zpXqNbvBG7DJrs+1nACsjZP1HMJ1TgXEy8wnNilXAn/5i57JizXKtrUtwDB7HxT9DDpw==", "requires": { "denque": "^2.1.0", "generate-function": "^2.3.1", @@ -13424,9 +13424,9 @@ } }, "ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", + "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", "requires": {} }, "y18n": { diff --git a/backend/package.json b/backend/package.json index 53eb6fc60..a330fa709 100644 --- a/backend/package.json +++ b/backend/package.json @@ -47,12 +47,12 @@ "crypto-js": "~4.2.0", "express": "~4.19.2", "maxmind": "~4.3.11", - "mysql2": "~3.9.4", + "mysql2": "~3.9.7", "rust-gbt": "file:./rust-gbt", "redis": "^4.6.6", "socks-proxy-agent": "~7.0.0", "typescript": "~4.9.3", - "ws": "~8.16.0" + "ws": "~8.17.0" }, "devDependencies": { "@babel/code-frame": "^7.18.6", diff --git a/backend/src/api/explorer/channels.routes.ts b/backend/src/api/explorer/channels.routes.ts index f28ab2a9d..391bf628e 100644 --- a/backend/src/api/explorer/channels.routes.ts +++ b/backend/src/api/explorer/channels.routes.ts @@ -54,9 +54,11 @@ class ChannelsRoutes { if (index < -1) { res.status(400).send('Invalid index'); + return; } if (['open', 'active', 'closed'].includes(status) === false) { res.status(400).send('Invalid status'); + return; } const channels = await channelsApi.$getChannelsForNode(req.query.public_key, index, 10, status); diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index d4ff7efe3..fdda3df88 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -3,6 +3,7 @@ import * as WebSocket from 'ws'; import { BlockExtended, TransactionExtended, MempoolTransactionExtended, WebsocketResponse, OptimizedStatistic, ILoadingIndicators, GbtCandidates, TxTrackingInfo, + MempoolBlockDelta, MempoolDelta, MempoolDeltaTxids } from '../mempool.interfaces'; import blocks from './blocks'; import memPool from './mempool'; @@ -364,6 +365,18 @@ class WebsocketHandler { client['track-donation'] = parsedMessage['track-donation']; } + if (parsedMessage['track-mempool-txids'] === true) { + client['track-mempool-txids'] = true; + } else if (parsedMessage['track-mempool-txids'] === false) { + delete client['track-mempool-txids']; + } + + if (parsedMessage['track-mempool'] === true) { + client['track-mempool'] = true; + } else if (parsedMessage['track-mempool'] === false) { + delete client['track-mempool']; + } + if (Object.keys(response).length) { client.send(this.serializeResponse(response)); } @@ -545,6 +558,33 @@ class WebsocketHandler { const latestTransactions = memPool.getLatestTransactions(); + if (memPool.isInSync()) { + this.mempoolSequence++; + } + + const replacedTransactions: { replaced: string, by: TransactionExtended }[] = []; + for (const tx of newTransactions) { + if (rbfTransactions[tx.txid]) { + for (const replaced of rbfTransactions[tx.txid]) { + replacedTransactions.push({ replaced: replaced.txid, by: tx }); + } + } + } + const mempoolDeltaTxids: MempoolDeltaTxids = { + sequence: this.mempoolSequence, + added: newTransactions.map(tx => tx.txid), + removed: deletedTransactions.map(tx => tx.txid), + mined: [], + replaced: replacedTransactions.map(replacement => ({ replaced: replacement.replaced, by: replacement.by.txid })), + }; + const mempoolDelta: MempoolDelta = { + sequence: this.mempoolSequence, + added: newTransactions, + removed: deletedTransactions.map(tx => tx.txid), + mined: [], + replaced: replacedTransactions, + }; + // update init data const socketDataFields = { 'mempoolInfo': mempoolInfo, @@ -604,10 +644,6 @@ class WebsocketHandler { const addressCache = this.makeAddressCache(newTransactions); const removedAddressCache = this.makeAddressCache(deletedTransactions); - if (memPool.isInSync()) { - this.mempoolSequence++; - } - // TODO - Fix indentation after PR is merged for (const server of this.webSocketServers) { server.clients.forEach(async (client) => { @@ -847,6 +883,14 @@ class WebsocketHandler { response['rbfLatestSummary'] = getCachedResponse('rbfLatestSummary', rbfSummary); } + if (client['track-mempool-txids']) { + response['mempool-txids'] = getCachedResponse('mempool-txids', mempoolDeltaTxids); + } + + if (client['track-mempool']) { + response['mempool-transactions'] = getCachedResponse('mempool-transactions', mempoolDelta); + } + if (Object.keys(response).length) { client.send(this.serializeResponse(response)); } @@ -992,6 +1036,31 @@ class WebsocketHandler { const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions(); + if (memPool.isInSync()) { + this.mempoolSequence++; + } + + const replacedTransactions: { replaced: string, by: TransactionExtended }[] = []; + for (const txid of Object.keys(rbfTransactions)) { + for (const replaced of rbfTransactions[txid].replaced) { + replacedTransactions.push({ replaced: replaced.txid, by: rbfTransactions[txid].replacedBy }); + } + } + const mempoolDeltaTxids: MempoolDeltaTxids = { + sequence: this.mempoolSequence, + added: [], + removed: [], + mined: transactions.map(tx => tx.txid), + replaced: replacedTransactions.map(replacement => ({ replaced: replacement.replaced, by: replacement.by.txid })), + }; + const mempoolDelta: MempoolDelta = { + sequence: this.mempoolSequence, + added: [], + removed: [], + mined: transactions.map(tx => tx.txid), + replaced: replacedTransactions, + }; + const responseCache = { ...this.socketData }; function getCachedResponse(key, data): string { if (!responseCache[key]) { @@ -1000,10 +1069,6 @@ class WebsocketHandler { return responseCache[key]; } - if (memPool.isInSync()) { - this.mempoolSequence++; - } - // TODO - Fix indentation after PR is merged for (const server of this.webSocketServers) { server.clients.forEach((client) => { @@ -1185,6 +1250,14 @@ class WebsocketHandler { } } + if (client['track-mempool-txids']) { + response['mempool-txids'] = getCachedResponse('mempool-txids', mempoolDeltaTxids); + } + + if (client['track-mempool']) { + response['mempool-transactions'] = getCachedResponse('mempool-transactions', mempoolDelta); + } + if (Object.keys(response).length) { client.send(this.serializeResponse(response)); } diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 0b4b20e02..0fcddc45a 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -71,6 +71,22 @@ export interface MempoolBlockDelta { changed: MempoolDeltaChange[]; } +export interface MempoolDeltaTxids { + sequence: number, + added: string[]; + removed: string[]; + mined: string[]; + replaced: { replaced: string, by: string }[]; +} + +export interface MempoolDelta { + sequence: number, + added: MempoolTransactionExtended[]; + removed: string[]; + mined: string[]; + replaced: { replaced: string, by: TransactionExtended }[]; +} + interface VinStrippedToScriptsig { scriptsig: string; } diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile index 3a63107bf..bee617595 100644 --- a/docker/frontend/Dockerfile +++ b/docker/frontend/Dockerfile @@ -13,7 +13,7 @@ RUN npm install --omit=dev --omit=optional RUN npm run build -FROM nginx:1.25.4-alpine +FROM nginx:1.26.0-alpine WORKDIR /patch diff --git a/frontend/.gitignore b/frontend/.gitignore index d2a765dda..c10a00946 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -63,6 +63,7 @@ src/resources/pools.json src/resources/mining-pools/* src/resources/**/*.mp4 src/resources/**/*.vtt +src/resources/customize.js # environment config mempool-frontend-config.json diff --git a/frontend/angular.json b/frontend/angular.json index f55c09934..46cc3f667 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -166,6 +166,7 @@ "src/resources", "src/robots.txt", "src/config.js", + "src/customize.js", "src/config.template.js" ], "styles": [ diff --git a/frontend/custom-sv-config.json b/frontend/custom-sv-config.json new file mode 100644 index 000000000..f64f41be8 --- /dev/null +++ b/frontend/custom-sv-config.json @@ -0,0 +1,44 @@ +{ + "theme": "contrast", + "enterprise": "onbtc", + "branding": { + "name": "onbtc", + "title": "Oficina Nacional del Bitcoin", + "site_id": 19, + "header_img": "/resources/onbtc.svg", + "img": "/resources/elsalvador.svg", + "rounded_corner": true + }, + "dashboard": { + "widgets": [ + { + "component": "fees" + }, + { + "component": "balance", + "props": { + "address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo" + } + }, + { + "component": "goggles" + }, + { + "component": "address", + "props": { + "address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo", + "period": "1m" + } + }, + { + "component": "blocks" + }, + { + "component": "addressTransactions", + "props": { + "address": "32ixEdVJWo3kmvJGMTZq5jAQVZZeuwnqzo" + } + } + ] + } +} \ No newline at end of file diff --git a/frontend/generate-config.js b/frontend/generate-config.js index c7a81a482..8f911dfe6 100644 --- a/frontend/generate-config.js +++ b/frontend/generate-config.js @@ -4,6 +4,7 @@ const { spawnSync } = require('child_process'); const CONFIG_FILE_NAME = 'mempool-frontend-config.json'; const GENERATED_CONFIG_FILE_NAME = 'src/resources/config.js'; const GENERATED_TEMPLATE_CONFIG_FILE_NAME = 'src/resources/config.template.js'; +const GENERATED_CUSTOMIZATION_FILE_NAME = 'src/resources/customize.js'; let settings = []; let configContent = {}; @@ -109,6 +110,23 @@ writeConfigTemplate(GENERATED_TEMPLATE_CONFIG_FILE_NAME, newConfigTemplate); const currentConfig = readConfig(GENERATED_CONFIG_FILE_NAME); +let customConfigJs = ''; +if (configContent && configContent.CUSTOMIZATION) { + const customConfig = readConfig(configContent.CUSTOMIZATION); + if (customConfig) { + console.log(`Customizing frontend using ${configContent.CUSTOMIZATION}`); + customConfigJs = `(function (window) { + window.__env = window.__env || {}; + window.__env.customize = ${customConfig}; + }((typeof global !== 'undefined') ? global : this)); + `; + } else { + throw new Error('Failed to load customization file'); + } +} + +writeConfig(GENERATED_CUSTOMIZATION_FILE_NAME, customConfigJs); + if (currentConfig && currentConfig === newConfig) { console.log(`No configuration updates, skipping ${GENERATED_CONFIG_FILE_NAME} file update`); return; diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html index e9f6badbd..44b027ae2 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.html @@ -23,7 +23,7 @@ Accelerate Confirmation expected within ~30 minutes
@if (!calculating) { - fee ({{ cost | number }} sats) + fee ({{ cost | number }} sats) } @else { Calculating cost... } diff --git a/frontend/src/app/components/address-graph/address-graph.component.html b/frontend/src/app/components/address-graph/address-graph.component.html index 35808cb14..df4cdf330 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.html +++ b/frontend/src/app/components/address-graph/address-graph.component.html @@ -1,14 +1,14 @@ - + -
-
+
+
Balance History
-
+
-
@@ -20,4 +20,8 @@

{{ error }}

+ +
+
+
diff --git a/frontend/src/app/components/address-graph/address-graph.component.scss b/frontend/src/app/components/address-graph/address-graph.component.scss index d23b95d8d..a118549fb 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.scss +++ b/frontend/src/app/components/address-graph/address-graph.component.scss @@ -66,7 +66,6 @@ .chart-widget { width: 100%; height: 100%; - max-height: 270px; } .disabled { diff --git a/frontend/src/app/components/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts index 6ae3dd8e8..26a1bd408 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.ts +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -1,12 +1,22 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnChanges, SimpleChanges } from '@angular/core'; import { echarts, EChartsOption } from '../../graphs/echarts'; -import { of } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; -import { ChainStats } from '../../interfaces/electrs.interface'; +import { AddressTxSummary, ChainStats } from '../../interfaces/electrs.interface'; import { ElectrsApiService } from '../../services/electrs-api.service'; import { AmountShortenerPipe } from '../../shared/pipes/amount-shortener.pipe'; import { Router } from '@angular/router'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; +import { StateService } from '../../services/state.service'; + +const periodSeconds = { + '1d': (60 * 60 * 24), + '3d': (60 * 60 * 24 * 3), + '1w': (60 * 60 * 24 * 7), + '1m': (60 * 60 * 24 * 30), + '6m': (60 * 60 * 24 * 180), + '1y': (60 * 60 * 24 * 365), +}; @Component({ selector: 'app-address-graph', @@ -26,8 +36,12 @@ export class AddressGraphComponent implements OnChanges { @Input() address: string; @Input() isPubkey: boolean = false; @Input() stats: ChainStats; + @Input() addressSummary$: Observable | null; + @Input() period: '1d' | '3d' | '1w' | '1m' | '6m' | '1y' | 'all' = 'all'; + @Input() height: number = 200; @Input() right: number | string = 10; @Input() left: number | string = 70; + @Input() widget: boolean = false; data: any[] = []; hoverData: any[] = []; @@ -43,6 +57,7 @@ export class AddressGraphComponent implements OnChanges { constructor( @Inject(LOCALE_ID) public locale: string, + public stateService: StateService, private electrsApiService: ElectrsApiService, private router: Router, private amountShortenerPipe: AmountShortenerPipe, @@ -52,14 +67,17 @@ export class AddressGraphComponent implements OnChanges { ngOnChanges(changes: SimpleChanges): void { this.isLoading = true; - (this.isPubkey + if (!this.address || !this.stats) { + return; + } + (this.addressSummary$ || (this.isPubkey ? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac') : this.electrsApiService.getAddressSummary$(this.address)).pipe( catchError(e => { this.error = `Failed to fetch address balance history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`; return of(null); }), - ).subscribe(addressSummary => { + )).subscribe(addressSummary => { if (addressSummary) { this.error = null; this.prepareChartOptions(addressSummary); @@ -70,14 +88,24 @@ export class AddressGraphComponent implements OnChanges { } prepareChartOptions(summary): void { - let total = (this.stats.funded_txo_sum - this.stats.spent_txo_sum); // + (summary[0]?.value || 0); + let total = (this.stats.funded_txo_sum - this.stats.spent_txo_sum); this.data = summary.map(d => { const balance = total; total -= d.value; return [d.time * 1000, balance, d]; }).reverse(); - const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1])), 0); + if (this.period !== 'all') { + const now = Date.now(); + const start = now - (periodSeconds[this.period] * 1000); + this.data = this.data.filter(d => d[0] >= start); + this.data.push( + {value: [now, this.stats.funded_txo_sum - this.stats.spent_txo_sum], symbol: 'none', tooltip: { show: false }} + ); + } + + const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] || d.value[1])), 0); + const minValue = this.data.reduce((acc, d) => Math.min(acc, Math.abs(d[1] || d.value[1])), maxValue); this.chartOptions = { color: [ @@ -108,6 +136,9 @@ export class AddressGraphComponent implements OnChanges { }, borderColor: '#000', formatter: function (data): string { + if (!data?.length || !data[0]?.data?.[2]?.txid) { + return ''; + } const header = data.length === 1 ? `${data[0].data[2].txid.slice(0, 6)}...${data[0].data[2].txid.slice(-6)}` : `${data.length} transactions`; @@ -141,13 +172,17 @@ export class AddressGraphComponent implements OnChanges { axisLabel: { color: 'rgb(110, 112, 121)', formatter: (val): string => { - if (maxValue > 1_000_000_000) { + let valSpan = maxValue - (this.period === 'all' ? 0 : minValue); + if (valSpan > 100_000_000_000) { return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 0)} BTC`; - } else if (maxValue > 100_000_000) { + } + else if (valSpan > 1_000_000_000) { + return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 2)} BTC`; + } else if (valSpan > 100_000_000) { return `${(val / 100_000_000).toFixed(1)} BTC`; - } else if (maxValue > 10_000_000) { + } else if (valSpan > 10_000_000) { return `${(val / 100_000_000).toFixed(2)} BTC`; - } else if (maxValue > 1_000_000) { + } else if (valSpan > 1_000_000) { return `${(val / 100_000_000).toFixed(3)} BTC`; } else { return `${this.amountShortenerPipe.transform(val, 0)} sats`; @@ -157,6 +192,7 @@ export class AddressGraphComponent implements OnChanges { splitLine: { show: false, }, + min: this.period === 'all' ? 0 : 'dataMin' }, ], series: [ diff --git a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.html b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.html new file mode 100644 index 000000000..13fe5b0d3 --- /dev/null +++ b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + +
 
+
TXIDAmount{{ currency }}Date
+ + + +
+ + + + +
+
+
+
+ + +
\ No newline at end of file diff --git a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.scss b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.scss new file mode 100644 index 000000000..851da5996 --- /dev/null +++ b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.scss @@ -0,0 +1,50 @@ +.latest-transactions { + width: 100%; + text-align: left; + table-layout:fixed; + tr, td, th { + border: 0px; + padding-top: 0.71rem !important; + padding-bottom: 0.75rem !important; + } + td { + overflow:hidden; + width: 25%; + } + .table-cell-satoshis { + display: none; + text-align: right; + @media (min-width: 576px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 1100px) { + display: table-cell; + } + } + .table-cell-fiat { + display: none; + text-align: right; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } + .table-cell-date { + text-align: right; + } +} +.skeleton-loader-transactions { + max-width: 250px; + position: relative; + top: 2px; + margin-bottom: -3px; + height: 18px; +} \ No newline at end of file diff --git a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts new file mode 100644 index 000000000..c3fc4260e --- /dev/null +++ b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts @@ -0,0 +1,76 @@ +import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; +import { StateService } from '../../services/state.service'; +import { Address, AddressTxSummary } from '../../interfaces/electrs.interface'; +import { ElectrsApiService } from '../../services/electrs-api.service'; +import { Observable, Subscription, catchError, map, of, switchMap, zip } from 'rxjs'; +import { PriceService } from '../../services/price.service'; + +@Component({ + selector: 'app-address-transactions-widget', + templateUrl: './address-transactions-widget.component.html', + styleUrls: ['./address-transactions-widget.component.scss'], +}) +export class AddressTransactionsWidgetComponent implements OnInit, OnChanges, OnDestroy { + @Input() address: string; + @Input() addressInfo: Address; + @Input() addressSummary$: Observable | null; + @Input() isPubkey: boolean = false; + + currencySubscription: Subscription; + currency: string; + + transactions$: Observable; + + isLoading: boolean = true; + error: any; + + constructor( + public stateService: StateService, + private electrsApiService: ElectrsApiService, + private priceService: PriceService, + ) { } + + ngOnInit(): void { + this.currencySubscription = this.stateService.fiatCurrency$.subscribe((fiat) => { + this.currency = fiat; + }); + this.startAddressSubscription(); + } + + ngOnChanges(changes: SimpleChanges): void { + this.startAddressSubscription(); + } + + startAddressSubscription(): void { + this.isLoading = true; + if (!this.address || !this.addressInfo) { + return; + } + this.transactions$ = (this.addressSummary$ || (this.isPubkey + ? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac') + : this.electrsApiService.getAddressSummary$(this.address)).pipe( + catchError(e => { + this.error = `Failed to fetch address history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`; + return of(null); + }) + )).pipe( + map(summary => { + return summary?.slice(0, 6); + }), + switchMap(txs => { + return (zip(txs.map(tx => this.priceService.getBlockPrice$(tx.time, true, this.currency).pipe( + map(price => { + return { + ...tx, + price, + }; + }) + )))); + }) + ); + } + + ngOnDestroy(): void { + this.currencySubscription.unsubscribe(); + } +} diff --git a/frontend/src/app/components/amount/amount.component.html b/frontend/src/app/components/amount/amount.component.html index 9ca0ba939..f157d17d6 100644 --- a/frontend/src/app/components/amount/amount.component.html +++ b/frontend/src/app/components/amount/amount.component.html @@ -1,4 +1,4 @@ - + {{ addPlus && satoshis >= 0 ? '+' : '' }}{{ ( @@ -20,10 +20,28 @@ Confidential - ‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis / 100000000 | number : digitsInfo }} - L- - tL- - t - sBTC + + @if ((viewAmountMode$ | async) === 'btc' || (viewAmountMode$ | async) === 'fiat') { + ‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis / 100000000 | number : digitsInfo }} + + BTC + + } @else { + @if (digitsInfo === '1.8-8') { + ‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis | number }} + } @else { + ‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis | amountShortener : satoshis < 1000 && satoshis > -1000 ? 0 : 1 }} + } + + sats + + } + + + L- + tL- + t + s + diff --git a/frontend/src/app/components/amount/amount.component.ts b/frontend/src/app/components/amount/amount.component.ts index 9d0337574..60cbe3117 100644 --- a/frontend/src/app/components/amount/amount.component.ts +++ b/frontend/src/app/components/amount/amount.component.ts @@ -12,7 +12,7 @@ import { Price } from '../../services/price.service'; export class AmountComponent implements OnInit, OnDestroy { conversions$: Observable; currency: string; - viewFiat$: Observable; + viewAmountMode$: Observable<'btc' | 'sats' | 'fiat'>; network = ''; stateSubscription: Subscription; @@ -37,7 +37,7 @@ export class AmountComponent implements OnInit, OnDestroy { } ngOnInit() { - this.viewFiat$ = this.stateService.viewFiat$.asObservable(); + this.viewAmountMode$ = this.stateService.viewAmountMode$.asObservable(); this.conversions$ = this.stateService.conversions$.asObservable(); this.stateSubscription = this.stateService.networkChanged$.subscribe((network) => this.network = network); } diff --git a/frontend/src/app/components/balance-widget/balance-widget.component.html b/frontend/src/app/components/balance-widget/balance-widget.component.html new file mode 100644 index 000000000..4923a2c06 --- /dev/null +++ b/frontend/src/app/components/balance-widget/balance-widget.component.html @@ -0,0 +1,59 @@ +
+
+
+
+
BTC Holdings
+
+ {{ ((addressInfo.chain_stats.funded_txo_sum - addressInfo.chain_stats.spent_txo_sum) / 100_000_000) | number: '1.2-2' }} BTC +
+
+ +
+
+
+
Change (7d)
+
+ {{ delta7d > 0 ? '+' : ''}}{{ ((delta7d) / 100_000_000) | number: '1.2-2' }} BTC +
+
+ +
+
+
+
Change (30d)
+
+ {{ delta30d > 0 ? '+' : ''}}{{ ((delta30d) / 100_000_000) | number: '1.2-2' }} BTC +
+
+ +
+
+
+
+
+ + +
+
+
BTC Holdings
+
+
+
+
+
+
+
Change (7d)
+
+
+
+
+
+
+
Change (30d)
+
+
+
+
+
+
+
diff --git a/frontend/src/app/components/balance-widget/balance-widget.component.scss b/frontend/src/app/components/balance-widget/balance-widget.component.scss new file mode 100644 index 000000000..a2f803c79 --- /dev/null +++ b/frontend/src/app/components/balance-widget/balance-widget.component.scss @@ -0,0 +1,160 @@ +.balance-container { + display: flex; + flex-direction: row; + justify-content: space-around; + height: 76px; + .shared-block { + color: var(--transparent-fg); + font-size: 12px; + } + .item { + padding: 0 5px; + width: 100%; + max-width: 150px; + &:last-child { + display: none; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } + } + .card-text { + font-size: 22px; + margin-top: -9px; + position: relative; + } +} + + +.balance-skeleton { + display: flex; + justify-content: space-between; + @media (min-width: 376px) { + flex-direction: row; + } + .item { + min-width: 120px; + max-width: 150px; + margin: 0; + width: -webkit-fill-available; + @media (min-width: 376px) { + margin: 0 auto 0px; + } + &:last-child{ + display: none; + @media (min-width: 485px) { + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + &:last-child { + margin-bottom: 0; + } + } + .card-text { + .skeleton-loader { + width: 100%; + display: block; + &:first-child { + margin: 14px auto 0; + max-width: 80px; + } + &:last-child { + margin: 10px auto 0; + max-width: 120px; + } + } + } +} + +.card { + background-color: var(--bg); + height: 126px; +} + +.card-title { + color: var(--title-fg); + font-size: 1rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.progress { + display: inline-flex; + width: 100%; + background-color: var(--secondary); + height: 1.1rem; + max-width: 180px; +} + +.skeleton-loader { + max-width: 100%; +} + +.more-padding { + padding: 24px 20px; +} + +.small-bar { + height: 8px; + top: -4px; + max-width: 120px; +} + +.loading-container { + min-height: 76px; +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.card-wrapper { + .card { + height: auto !important; + } + .card-body { + display: flex; + flex: inherit; + text-align: center; + flex-direction: column; + justify-content: space-around; + padding: 24px 20px; + } +} + +.retarget-sign { + margin-right: -3px; + font-size: 14px; + top: -2px; + position: relative; +} + +.previous-retarget-sign { + margin-right: -2px; + font-size: 10px; +} + +.symbol { + font-size: 13px; + white-space: nowrap; +} \ No newline at end of file diff --git a/frontend/src/app/components/balance-widget/balance-widget.component.ts b/frontend/src/app/components/balance-widget/balance-widget.component.ts new file mode 100644 index 000000000..c48cbc869 --- /dev/null +++ b/frontend/src/app/components/balance-widget/balance-widget.component.ts @@ -0,0 +1,71 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { StateService } from '../../services/state.service'; +import { Address, AddressTxSummary } from '../../interfaces/electrs.interface'; +import { ElectrsApiService } from '../../services/electrs-api.service'; +import { Observable, catchError, of } from 'rxjs'; + +@Component({ + selector: 'app-balance-widget', + templateUrl: './balance-widget.component.html', + styleUrls: ['./balance-widget.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BalanceWidgetComponent implements OnInit, OnChanges { + @Input() address: string; + @Input() addressInfo: Address; + @Input() addressSummary$: Observable | null; + @Input() isPubkey: boolean = false; + + isLoading: boolean = true; + error: any; + + delta7d: number = 0; + delta30d: number = 0; + + constructor( + public stateService: StateService, + private electrsApiService: ElectrsApiService, + private cd: ChangeDetectorRef, + ) { } + + ngOnInit(): void { + + } + + ngOnChanges(changes: SimpleChanges): void { + this.isLoading = true; + if (!this.address || !this.addressInfo) { + return; + } + (this.addressSummary$ || (this.isPubkey + ? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac') + : this.electrsApiService.getAddressSummary$(this.address)).pipe( + catchError(e => { + this.error = `Failed to fetch address balance history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`; + return of(null); + }), + )).subscribe(addressSummary => { + if (addressSummary) { + this.error = null; + this.calculateStats(addressSummary); + } + this.isLoading = false; + this.cd.markForCheck(); + }); + } + + calculateStats(summary: AddressTxSummary[]): void { + let weekTotal = 0; + let monthTotal = 0; + const weekAgo = (Date.now() / 1000) - (60 * 60 * 24 * 7); + const monthAgo = (Date.now() / 1000) - (60 * 60 * 24 * 30); + for (let i = 0; i < summary.length && summary[i].time >= monthAgo; i++) { + monthTotal += summary[i].value; + if (summary[i].time >= weekAgo) { + weekTotal += summary[i].value; + } + } + this.delta7d = weekTotal; + this.delta30d = monthTotal; + } +} diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts index 3fee3f901..57db9bfca 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts @@ -81,6 +81,20 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On tooltipPosition: Position; readyNextFrame = false; + lastUpdate: number = 0; + pendingUpdate: { + count: number, + add: { [txid: string]: TransactionStripped }, + remove: { [txid: string]: string }, + change: { [txid: string]: { txid: string, rate: number | undefined, acc: boolean | undefined } }, + direction?: string, + } = { + count: 0, + add: {}, + remove: {}, + change: {}, + direction: 'left', + }; searchText: string; searchSubscription: Subscription; @@ -176,6 +190,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On destroy(): void { if (this.scene) { this.scene.destroy(); + this.clearUpdateQueue(); this.start(); } } @@ -188,6 +203,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On } this.filtersAvailable = filtersAvailable; if (this.scene) { + this.clearUpdateQueue(); this.scene.setup(transactions); this.readyNextFrame = true; this.start(); @@ -197,6 +213,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On enter(transactions: TransactionStripped[], direction: string): void { if (this.scene) { + this.clearUpdateQueue(); this.scene.enter(transactions, direction); this.start(); this.updateSearchHighlight(); @@ -205,6 +222,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On exit(direction: string): void { if (this.scene) { + this.clearUpdateQueue(); this.scene.exit(direction); this.start(); this.updateSearchHighlight(); @@ -213,13 +231,61 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On replace(transactions: TransactionStripped[], direction: string, sort: boolean = true, startTime?: number): void { if (this.scene) { + this.clearUpdateQueue(); this.scene.replace(transactions || [], direction, sort, startTime); this.start(); this.updateSearchHighlight(); } } + // collates non-urgent updates into a set of consistent pending changes + queueUpdate(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left'): void { + for (const tx of add) { + this.pendingUpdate.add[tx.txid] = tx; + delete this.pendingUpdate.remove[tx.txid]; + delete this.pendingUpdate.change[tx.txid]; + } + for (const txid of remove) { + delete this.pendingUpdate.add[txid]; + this.pendingUpdate.remove[txid] = txid; + delete this.pendingUpdate.change[txid]; + } + for (const tx of change) { + if (this.pendingUpdate.add[tx.txid]) { + this.pendingUpdate.add[tx.txid].rate = tx.rate; + this.pendingUpdate.add[tx.txid].acc = tx.acc; + } else { + this.pendingUpdate.change[tx.txid] = tx; + } + } + this.pendingUpdate.direction = direction; + this.pendingUpdate.count++; + } + + applyQueuedUpdates(): void { + if (this.pendingUpdate.count && performance.now() > (this.lastUpdate + this.animationDuration)) { + this.update([], [], [], this.pendingUpdate?.direction); + } + } + + clearUpdateQueue(): void { + this.pendingUpdate = { + count: 0, + add: {}, + remove: {}, + change: {}, + }; + this.lastUpdate = performance.now(); + } + update(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left', resetLayout: boolean = false): void { + // merge any pending changes into this update + this.queueUpdate(add, remove, change); + this.applyUpdate(Object.values(this.pendingUpdate.add), Object.values(this.pendingUpdate.remove), Object.values(this.pendingUpdate.change), direction, resetLayout); + this.clearUpdateQueue(); + } + + applyUpdate(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left', resetLayout: boolean = false): void { if (this.scene) { add = add.filter(tx => !this.scene.txs[tx.txid]); remove = remove.filter(txid => this.scene.txs[txid]); @@ -230,6 +296,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On } this.scene.update(add, remove, change, direction, resetLayout); this.start(); + this.lastUpdate = performance.now(); this.updateSearchHighlight(); } } @@ -370,6 +437,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On if (!now) { now = performance.now(); } + this.applyQueuedUpdates(); // skip re-render if there's no change to the scene if (this.scene && this.gl) { /* SET UP SHADER UNIFORMS */ diff --git a/frontend/src/app/components/block-overview-graph/block-scene.ts b/frontend/src/app/components/block-overview-graph/block-scene.ts index bef907a7a..9dd76dec9 100644 --- a/frontend/src/app/components/block-overview-graph/block-scene.ts +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -13,7 +13,7 @@ export default class BlockScene { theme: ThemeService; orientation: string; flip: boolean; - animationDuration: number = 900; + animationDuration: number = 1000; configAnimationOffset: number | null; animationOffset: number; highlightingEnabled: boolean; @@ -179,7 +179,7 @@ export default class BlockScene { removed.forEach(tx => { tx.destroy(); }); - }, 1000); + }, (startTime - performance.now()) + this.animationDuration + 1000); if (resetLayout) { add.forEach(tx => { @@ -239,7 +239,7 @@ export default class BlockScene { { width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number, orientation: string, flip: boolean, vertexArray: FastVertexArray, theme: ThemeService, highlighting: boolean, colorFunction: ((tx: TxView) => Color) | null } ): void { - this.animationDuration = animationDuration || 1000; + this.animationDuration = animationDuration || this.animationDuration || 1000; this.configAnimationOffset = animationOffset; this.animationOffset = this.configAnimationOffset == null ? (this.width * 1.4) : this.configAnimationOffset; this.orientation = orientation; diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html index ac29524bb..603c7e4c0 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html @@ -12,6 +12,7 @@ class="text-center bitcoin-block mined-block blockchain-blocks-offset-{{ offset }}-index-{{ i }}" [class.offscreen]="!static && count && i >= count" id="bitcoin-block-{{ block.height }}" [ngStyle]="blockStyles[i]" + [style]="blockTransformation" [class.blink-bg]="isSpecial(block.height)">   @@ -40,7 +41,7 @@
-
diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts index ca79b68a6..35499f162 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, Input, OnChanges, SimpleChanges } from '@angular/core'; -import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { Observable, Subscription, delay, filter, tap } from 'rxjs'; import { StateService } from '../../services/state.service'; import { specialBlocks } from '../../app.constants'; import { BlockExtended } from '../../interfaces/node-api.interface'; @@ -45,7 +45,10 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { markBlockSubscription: Subscription; txConfirmedSubscription: Subscription; loadingBlocks$: Observable; - showMiningInfo$: BehaviorSubject = new BehaviorSubject(false); + showMiningInfoSubscription: Subscription; + blockDisplayModeSubscription: Subscription; + blockDisplayMode: 'size' | 'fees'; + blockTransformation = {}; blockStyles = []; emptyBlockStyles = []; interval: any; @@ -78,22 +81,38 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { ) { } - enabledMiningInfoIfNeeded(url) { - const urlParts = url.split('/'); - const onDashboard = ['','testnet','signet','mining','acceleration'].includes(urlParts[urlParts.length - 1]); - if (onDashboard) { // Only update showMiningInfo if we are on the main, mining or acceleration dashboards - this.stateService.showMiningInfo$.next(url.includes('/mining') || url.includes('/acceleration')); - } - } - ngOnInit() { this.dynamicBlocksAmount = Math.min(8, this.stateService.env.KEEP_BLOCKS_AMOUNT); - if (['', 'testnet', 'signet'].includes(this.stateService.network)) { - this.enabledMiningInfoIfNeeded(this.location.path()); - this.location.onUrlChange((url) => this.enabledMiningInfoIfNeeded(url)); - this.showMiningInfo$ = this.stateService.showMiningInfo$; - } + this.blockDisplayMode = this.stateService.blockDisplayMode$.value as 'size' | 'fees'; + this.blockDisplayModeSubscription = this.stateService.blockDisplayMode$ + .pipe( + filter((mode: 'size' | 'fees') => mode !== this.blockDisplayMode), + tap(() => { + this.blockTransformation = this.timeLtr ? { + transform: 'scaleX(-1) rotateX(90deg)', + transition: 'transform 0.375s' + } : { + transform: 'rotateX(90deg)', + transition: 'transform 0.375s' + }; + }), + delay(375), + tap((mode) => { + this.blockDisplayMode = mode; + this.blockTransformation = this.timeLtr ? { + transform: 'scaleX(-1)', + transition: 'transform 0.375s' + } : { + transition: 'transform 0.375s' + }; + this.cd.markForCheck(); + }), + delay(375), + ) + .subscribe(() => { + this.blockTransformation = {}; + }); this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { this.timeLtr = !!ltr; @@ -204,6 +223,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { this.networkSubscription.unsubscribe(); this.tabHiddenSubscription.unsubscribe(); this.markBlockSubscription.unsubscribe(); + this.blockDisplayModeSubscription.unsubscribe(); this.timeLtrSubscription.unsubscribe(); clearInterval(this.interval); } diff --git a/frontend/src/app/components/blockchain/blockchain.component.html b/frontend/src/app/components/blockchain/blockchain.component.html index 5f625e4b3..af3bf52b1 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.html +++ b/frontend/src/app/components/blockchain/blockchain.component.html @@ -10,6 +10,7 @@
+
diff --git a/frontend/src/app/components/blockchain/blockchain.component.scss b/frontend/src/app/components/blockchain/blockchain.component.scss index a8ecc6aba..b0a589a04 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.scss +++ b/frontend/src/app/components/blockchain/blockchain.component.scss @@ -67,9 +67,24 @@ padding: 0; } +.block-display-toggle { + color: white; + font-size: 0.8rem; + position: absolute; + bottom: 15.8em; + left: 1px; + transform: translateX(-50%) rotate(90deg); + background: none; + border: none; + outline: none; + margin: 0; + padding: 0; +} + .blockchain-wrapper.ltr-transition .blocks-wrapper, .blockchain-wrapper.ltr-transition .position-container, -.blockchain-wrapper.ltr-transition .time-toggle { +.blockchain-wrapper.ltr-transition .time-toggle, +.blockchain-wrapper.ltr-transition .block-display-toggle { transition: transform 1s; } @@ -81,6 +96,10 @@ .time-toggle { transform: translateX(-50%) scaleX(-1); } + + .block-display-toggle { + transform: translateX(-50%) scaleX(-1) rotate(90deg); + } } :host-context(.ltr-layout) { diff --git a/frontend/src/app/components/blockchain/blockchain.component.ts b/frontend/src/app/components/blockchain/blockchain.component.ts index 2293b9479..d70e788a2 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.ts +++ b/frontend/src/app/components/blockchain/blockchain.component.ts @@ -1,6 +1,7 @@ import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, Output, EventEmitter, ChangeDetectorRef, OnChanges, SimpleChanges } from '@angular/core'; import { firstValueFrom, Subscription } from 'rxjs'; import { StateService } from '../../services/state.service'; +import { StorageService } from '../../services/storage.service'; @Component({ selector: 'app-blockchain', @@ -26,15 +27,18 @@ export class BlockchainComponent implements OnInit, OnDestroy, OnChanges { connectionStateSubscription: Subscription; loadingTip: boolean = true; connected: boolean = true; + blockDisplayMode: 'size' | 'fees'; dividerOffset: number | null = null; mempoolOffset: number | null = null; positionStyle = { transform: "translateX(1280px)", }; + blockDisplayToggleStyle = {}; constructor( public stateService: StateService, + public StorageService: StorageService, private cd: ChangeDetectorRef, ) {} @@ -51,6 +55,7 @@ export class BlockchainComponent implements OnInit, OnDestroy, OnChanges { firstValueFrom(this.stateService.chainTip$).then(() => { this.loadingTip = false; }); + this.blockDisplayMode = this.StorageService.getValue('block-display-mode-preference') as 'size' | 'fees' || 'fees'; } ngOnDestroy(): void { @@ -84,6 +89,13 @@ export class BlockchainComponent implements OnInit, OnDestroy, OnChanges { }, 0); } + toggleBlockDisplayMode(): void { + if (this.blockDisplayMode === 'size') this.blockDisplayMode = 'fees'; + else this.blockDisplayMode = 'size'; + this.StorageService.setValue('block-display-mode-preference', this.blockDisplayMode); + this.stateService.blockDisplayMode$.next(this.blockDisplayMode); + } + onMempoolWidthChange(width): void { if (this.flipping) { return; diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html new file mode 100644 index 000000000..9180571a0 --- /dev/null +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html @@ -0,0 +1,270 @@ + +
+
+ @for (widget of widgets; track widget.component) { + @switch (widget.component) { + @case ('fees') { +
+
Transaction Fees
+
+
+ +
+
+
+ } + @case ('difficulty') { +
+ +
+ } + @case ('goggles') { +
+
+ +
+
+ } + @case ('incoming') { +
+
+
+ +
Incoming Transactions
+
+ +
+
+
+
+ +
+
+
Minimum fee
+
Purging
+

+ < +

+
+
+
Unconfirmed
+

+ {{ mempoolInfoData.value.memPoolInfo.size | number }} TXs +

+
+
+
Memory Usage
+
+
+
 
+
/
+
+
+
+
+
+ } + @case ('replacements') { +
+
+
+ +
Recent Replacements
+   + +
+ + + + + + + + + + + + + + + +
TXIDPrevious feeNew feeStatus
+ + + + + Mined + Full RBF + RBF +
+
+
+
+ + + +
+
+
+
+ + +
+ } + @case ('blocks') { +
+
+
+ +
Recent Blocks
+   + +
+ + + + + + + + + + + + + + + +
HeightMinedTXsSize
{{ block.height }}{{ block.tx_count | number }} +
+
 
+
+
+
+
+
+
+ + + +
+
+
+
+ + +
+ } + @case ('transactions') { +
+
+
+
Recent Transactions
+ + + + + + + + + + + + + + + +
TXIDAmount{{ currency }}Fee
+ + + + Confidential
+
 
+
+
+
+ + + +
+
+
+
+ + +
+ } + @case ('balance') { +
+
Treasury
+ +
+ } + @case ('address') { + + } + @case ('addressTransactions') { + + } + } + } +
+
+ + +
+
+ + + \ No newline at end of file diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.scss b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.scss new file mode 100644 index 000000000..4a9ffe94a --- /dev/null +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.scss @@ -0,0 +1,490 @@ +.dashboard-container { + text-align: center; + margin-top: 0.5rem; + .col { + margin-bottom: 1.5rem; + } +} + +.card { + background-color: var(--bg); + height: 100%; +} + +.card-title { + color: var(--title-fg); + font-size: 1rem; +} + +.info-block { + float: left; + width: 350px; + line-height: 25px; +} + +.progress { + display: inline-flex; + width: 100%; + background-color: var(--secondary); + height: 1.1rem; + max-width: 180px; +} + +.bg-warning { + background-color: #b58800 !important; +} + +.skeleton-loader { + max-width: 100%; +} + +.more-padding { + padding: 18px; +} + +.graph-card { + height: 100%; + @media (min-width: 768px) { + height: 415px; + } + @media (min-width: 992px) { + height: 510px; + } +} + +.mempool-info-data { + min-height: 56px; + display: block; + @media (min-width: 485px) { + display: flex; + flex-direction: row; + } + &.lbtc-pegs-stats { + display: flex; + flex-direction: row; + } + h5 { + margin-bottom: 10px; + } + .item { + width: 50%; + margin: 0px auto 20px; + display: inline-block; + @media (min-width: 485px) { + margin: 0px auto 10px; + } + @media (min-width: 768px) { + margin: 0px auto 0px; + } + &:last-child { + margin: 0px auto 0px; + } + &:nth-child(2) { + order: 2; + @media (min-width: 485px) { + order: 3; + } + } + &:nth-child(3) { + order: 3; + @media (min-width: 485px) { + order: 2; + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + .card-text { + font-size: 18px; + span { + color: var(--transparent-fg); + font-size: 12px; + } + .bitcoin-color { + color: var(--orange); + } + } + .progress { + width: 90%; + @media (min-width: 768px) { + width: 100%; + } + } + } + .bar { + width: 93%; + margin: 0px 5px 20px; + @media (min-width: 485px) { + max-width: 200px; + margin: 0px auto 0px; + } + } + .skeleton-loader { + width: 100%; + max-width: 100px; + display: block; + margin: 18px auto 0; + } + .skeleton-loader-big { + max-width: 180px; + } +} + +.latest-transactions { + width: 100%; + text-align: left; + table-layout:fixed; + tr, td, th { + border: 0px; + padding-top: 0.71rem !important; + padding-bottom: 0.75rem !important; + } + td { + overflow:hidden; + width: 25%; + } + .table-cell-satoshis { + display: none; + text-align: right; + @media (min-width: 576px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 1100px) { + display: table-cell; + } + } + .table-cell-fiat { + display: none; + text-align: right; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } + .table-cell-fees { + text-align: right; + } +} +.skeleton-loader-transactions { + max-width: 250px; + position: relative; + top: 2px; + margin-bottom: -3px; + height: 18px; +} + +.lastest-blocks-table { + width: 100%; + text-align: left; + tr, td, th { + border: 0px; + padding-top: 0.65rem !important; + padding-bottom: 0.7rem !important; + } + .table-cell-height { + width: 15%; + } + .table-cell-mined { + width: 35%; + text-align: left; + } + .table-cell-transaction-count { + display: none; + text-align: right; + width: 20%; + display: table-cell; + } + .table-cell-size { + display: none; + text-align: center; + width: 30%; + @media (min-width: 485px) { + display: table-cell; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: table-cell; + } + } +} + +.lastest-replacements-table { + width: 100%; + text-align: left; + table-layout:fixed; + tr, td, th { + border: 0px; + padding-top: 0.71rem !important; + padding-bottom: 0.75rem !important; + } + td { + overflow:hidden; + width: 25%; + } + .table-cell-txid { + width: 25%; + text-align: start; + } + .table-cell-old-fee { + width: 25%; + text-align: end; + + @media(max-width: 1080px) { + display: none; + } + } + .table-cell-new-fee { + width: 20%; + text-align: end; + } + .table-cell-badges { + width: 23%; + padding-right: 0; + padding-left: 5px; + text-align: end; + + .badge { + margin-left: 5px; + } + } +} + +.mempool-graph { + height: 255px; + @media (min-width: 768px) { + height: 285px; + } + @media (min-width: 992px) { + height: 370px; + } +} +.loadingGraphs{ + height: 250px; + display: grid; + place-items: center; +} + +.inc-tx-progress-bar { + max-width: 250px; + .progress-bar { + padding: 4px; + } +} + +.terms-of-service { + margin-top: 1rem; +} + +.small-bar { + height: 8px; + top: -4px; + max-width: 120px; +} + +.loading-container { + min-height: 76px; +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.card-wrapper { + .card { + height: auto !important; + } + .card-body { + display: flex; + flex: inherit; + text-align: center; + flex-direction: column; + justify-content: space-around; + padding: 22px 20px; + &.liquid { + height: 124.5px; + } + } + .less-padding { + padding: 20px 20px; + } +} + +.retarget-sign { + margin-right: -3px; + font-size: 14px; + top: -2px; + position: relative; +} + +.previous-retarget-sign { + margin-right: -2px; + font-size: 10px; +} + +.assetIcon { + width: 40px; + height: 40px; +} + +.asset-title { + text-align: left; + vertical-align: middle; +} + +.asset-icon { + width: 65px; + height: 65px; + vertical-align: middle; +} + +.circulating-amount { + text-align: right; + width: 100%; + vertical-align: middle; +} + +.clear-link { + color: white; +} + +.pool-name { + display: inline-block; + vertical-align: text-top; + padding-left: 10px; +} + +.title-link, .title-link:hover, .title-link:focus, .title-link:active { + display: block; + margin-bottom: 10px; + text-decoration: none; + color: inherit; +} + +.mempool-block-wrapper { + max-height: 410px; + max-width: 410px; + margin: auto; + + @media (min-width: 768px) { + max-height: 344px; + max-width: 344px; + } + @media (min-width: 992px) { + max-height: 410px; + max-width: 410px; + } +} + +.goggle-badge { + margin: 6px 5px 8px; + background: none; + border: solid 2px var(--primary); + cursor: pointer; + + &.active { + background: var(--primary); + } +} + +.btn-xs { + padding: 0.35rem 0.5rem; + font-size: 12px; +} + +.quick-filter { + margin-top: 5px; + margin-bottom: 6px; +} + +.card-liquid { + background-color: var(--bg); + height: 418px; + @media (min-width: 992px) { + height: 512px; + } + &.smaller { + height: 408px; + } +} + +.card-title-liquid { + padding-top: 20px; + margin-left: 10px; +} + +.in-progress-message { + position: relative; + color: #ffffff91; + margin-top: 20px; + text-align: center; + padding-bottom: 3px; + font-weight: 500; +} + +.stats-card { + min-height: 56px; + display: block; + @media (min-width: 485px) { + display: flex; + flex-direction: row; + } + h5 { + margin-bottom: 10px; + } + .item { + width: 50%; + display: inline-block; + margin: 0px auto 20px; + &:nth-child(2) { + order: 2; + @media (min-width: 485px) { + order: 3; + } + } + &:nth-child(3) { + order: 3; + @media (min-width: 485px) { + order: 2; + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + .card-title { + font-size: 1rem; + color: var(--title-fg); + } + .card-text { + font-size: 18px; + span { + color: var(--transparent-fg); + font-size: 12px; + } + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts new file mode 100644 index 000000000..2847b6586 --- /dev/null +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts @@ -0,0 +1,372 @@ +import { AfterViewInit, ChangeDetectionStrategy, Component, HostListener, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; +import { combineLatest, merge, Observable, of, Subject, Subscription } from 'rxjs'; +import { catchError, filter, map, scan, share, shareReplay, startWith, switchMap, tap } from 'rxjs/operators'; +import { BlockExtended, OptimizedMempoolStats, TransactionStripped } from '../../interfaces/node-api.interface'; +import { MempoolInfo, ReplacementInfo } from '../../interfaces/websocket.interface'; +import { ApiService } from '../../services/api.service'; +import { StateService } from '../../services/state.service'; +import { WebsocketService } from '../../services/websocket.service'; +import { SeoService } from '../../services/seo.service'; +import { ActiveFilter, FilterMode, GradientMode, toFlags } from '../../shared/filters.utils'; +import { detectWebGL } from '../../shared/graphs.utils'; +import { Address, AddressTxSummary } from '../../interfaces/electrs.interface'; +import { ElectrsApiService } from '../../services/electrs-api.service'; + +interface MempoolBlocksData { + blocks: number; + size: number; +} + +interface MempoolInfoData { + memPoolInfo: MempoolInfo; + vBytesPerSecond: number; + progressWidth: string; + progressColor: string; +} + +interface MempoolStatsData { + mempool: OptimizedMempoolStats[]; + weightPerSecond: any; +} + +@Component({ + selector: 'app-custom-dashboard', + templateUrl: './custom-dashboard.component.html', + styleUrls: ['./custom-dashboard.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewInit { + network$: Observable; + mempoolBlocksData$: Observable; + mempoolInfoData$: Observable; + mempoolLoadingStatus$: Observable; + vBytesPerSecondLimit = 1667; + transactions$: Observable; + blocks$: Observable; + replacements$: Observable; + latestBlockHeight: number; + mempoolTransactionsWeightPerSecondData: any; + mempoolStats$: Observable; + transactionsWeightPerSecondOptions: any; + isLoadingWebSocket$: Observable; + isLoad: boolean = true; + filterSubscription: Subscription; + mempoolInfoSubscription: Subscription; + currencySubscription: Subscription; + currency: string; + incomingGraphHeight: number = 300; + graphHeight: number = 300; + webGlEnabled = true; + + widgets; + + addressSubscription: Subscription; + blockTxSubscription: Subscription; + addressSummary$: Observable; + address: Address; + + goggleResolution = 82; + goggleCycle: { index: number, name: string, mode: FilterMode, filters: string[], gradient: GradientMode }[] = [ + { index: 0, name: $localize`:@@dfc3c34e182ea73c5d784ff7c8135f087992dac1:All`, mode: 'and', filters: [], gradient: 'age' }, + { index: 1, name: $localize`Consolidation`, mode: 'and', filters: ['consolidation'], gradient: 'fee' }, + { index: 2, name: $localize`Coinjoin`, mode: 'and', filters: ['coinjoin'], gradient: 'fee' }, + { index: 3, name: $localize`Data`, mode: 'or', filters: ['inscription', 'fake_pubkey', 'op_return'], gradient: 'fee' }, + ]; + goggleFlags = 0n; + goggleMode: FilterMode = 'and'; + gradientMode: GradientMode = 'age'; + goggleIndex = 0; + + private destroy$ = new Subject(); + + constructor( + public stateService: StateService, + private apiService: ApiService, + private electrsApiService: ElectrsApiService, + private websocketService: WebsocketService, + private seoService: SeoService, + @Inject(PLATFORM_ID) private platformId: Object, + ) { + this.webGlEnabled = this.stateService.isBrowser && detectWebGL(); + this.widgets = this.stateService.env.customize?.dashboard.widgets || []; + } + + ngAfterViewInit(): void { + this.stateService.focusSearchInputDesktop(); + } + + ngOnDestroy(): void { + this.filterSubscription.unsubscribe(); + this.mempoolInfoSubscription.unsubscribe(); + this.currencySubscription.unsubscribe(); + this.websocketService.stopTrackRbfSummary(); + if (this.addressSubscription) { + this.addressSubscription.unsubscribe(); + this.websocketService.stopTrackingAddress(); + this.address = null; + } + this.destroy$.next(1); + this.destroy$.complete(); + } + + ngOnInit(): void { + this.onResize(); + this.isLoadingWebSocket$ = this.stateService.isLoadingWebSocket$; + this.seoService.resetTitle(); + this.seoService.resetDescription(); + this.websocketService.want(['blocks', 'stats', 'mempool-blocks', 'live-2h-chart']); + this.websocketService.startTrackRbfSummary(); + this.network$ = merge(of(''), this.stateService.networkChanged$); + this.mempoolLoadingStatus$ = this.stateService.loadingIndicators$ + .pipe( + map((indicators) => indicators.mempool !== undefined ? indicators.mempool : 100) + ); + + this.filterSubscription = this.stateService.activeGoggles$.subscribe((active: ActiveFilter) => { + const activeFilters = active.filters.sort().join(','); + for (const goggle of this.goggleCycle) { + if (goggle.mode === active.mode) { + const goggleFilters = goggle.filters.sort().join(','); + if (goggleFilters === activeFilters) { + this.goggleIndex = goggle.index; + this.goggleFlags = toFlags(goggle.filters); + this.goggleMode = goggle.mode; + this.gradientMode = active.gradient; + return; + } + } + } + this.goggleCycle.push({ + index: this.goggleCycle.length, + name: 'Custom', + mode: active.mode, + filters: active.filters, + gradient: active.gradient, + }); + this.goggleIndex = this.goggleCycle.length - 1; + this.goggleFlags = toFlags(active.filters); + this.goggleMode = active.mode; + }); + + this.mempoolInfoData$ = combineLatest([ + this.stateService.mempoolInfo$, + this.stateService.vbytesPerSecond$ + ]).pipe( + map(([mempoolInfo, vbytesPerSecond]) => { + const percent = Math.round((Math.min(vbytesPerSecond, this.vBytesPerSecondLimit) / this.vBytesPerSecondLimit) * 100); + + let progressColor = 'bg-success'; + if (vbytesPerSecond > 1667) { + progressColor = 'bg-warning'; + } + if (vbytesPerSecond > 3000) { + progressColor = 'bg-danger'; + } + + const mempoolSizePercentage = (mempoolInfo.usage / mempoolInfo.maxmempool * 100); + let mempoolSizeProgress = 'bg-danger'; + if (mempoolSizePercentage <= 50) { + mempoolSizeProgress = 'bg-success'; + } else if (mempoolSizePercentage <= 75) { + mempoolSizeProgress = 'bg-warning'; + } + + return { + memPoolInfo: mempoolInfo, + vBytesPerSecond: vbytesPerSecond, + progressWidth: percent + '%', + progressColor: progressColor, + mempoolSizeProgress: mempoolSizeProgress, + }; + }) + ); + + this.mempoolInfoSubscription = this.mempoolInfoData$.subscribe(); + + this.mempoolBlocksData$ = this.stateService.mempoolBlocks$ + .pipe( + map((mempoolBlocks) => { + const size = mempoolBlocks.map((m) => m.blockSize).reduce((a, b) => a + b, 0); + const vsize = mempoolBlocks.map((m) => m.blockVSize).reduce((a, b) => a + b, 0); + + return { + size: size, + blocks: Math.ceil(vsize / this.stateService.blockVSize) + }; + }) + ); + + this.transactions$ = this.stateService.transactions$; + + this.blocks$ = this.stateService.blocks$ + .pipe( + tap((blocks) => { + this.latestBlockHeight = blocks[0].height; + }), + switchMap((blocks) => { + if (this.stateService.env.MINING_DASHBOARD === true) { + for (const block of blocks) { + // @ts-ignore: Need to add an extra field for the template + block.extras.pool.logo = `/resources/mining-pools/` + + block.extras.pool.slug + '.svg'; + } + } + return of(blocks.slice(0, 6)); + }) + ); + + this.replacements$ = this.stateService.rbfLatestSummary$; + + this.mempoolStats$ = this.stateService.connectionState$ + .pipe( + filter((state) => state === 2), + switchMap(() => this.apiService.list2HStatistics$().pipe( + catchError((e) => { + return of(null); + }) + )), + switchMap((mempoolStats) => { + return merge( + this.stateService.live2Chart$ + .pipe( + scan((acc, stats) => { + acc.unshift(stats); + acc = acc.slice(0, 120); + return acc; + }, (mempoolStats || [])) + ), + of(mempoolStats) + ); + }), + map((mempoolStats) => { + if (mempoolStats) { + return { + mempool: mempoolStats, + weightPerSecond: this.handleNewMempoolData(mempoolStats.concat([])), + }; + } else { + return null; + } + }), + shareReplay(1), + ); + + this.currencySubscription = this.stateService.fiatCurrency$.subscribe((fiat) => { + this.currency = fiat; + }); + + this.startAddressSubscription(); + } + + handleNewMempoolData(mempoolStats: OptimizedMempoolStats[]) { + mempoolStats.reverse(); + const labels = mempoolStats.map(stats => stats.added); + + return { + labels: labels, + series: [mempoolStats.map((stats) => [stats.added * 1000, stats.vbytes_per_second])], + }; + } + + trackByBlock(index: number, block: BlockExtended) { + return block.height; + } + + getArrayFromNumber(num: number): number[] { + return Array.from({ length: num }, (_, i) => i + 1); + } + + setFilter(index): void { + const selected = this.goggleCycle[index]; + this.stateService.activeGoggles$.next(selected); + } + + startAddressSubscription(): void { + if (this.stateService.env.customize && this.stateService.env.customize.dashboard.widgets.some(w => w.props?.address)) { + const address = this.stateService.env.customize.dashboard.widgets.find(w => w.props?.address).props.address; + const addressString = (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}$/.test(address)) ? address.toLowerCase() : address; + + this.addressSubscription = ( + addressString.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/) + ? this.electrsApiService.getPubKeyAddress$(addressString) + : this.electrsApiService.getAddress$(addressString) + ).pipe( + catchError((err) => { + console.log(err); + return of(null); + }), + filter((address) => !!address), + ).subscribe((address: Address) => { + this.websocketService.startTrackAddress(address.address); + this.address = address; + }); + + this.addressSummary$ = ( + addressString.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/) + ? this.electrsApiService.getScriptHashSummary$((addressString.length === 66 ? '21' : '41') + addressString + 'ac') + : this.electrsApiService.getAddressSummary$(addressString)).pipe( + catchError(e => { + return of(null); + }), + switchMap(initial => this.stateService.blockTransactions$.pipe( + startWith(null), + scan((summary, tx) => { + if (tx && !summary.some(t => t.txid === tx.txid)) { + let value = 0; + let funded = 0; + let fundedCount = 0; + let spent = 0; + let spentCount = 0; + for (const vout of tx.vout) { + if (vout.scriptpubkey_address === addressString) { + value += vout.value; + funded += vout.value; + fundedCount++; + } + } + for (const vin of tx.vin) { + if (vin.prevout?.scriptpubkey_address === addressString) { + value -= vin.prevout?.value; + spent += vin.prevout?.value; + spentCount++; + } + } + if (this.address && this.address.address === addressString) { + this.address.chain_stats.tx_count++; + this.address.chain_stats.funded_txo_sum += funded; + this.address.chain_stats.funded_txo_count += fundedCount; + this.address.chain_stats.spent_txo_sum += spent; + this.address.chain_stats.spent_txo_count += spentCount; + } + summary.unshift({ + txid: tx.txid, + time: tx.status?.block_time, + height: tx.status?.block_height, + value + }); + } + return summary; + }, initial) + )), + share(), + ); + } + } + + @HostListener('window:resize', ['$event']) + onResize(): void { + if (window.innerWidth >= 992) { + this.incomingGraphHeight = 300; + this.goggleResolution = 82; + this.graphHeight = 400; + } else if (window.innerWidth >= 768) { + this.incomingGraphHeight = 215; + this.goggleResolution = 80; + this.graphHeight = 310; + } else { + this.incomingGraphHeight = 180; + this.goggleResolution = 86; + this.graphHeight = 310; + } + } +} diff --git a/frontend/src/app/components/language-selector/language-selector.component.html b/frontend/src/app/components/language-selector/language-selector.component.html index bfd36af77..adc771c83 100644 --- a/frontend/src/app/components/language-selector/language-selector.component.html +++ b/frontend/src/app/components/language-selector/language-selector.component.html @@ -1,5 +1,5 @@
-
diff --git a/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.ts b/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.ts index e245e01ea..e41c49643 100644 --- a/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.ts +++ b/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.ts @@ -122,8 +122,8 @@ export class FederationUtxosListComponent implements OnInit { getGradientColor(value: number): string { const distanceToGreen = Math.abs(4032 - value); - const green = 'var(--green)'; - const red = 'var(--red)'; + const green = '#3bcc49'; + const red = '#dc3545'; if (value < 0) { return red; diff --git a/frontend/src/app/components/liquid-reserves-audit/reserves-ratio-stats/reserves-ratio-stats.component.html b/frontend/src/app/components/liquid-reserves-audit/reserves-ratio-stats/reserves-ratio-stats.component.html index 49ba80d0c..18fdf6319 100644 --- a/frontend/src/app/components/liquid-reserves-audit/reserves-ratio-stats/reserves-ratio-stats.component.html +++ b/frontend/src/app/components/liquid-reserves-audit/reserves-ratio-stats/reserves-ratio-stats.component.html @@ -3,7 +3,7 @@
Unpeg
-
+
{{ unbackedMonths.total }} Unpeg Event
diff --git a/frontend/src/app/components/liquid-reserves-audit/reserves-ratio-stats/reserves-ratio-stats.component.ts b/frontend/src/app/components/liquid-reserves-audit/reserves-ratio-stats/reserves-ratio-stats.component.ts index 7fbac3144..e2574b686 100644 --- a/frontend/src/app/components/liquid-reserves-audit/reserves-ratio-stats/reserves-ratio-stats.component.ts +++ b/frontend/src/app/components/liquid-reserves-audit/reserves-ratio-stats/reserves-ratio-stats.component.ts @@ -34,7 +34,7 @@ export class ReservesRatioStatsComponent implements OnInit { let avg = 0; for (let i = 0; i < ratioSeries.length; i++) { avg += ratioSeries[i]; - if (ratioSeries[i] < 1) { + if (ratioSeries[i] < 0.95) { total++; } } diff --git a/frontend/src/app/components/liquid-reserves-audit/reserves-ratio/reserves-ratio.component.ts b/frontend/src/app/components/liquid-reserves-audit/reserves-ratio/reserves-ratio.component.ts index 471bffea6..45cd63db0 100644 --- a/frontend/src/app/components/liquid-reserves-audit/reserves-ratio/reserves-ratio.component.ts +++ b/frontend/src/app/components/liquid-reserves-audit/reserves-ratio/reserves-ratio.component.ts @@ -85,6 +85,7 @@ export class ReservesRatioComponent implements OnInit, OnChanges { { type: 'gauge', startAngle: 180, + silent: true, endAngle: 0, center: ['50%', '75%'], radius: '100%', diff --git a/frontend/src/app/components/master-page-preview/master-page-preview.component.html b/frontend/src/app/components/master-page-preview/master-page-preview.component.html index 4c436607b..36e8eed10 100644 --- a/frontend/src/app/components/master-page-preview/master-page-preview.component.html +++ b/frontend/src/app/components/master-page-preview/master-page-preview.component.html @@ -2,8 +2,15 @@
- - + @if (enterpriseInfo?.img) { + + } + @if (enterpriseInfo?.header_img) { + enterpriseInfo.title + } @else { + + + }
diff --git a/frontend/src/app/components/master-page-preview/master-page-preview.component.scss b/frontend/src/app/components/master-page-preview/master-page-preview.component.scss index 486922dfa..fb0fd5c24 100644 --- a/frontend/src/app/components/master-page-preview/master-page-preview.component.scss +++ b/frontend/src/app/components/master-page-preview/master-page-preview.component.scss @@ -18,7 +18,7 @@ flex-direction: row; justify-content: space-between; align-items: center; - background: var(--active-bg); + background: var(--stat-box-bg); text-align: start; font-size: 1.8em; } @@ -77,3 +77,15 @@ flex-shrink: 1; } } + +.subdomain_logo { + height: 35px; + overflow: clip; + max-width: 140px; + margin: auto; + align-self: center; + margin-right: 1em; + .rounded { + border-radius: 5px; + } +} diff --git a/frontend/src/app/components/master-page-preview/master-page-preview.component.ts b/frontend/src/app/components/master-page-preview/master-page-preview.component.ts index 03a6a1ebb..64bdcfda2 100644 --- a/frontend/src/app/components/master-page-preview/master-page-preview.component.ts +++ b/frontend/src/app/components/master-page-preview/master-page-preview.component.ts @@ -1,7 +1,8 @@ import { Component, OnInit } from '@angular/core'; import { StateService } from '../../services/state.service'; -import { Observable, merge, of } from 'rxjs'; +import { Observable, Subscription, merge, of } from 'rxjs'; import { LanguageService } from '../../services/language.service'; +import { EnterpriseService } from '../../services/enterprise.service'; @Component({ selector: 'app-master-page-preview', @@ -13,15 +14,23 @@ export class MasterPagePreviewComponent implements OnInit { lightning$: Observable; officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE; urlLanguage: string; + subdomain = ''; + enterpriseInfo: any; + enterpriseInfo$: Subscription; constructor( public stateService: StateService, private languageService: LanguageService, + private enterpriseService: EnterpriseService, ) { } ngOnInit() { this.network$ = merge(of(''), this.stateService.networkChanged$); this.lightning$ = this.stateService.lightningChanged$; this.urlLanguage = this.languageService.getLanguageForUrl(); + this.subdomain = this.enterpriseService.getSubdomain(); + this.enterpriseInfo$ = this.enterpriseService.info$.subscribe(info => { + this.enterpriseInfo = info; + }); } } diff --git a/frontend/src/app/components/master-page/master-page.component.html b/frontend/src/app/components/master-page/master-page.component.html index f55a05fac..5da892f4a 100644 --- a/frontend/src/app/components/master-page/master-page.component.html +++ b/frontend/src/app/components/master-page/master-page.component.html @@ -19,13 +19,17 @@
- +
- - + @if (enterpriseInfo?.header_img) { + enterpriseInfo.title + } @else { + + + }
Offline
Reconnecting...
@@ -36,14 +40,18 @@
- +
- - -
+ @if (enterpriseInfo?.header_img) { + enterpriseInfo.title + } @else { + + + } +
Offline
Reconnecting...
diff --git a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts index 4d01bd9b9..3cea7e123 100644 --- a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts @@ -1,11 +1,10 @@ -import { Component, ComponentRef, ViewChild, HostListener, Input, Output, EventEmitter, +import { Component, ViewChild, Input, Output, EventEmitter, OnInit, OnDestroy, OnChanges, ChangeDetectionStrategy, ChangeDetectorRef, AfterViewInit } from '@angular/core'; import { StateService } from '../../services/state.service'; -import { MempoolBlockDelta } from '../../interfaces/websocket.interface'; +import { MempoolBlockDelta, isMempoolDelta } from '../../interfaces/websocket.interface'; import { TransactionStripped } from '../../interfaces/node-api.interface'; import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; -import { Subscription, BehaviorSubject, merge, of, timer } from 'rxjs'; -import { switchMap, filter, concatMap, map } from 'rxjs/operators'; +import { Subscription, BehaviorSubject } from 'rxjs'; import { WebsocketService } from '../../services/websocket.service'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { Router } from '@angular/router'; @@ -39,10 +38,6 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang poolDirection: string = 'left'; blockSub: Subscription; - rateLimit = 1000; - private lastEventTime = Date.now() - this.rateLimit; - private subId = 0; - firstLoad: boolean = true; constructor( @@ -62,39 +57,13 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang } ngAfterViewInit(): void { - this.blockSub = merge( - this.stateService.mempoolBlockTransactions$, - this.stateService.mempoolBlockDelta$, - ).pipe( - concatMap(update => { - const now = Date.now(); - const timeSinceLastEvent = now - this.lastEventTime; - this.lastEventTime = Math.max(now, this.lastEventTime + this.rateLimit); - - const subId = this.subId; - - // If time since last event is less than X seconds, delay this event - if (timeSinceLastEvent < this.rateLimit) { - return timer(this.rateLimit - timeSinceLastEvent).pipe( - // Emit the event after the timer - map(() => ({ update, subId })) - ); - } else { - // If enough time has passed, emit the event immediately - return of({ update, subId }); - } - }) - ).subscribe(({ update, subId }) => { - // discard stale updates after a block transition - if (subId !== this.subId) { - return; - } + this.blockSub = this.stateService.mempoolBlockUpdate$.subscribe((update) => { // process update - if (update['added']) { + if (isMempoolDelta(update)) { // delta - this.updateBlock(update as MempoolBlockDelta); + this.updateBlock(update); } else { - const transactionsStripped = update as TransactionStripped[]; + const transactionsStripped = update.transactions; // new transactions if (this.firstLoad) { this.replaceBlock(transactionsStripped); @@ -137,7 +106,6 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang ngOnChanges(changes): void { if (changes.index) { - this.subId++; this.firstLoad = true; if (this.blockGraph) { this.blockGraph.clear(changes.index.currentValue > changes.index.previousValue ? this.chainDirection : this.poolDirection); @@ -173,7 +141,11 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang const direction = (this.blockIndex == null || this.index < this.blockIndex) ? this.poolDirection : this.chainDirection; this.blockGraph.replace(delta.added, direction); } else { - this.blockGraph.update(delta.added, delta.removed, delta.changed || [], blockMined ? this.chainDirection : this.poolDirection, blockMined); + if (blockMined) { + this.blockGraph.update(delta.added, delta.removed, delta.changed || [], blockMined ? this.chainDirection : this.poolDirection, blockMined); + } else { + this.blockGraph.queueUpdate(delta.added, delta.removed, delta.changed || [], this.poolDirection); + } } this.lastBlockHeight = this.stateService.latestBlockHeight; diff --git a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html index aa2043af2..24f229598 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html @@ -7,7 +7,7 @@ class="spotlight-bottom" [style.right]="mempoolBlockStyles[i].right" >
-
+
 
@@ -20,7 +20,7 @@ -
-
+
diff --git a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts index f403eac71..dee770cd8 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts @@ -1,9 +1,9 @@ import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, HostListener, Input, OnChanges, SimpleChanges, Output, EventEmitter } from '@angular/core'; -import { Subscription, Observable, of, combineLatest, BehaviorSubject } from 'rxjs'; +import { Subscription, Observable, of, combineLatest } from 'rxjs'; import { MempoolBlock } from '../../interfaces/websocket.interface'; import { StateService } from '../../services/state.service'; import { Router } from '@angular/router'; -import { map, switchMap, tap } from 'rxjs/operators'; +import { delay, filter, map, switchMap, tap } from 'rxjs/operators'; import { feeLevels } from '../../app.constants'; import { specialBlocks } from '../../app.constants'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; @@ -43,7 +43,10 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { mempoolBlocks$: Observable; difficultyAdjustments$: Observable; loadingBlocks$: Observable; - showMiningInfo$: BehaviorSubject = new BehaviorSubject(false); + showMiningInfoSubscription: Subscription; + blockDisplayModeSubscription: Subscription; + blockDisplayMode: 'size' | 'fees'; + blockTransformation = {}; blocksSubscription: Subscription; mempoolBlocksFull: MempoolBlock[] = []; @@ -99,9 +102,29 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { this.mempoolWidth = width; this.widthChange.emit(this.mempoolWidth); - if (['', 'testnet', 'signet'].includes(this.stateService.network)) { - this.showMiningInfo$ = this.stateService.showMiningInfo$; - } + this.blockDisplayMode = this.stateService.blockDisplayMode$.value as 'size' | 'fees'; + this.blockDisplayModeSubscription = this.stateService.blockDisplayMode$ + .pipe( + filter((mode: 'size' | 'fees') => mode !== this.blockDisplayMode), + tap(() => { + this.blockTransformation = { + transform: 'rotateX(90deg)', + transition: 'transform 0.375s' + }; + }), + delay(375), + tap((mode) => { + this.blockDisplayMode = mode; + this.blockTransformation = { + transition: 'transform 0.375s' + }; + this.cd.markForCheck(); + }), + delay(375), + ) + .subscribe(() => { + this.blockTransformation = {}; + }); this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { this.timeLtr = !this.forceRtl && !!ltr; @@ -262,6 +285,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { this.markBlocksSubscription.unsubscribe(); this.blockSubscription.unsubscribe(); this.networkSubscription.unsubscribe(); + this.blockDisplayModeSubscription.unsubscribe(); this.timeLtrSubscription.unsubscribe(); this.chainTipSubscription.unsubscribe(); this.keySubscription.unsubscribe(); diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts index 687a7dbfd..83654a137 100644 --- a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts @@ -51,7 +51,7 @@ export class RbfTimelineComponent implements OnInit, OnChanges { ngOnChanges(changes): void { this.rows = this.buildTimelines(this.replacements); - if (changes.txid) { + if (changes.txid && !changes.txid.firstChange && changes.txid.previousValue !== changes.txid.currentValue) { setTimeout(() => { this.scrollToSelected(); }); } } diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 3c9b895d9..b1b553294 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -81,6 +81,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { mempoolBlocksSubscription: Subscription; blocksSubscription: Subscription; miningSubscription: Subscription; + auditSubscription: Subscription; currencyChangeSubscription: Subscription; fragmentParams: URLSearchParams; rbfTransaction: undefined | Transaction; @@ -308,51 +309,57 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { filter((target) => target.txid === this.txId), tap(() => { this.pool = null; - this.auditStatus = null; }), - switchMap(({ hash, height, txid }) => { + switchMap(({ hash, height }) => { const foundBlock = this.cacheService.getCachedBlock(height) || null; - const auditAvailable = this.isAuditAvailable(height); - const isCoinbase = this.tx.vin.some(v => v.is_coinbase); - const fetchAudit = auditAvailable && !isCoinbase; - return combineLatest([ - foundBlock ? of(foundBlock.extras.pool) : this.apiService.getBlock$(hash).pipe( - map(block => { - return block.extras.pool; - }), - retry({ count: 3, delay: 2000 }), - catchError(() => { - return of(null); - }) - ), - fetchAudit ? this.apiService.getBlockAudit$(hash).pipe( - map(audit => { - const isAdded = audit.addedTxs.includes(txid); - const isPrioritized = audit.prioritizedTxs.includes(txid); - const isAccelerated = audit.acceleratedTxs.includes(txid); - const isConflict = audit.fullrbfTxs.includes(txid); - const isExpected = audit.template.some(tx => tx.txid === txid); - return { - seen: isExpected || isPrioritized || isAccelerated, - expected: isExpected, - added: isAdded, - prioritized: isPrioritized, - conflict: isConflict, - accelerated: isAccelerated, - }; - }), - retry({ count: 3, delay: 2000 }), - catchError(() => { - return of(null); - }) - ) : of(isCoinbase ? { coinbase: true } : null) - ]); + return foundBlock ? of(foundBlock.extras.pool) : this.apiService.getBlock$(hash).pipe( + map(block => block.extras.pool), + retry({ count: 3, delay: 2000 }), + catchError(() => of(null)) + ); }), catchError((e) => { return of(null); }) - ).subscribe(([pool, auditStatus]) => { + ).subscribe(pool => { this.pool = pool; + }); + + this.auditSubscription = this.fetchMiningInfo$.pipe( + filter((target) => target.txid === this.txId), + tap(() => { + this.auditStatus = null; + }), + switchMap(({ hash, height, txid }) => { + const auditAvailable = this.isAuditAvailable(height); + const isCoinbase = this.tx.vin.some(v => v.is_coinbase); + const fetchAudit = auditAvailable && !isCoinbase; + return fetchAudit ? this.apiService.getBlockAudit$(hash).pipe( + map(audit => { + const isAdded = audit.addedTxs.includes(txid); + const isPrioritized = audit.prioritizedTxs.includes(txid); + const isAccelerated = audit.acceleratedTxs.includes(txid); + const isConflict = audit.fullrbfTxs.includes(txid); + const isExpected = audit.template.some(tx => tx.txid === txid); + return { + seen: isExpected || isPrioritized || isAccelerated, + expected: isExpected, + added: isAdded, + prioritized: isPrioritized, + conflict: isConflict, + accelerated: isAccelerated, + }; + }), + retry({ count: 3, delay: 2000 }), + catchError(() => { + return of(null); + }) + ) : of(isCoinbase ? { coinbase: true } : null); + }), + catchError((e) => { + return of(null); + }) + ).subscribe(auditStatus => { this.auditStatus = auditStatus; this.setIsAccelerated(); @@ -858,6 +865,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.mempoolBlocksSubscription.unsubscribe(); this.blocksSubscription.unsubscribe(); this.miningSubscription?.unsubscribe(); + this.auditSubscription?.unsubscribe(); this.currencyChangeSubscription?.unsubscribe(); this.leaveTransaction(); } diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index b66889f0b..fc2afefc6 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -10,6 +10,7 @@ import { filter, map, tap, switchMap, shareReplay, catchError } from 'rxjs/opera import { BlockExtended } from '../../interfaces/node-api.interface'; import { ApiService } from '../../services/api.service'; import { PriceService } from '../../services/price.service'; +import { StorageService } from '../../services/storage.service'; @Component({ selector: 'app-transactions-list', @@ -56,6 +57,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { private assetsService: AssetsService, private ref: ChangeDetectorRef, private priceService: PriceService, + private storageService: StorageService, ) { } ngOnInit(): void { @@ -271,8 +273,11 @@ export class TransactionsListComponent implements OnInit, OnChanges { if (this.network === 'liquid' || this.network === 'liquidtestnet') { return; } - const oldvalue = !this.stateService.viewFiat$.value; - this.stateService.viewFiat$.next(oldvalue); + const modes = ['btc', 'sats', 'fiat']; + const oldIndex = modes.indexOf(this.stateService.viewAmountMode$.value); + const newIndex = (oldIndex + 1) % modes.length; + this.stateService.viewAmountMode$.next(modes[newIndex] as 'btc' | 'sats' | 'fiat'); + this.storageService.setValue('view-amount-mode', modes[newIndex]); } trackByFn(index: number, tx: Transaction): string { diff --git a/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.ts b/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.ts index ff1e86596..9132cc653 100644 --- a/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.ts +++ b/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.ts @@ -63,7 +63,7 @@ export class TxBowtieGraphTooltipComponent implements OnChanges { this.blockConversions = {}; this.inputStatus = {}; }); - this.viewFiatSubscription = this.stateService.viewFiat$.subscribe(viewFiat => this.viewFiat = viewFiat); + this.viewFiatSubscription = this.stateService.viewAmountMode$.subscribe(viewFiat => this.viewFiat = viewFiat === 'fiat'); this.chainTipSubscription = this.stateService.chainTip$.subscribe(tip => this.chainTip = tip); } diff --git a/frontend/src/app/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts index 761bd8e1f..83aebed73 100644 --- a/frontend/src/app/graphs/graphs.module.ts +++ b/frontend/src/app/graphs/graphs.module.ts @@ -27,6 +27,7 @@ import { PoolRankingComponent } from '../components/pool-ranking/pool-ranking.co import { PoolComponent } from '../components/pool/pool.component'; import { TelevisionComponent } from '../components/television/television.component'; import { DashboardComponent } from '../dashboard/dashboard.component'; +import { CustomDashboardComponent } from '../components/custom-dashboard/custom-dashboard.component'; import { MiningDashboardComponent } from '../components/mining-dashboard/mining-dashboard.component'; import { AcceleratorDashboardComponent } from '../components/acceleration/accelerator-dashboard/accelerator-dashboard.component'; import { HashrateChartComponent } from '../components/hashrate-chart/hashrate-chart.component'; @@ -39,6 +40,7 @@ import { CommonModule } from '@angular/common'; @NgModule({ declarations: [ DashboardComponent, + CustomDashboardComponent, MempoolBlockComponent, AddressComponent, diff --git a/frontend/src/app/graphs/graphs.routing.module.ts b/frontend/src/app/graphs/graphs.routing.module.ts index e069022cd..9c7d55930 100644 --- a/frontend/src/app/graphs/graphs.routing.module.ts +++ b/frontend/src/app/graphs/graphs.routing.module.ts @@ -17,10 +17,16 @@ import { StartComponent } from '../components/start/start.component'; import { StatisticsComponent } from '../components/statistics/statistics.component'; import { TelevisionComponent } from '../components/television/television.component'; import { DashboardComponent } from '../dashboard/dashboard.component'; +import { CustomDashboardComponent } from '../components/custom-dashboard/custom-dashboard.component'; import { AccelerationFeesGraphComponent } from '../components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component'; import { AccelerationsListComponent } from '../components/acceleration/accelerations-list/accelerations-list.component'; import { AddressComponent } from '../components/address/address.component'; +const browserWindow = window || {}; +// @ts-ignore +const browserWindowEnv = browserWindow.__env || {}; +const isCustomized = browserWindowEnv?.customize; + const routes: Routes = [ { path: '', @@ -149,7 +155,7 @@ const routes: Routes = [ component: StartComponent, children: [{ path: '', - component: DashboardComponent, + component: isCustomized ? CustomDashboardComponent : DashboardComponent, }] }, ] diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index daf06603f..8c24979e7 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -75,6 +75,16 @@ export interface MempoolBlockDelta { removed: string[]; changed: { txid: string, rate: number, flags: number, acc: boolean }[]; } +export interface MempoolBlockState { + transactions: TransactionStripped[]; +} +export type MempoolBlockUpdate = MempoolBlockDelta | MempoolBlockState; +export function isMempoolState(update: MempoolBlockUpdate): update is MempoolBlockState { + return update['transactions'] !== undefined; +} +export function isMempoolDelta(update: MempoolBlockUpdate): update is MempoolBlockDelta { + return update['transactions'] === undefined; +} export interface MempoolBlockDeltaCompressed { added: TransactionCompressed[]; diff --git a/frontend/src/app/services/enterprise.service.ts b/frontend/src/app/services/enterprise.service.ts index 4ad31bd9f..5de57b059 100644 --- a/frontend/src/app/services/enterprise.service.ts +++ b/frontend/src/app/services/enterprise.service.ts @@ -23,7 +23,7 @@ export class EnterpriseService { private stateService: StateService, private activatedRoute: ActivatedRoute, ) { - const subdomain = this.document.location.hostname.indexOf(this.exclusiveHostName) > -1 + const subdomain = this.stateService.env.customize?.enterprise || this.document.location.hostname.indexOf(this.exclusiveHostName) > -1 && this.document.location.hostname.split(this.exclusiveHostName)[0] || false; if (subdomain && subdomain.match(/^[A-z0-9-_]+$/)) { this.subdomain = subdomain; @@ -47,16 +47,23 @@ export class EnterpriseService { } fetchSubdomainInfo(): void { - this.apiService.getEnterpriseInfo$(this.subdomain).subscribe((info) => { + if (this.stateService.env.customize?.branding) { + const info = this.stateService.env.customize?.branding; this.insertMatomo(info.site_id); - this.seoService.setEnterpriseTitle(info.title); + this.seoService.setEnterpriseTitle(info.title, true); this.info$.next(info); - }, - (error) => { - if (error.status === 404) { - window.location.href = 'https://mempool.space' + window.location.pathname; - } - }); + } else { + this.apiService.getEnterpriseInfo$(this.subdomain).subscribe((info) => { + this.insertMatomo(info.site_id); + this.seoService.setEnterpriseTitle(info.title); + this.info$.next(info); + }, + (error) => { + if (error.status === 404) { + window.location.href = 'https://mempool.space' + window.location.pathname; + } + }); + } } insertMatomo(siteId?: number): void { diff --git a/frontend/src/app/services/seo.service.ts b/frontend/src/app/services/seo.service.ts index 45d62ebdd..008cdc9bc 100644 --- a/frontend/src/app/services/seo.service.ts +++ b/frontend/src/app/services/seo.service.ts @@ -50,8 +50,12 @@ export class SeoService { this.metaService.updateTag({ property: 'og:meta:ready', content: 'ready'}); } - setEnterpriseTitle(title: string) { - this.baseTitle = title + ' - ' + this.baseTitle; + setEnterpriseTitle(title: string, override: boolean = false) { + if (override) { + this.baseTitle = title; + } else { + this.baseTitle = title + ' - ' + this.baseTitle; + } this.resetTitle(); } diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 970bfcac3..b939574b5 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core'; import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable, merge } from 'rxjs'; import { Transaction } from '../interfaces/electrs.interface'; -import { HealthCheckHost, IBackendInfo, MempoolBlock, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo } from '../interfaces/websocket.interface'; +import { HealthCheckHost, IBackendInfo, MempoolBlock, MempoolBlockDelta, MempoolBlockUpdate, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, isMempoolState } from '../interfaces/websocket.interface'; import { BlockExtended, CpfpInfo, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree, TransactionStripped } from '../interfaces/node-api.interface'; import { Router, NavigationStart } from '@angular/router'; import { isPlatformBrowser } from '@angular/common'; @@ -20,6 +20,24 @@ export interface MarkBlockState { export interface ILoadingIndicators { [name: string]: number; } +export interface Customization { + theme: string; + enterprise?: string; + branding: { + name: string; + site_id?: number; + title: string; + img: string; + rounded_corner: boolean; + }, + dashboard: { + widgets: { + component: string; + props: { [key: string]: any }; + }[]; + }; +} + export interface Env { TESTNET_ENABLED: boolean; SIGNET_ENABLED: boolean; @@ -50,6 +68,7 @@ export interface Env { ADDITIONAL_CURRENCIES: boolean; GIT_COMMIT_HASH_MEMPOOL_SPACE?: string; PACKAGE_JSON_VERSION_MEMPOOL_SPACE?: string; + customize?: Customization; } const defaultEnv: Env = { @@ -108,8 +127,7 @@ export class StateService { bsqPrice$ = new ReplaySubject(1); mempoolInfo$ = new ReplaySubject(1); mempoolBlocks$ = new ReplaySubject(1); - mempoolBlockTransactions$ = new Subject(); - mempoolBlockDelta$ = new Subject(); + mempoolBlockUpdate$ = new Subject(); liveMempoolBlockTransactions$: Observable<{ [txid: string]: TransactionStripped}>; txConfirmed$ = new Subject<[string, BlockExtended]>(); txReplaced$ = new Subject(); @@ -136,7 +154,7 @@ export class StateService { live2Chart$ = new Subject(); - viewFiat$ = new BehaviorSubject(false); + viewAmountMode$: BehaviorSubject<'btc' | 'sats' | 'fiat'>; connectionState$ = new BehaviorSubject<0 | 1 | 2>(2); isTabHidden$: Observable; @@ -151,7 +169,7 @@ export class StateService { hideAudit: BehaviorSubject; fiatCurrency$: BehaviorSubject; rateUnits$: BehaviorSubject; - showMiningInfo$: BehaviorSubject = new BehaviorSubject(false); + blockDisplayMode$: BehaviorSubject; searchFocus$: Subject = new Subject(); menuOpen$: BehaviorSubject = new BehaviorSubject(false); @@ -196,25 +214,25 @@ export class StateService { this.router.navigate(['/tracker/' + window.location.pathname.slice(4)]); } - this.liveMempoolBlockTransactions$ = merge( - this.mempoolBlockTransactions$.pipe(map(transactions => { return { transactions }; })), - this.mempoolBlockDelta$.pipe(map(delta => { return { delta }; })), - ).pipe(scan((transactions: { [txid: string]: TransactionStripped }, change: any): { [txid: string]: TransactionStripped } => { - if (change.transactions) { - const txMap = {} + this.liveMempoolBlockTransactions$ = this.mempoolBlockUpdate$.pipe(scan((transactions: { [txid: string]: TransactionStripped }, change: MempoolBlockUpdate): { [txid: string]: TransactionStripped } => { + if (isMempoolState(change)) { + const txMap = {}; change.transactions.forEach(tx => { txMap[tx.txid] = tx; - }) + }); return txMap; } else { - change.delta.changed.forEach(tx => { - transactions[tx.txid].rate = tx.rate; - }) - change.delta.removed.forEach(txid => { + change.added.forEach(tx => { + transactions[tx.txid] = tx; + }); + change.removed.forEach(txid => { delete transactions[txid]; }); - change.delta.added.forEach(tx => { - transactions[tx.txid] = tx; + change.changed.forEach(tx => { + if (transactions[tx.txid]) { + transactions[tx.txid].rate = tx.rate; + transactions[tx.txid].acc = tx.acc; + } }); return transactions; } @@ -259,6 +277,12 @@ export class StateService { const rateUnitPreference = this.storageService.getValue('rate-unit-preference'); this.rateUnits$ = new BehaviorSubject(rateUnitPreference || 'vb'); + const blockDisplayModePreference = this.storageService.getValue('block-display-mode-preference'); + this.blockDisplayMode$ = new BehaviorSubject(blockDisplayModePreference || 'fees'); + + const viewAmountModePreference = this.storageService.getValue('view-amount-mode') as 'btc' | 'sats' | 'fiat'; + this.viewAmountMode$ = new BehaviorSubject<'btc' | 'sats' | 'fiat'>(viewAmountModePreference || 'btc'); + this.backend$.subscribe(backend => { this.backend = backend; }); diff --git a/frontend/src/app/services/theme.service.ts b/frontend/src/app/services/theme.service.ts index 098089597..7981f37a3 100644 --- a/frontend/src/app/services/theme.service.ts +++ b/frontend/src/app/services/theme.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { Subject } from 'rxjs'; import { defaultMempoolFeeColors, contrastMempoolFeeColors } from '../app.constants'; import { StorageService } from './storage.service'; +import { StateService } from './state.service'; @Injectable({ providedIn: 'root' @@ -14,8 +15,9 @@ export class ThemeService { constructor( private storageService: StorageService, + private stateService: StateService, ) { - const theme = this.storageService.getValue('theme-preference') || 'default'; + const theme = this.storageService.getValue('theme-preference') || this.stateService.env.customize?.theme || 'default'; this.apply(theme); } diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index 414f60bc5..e4df12aa6 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -401,14 +401,16 @@ export class WebsocketService { if (response['projected-block-transactions'].index == this.trackingMempoolBlock) { if (response['projected-block-transactions'].blockTransactions) { this.stateService.mempoolSequence = response['projected-block-transactions'].sequence; - this.stateService.mempoolBlockTransactions$.next(response['projected-block-transactions'].blockTransactions.map(uncompressTx)); + this.stateService.mempoolBlockUpdate$.next({ + transactions: response['projected-block-transactions'].blockTransactions.map(uncompressTx), + }); } else if (response['projected-block-transactions'].delta) { if (this.stateService.mempoolSequence && response['projected-block-transactions'].sequence !== this.stateService.mempoolSequence + 1) { this.stateService.mempoolSequence = 0; this.startTrackMempoolBlock(this.trackingMempoolBlock, true); } else { this.stateService.mempoolSequence = response['projected-block-transactions'].sequence; - this.stateService.mempoolBlockDelta$.next(uncompressDeltaChange(response['projected-block-transactions'].delta)); + this.stateService.mempoolBlockUpdate$.next(uncompressDeltaChange(response['projected-block-transactions'].delta)); } } } diff --git a/frontend/src/app/shared/components/global-footer/global-footer.component.html b/frontend/src/app/shared/components/global-footer/global-footer.component.html index 74b5b1c6f..7e3d44b06 100644 --- a/frontend/src/app/shared/components/global-footer/global-footer.component.html +++ b/frontend/src/app/shared/components/global-footer/global-footer.component.html @@ -45,7 +45,7 @@