Merge branch 'master' into simon/search-bar-click-outside

This commit is contained in:
Felipe Knorr Kuhn
2022-11-07 07:21:19 -08:00
committed by GitHub
78 changed files with 78060 additions and 16589 deletions

View File

@@ -1,21 +1,22 @@
<div class="container-xl" (window:resize)="onResize($event)">
<div *ngIf="(auditObservable$ | async) as blockAudit; else skeleton">
<div class="title-block" id="block">
<h1>
<span class="next-previous-blocks">
<span i18n="shared.block-title">Block </span>
&nbsp;
<a [routerLink]="['/block/' | relativeUrl, blockAudit.id]">{{ blockAudit.height }}</a>
&nbsp;
<span i18n="shared.template-vs-mined">Template vs Mined</span>
</span>
</h1>
<div class="title-block" id="block">
<h1>
<span class="next-previous-blocks">
<span i18n="shared.block-audit-title">Block Audit</span>
&nbsp;
<a *ngIf="blockAudit" [routerLink]="['/block/' | relativeUrl, blockHash]">{{ blockAudit.height }}</a>
&nbsp;
</span>
</h1>
<div class="grow"></div>
<div class="grow"></div>
<button [routerLink]="['/' | relativeUrl]" class="btn btn-sm">&#10005;</button>
</div>
<button [routerLink]="['/block/' | relativeUrl, blockHash]" class="btn btn-sm">&#10005;</button>
</div>
<div *ngIf="!error && !isLoading">
<!-- OVERVIEW -->
<div class="box mb-3">
@@ -26,8 +27,8 @@
<tbody>
<tr>
<td class="td-width" i18n="block.hash">Hash</td>
<td><a [routerLink]="['/block/' | relativeUrl, blockAudit.id]" title="{{ blockAudit.id }}">{{ blockAudit.id | shortenString : 13 }}</a>
<app-clipboard class="d-none d-sm-inline-block" [text]="blockAudit.id"></app-clipboard>
<td><a [routerLink]="['/block/' | relativeUrl, blockHash]" title="{{ blockHash }}">{{ blockHash | shortenString : 13 }}</a>
<app-clipboard class="d-none d-sm-inline-block" [text]="blockHash"></app-clipboard>
</td>
</tr>
<tr>
@@ -40,6 +41,10 @@
</div>
</td>
</tr>
<tr>
<td class="td-width" i18n="shared.transaction-count">Transactions</td>
<td>{{ blockAudit.tx_count }}</td>
</tr>
<tr>
<td i18n="blockAudit.size">Size</td>
<td [innerHTML]="'&lrm;' + (blockAudit.size | bytes: 2)"></td>
@@ -57,21 +62,25 @@
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td class="td-width" i18n="shared.transaction-count">Transactions</td>
<td>{{ blockAudit.tx_count }}</td>
</tr>
<tr>
<td i18n="block.match-rate">Match rate</td>
<td i18n="block.health">Block health</td>
<td>{{ blockAudit.matchRate }}%</td>
</tr>
<tr>
<td i18n="block.missing-txs">Missing txs</td>
<td i18n="block.missing-txs">Removed txs</td>
<td>{{ blockAudit.missingTxs.length }}</td>
</tr>
<tr>
<td i18n="block.missing-txs">Omitted txs</td>
<td>{{ numMissing }}</td>
</tr>
<tr>
<td i18n="block.added-txs">Added txs</td>
<td>{{ blockAudit.addedTxs.length }}</td>
</tr>
<tr>
<td i18n="block.missing-txs">Included txs</td>
<td>{{ numUnexpected }}</td>
</tr>
</tbody>
</table>
</div>
@@ -79,33 +88,110 @@
</div> <!-- box -->
<!-- ADDED vs MISSING button -->
<div class="d-flex justify-content-center menu mt-3" *ngIf="isMobile">
<a routerLinkActive="active" class="btn btn-primary w-50 mr-1 ml-1 menu-button" i18n="block.missing-txs"
fragment="missing" (click)="changeMode('missing')">Missing</a>
<a routerLinkActive="active" class="btn btn-primary w-50 mr-1 ml-1 menu-button" i18n="block.added-txs"
fragment="added" (click)="changeMode('added')">Added</a>
<div class="d-flex justify-content-center menu mt-3 mb-3" *ngIf="isMobile">
<a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'projected'" i18n="block.projected"
fragment="projected" (click)="changeMode('projected')">Projected</a>
<a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'actual'" i18n="block.actual"
fragment="actual" (click)="changeMode('actual')">Actual</a>
</div>
</div>
<ng-template [ngIf]="!error && isLoading">
<div class="title-block" id="block">
<h1>
<span class="next-previous-blocks">
<span i18n="shared.block-audit-title">Block Audit</span>
&nbsp;
<a *ngIf="blockAudit" [routerLink]="['/block/' | relativeUrl, blockHash]">{{ blockAudit.height }}</a>
&nbsp;
</span>
</h1>
<div class="grow"></div>
<button [routerLink]="['/' | relativeUrl]" class="btn btn-sm">&#10005;</button>
</div>
<!-- OVERVIEW -->
<div class="box mb-3">
<div class="row">
<!-- LEFT COLUMN -->
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
</tbody>
</table>
</div>
<!-- RIGHT COLUMN -->
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
</tbody>
</table>
</div>
</div> <!-- row -->
</div> <!-- box -->
<!-- ADDED vs MISSING button -->
<div class="d-flex justify-content-center menu mt-3 mb-3" *ngIf="isMobile">
<a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'projected'" i18n="block.projected"
fragment="projected" (click)="changeMode('projected')">Projected</a>
<a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'actual'" i18n="block.actual"
fragment="actual" (click)="changeMode('actual')">Actual</a>
</div>
</ng-template>
<ng-template [ngIf]="error">
<div *ngIf="error && error.status === 404; else generalError" class="text-center">
<br>
<b i18n="error.audit-unavailable">audit unavailable</b>
<br><br>
<i>{{ error.error }}</i>
<br>
<br>
</div>
<ng-template #generalError>
<div class="text-center">
<br>
<span i18n="error.general-loading-data">Error loading data.</span>
<br><br>
<i>{{ error }}</i>
<br>
<br>
</div>
</ng-template>
</ng-template>
<!-- VISUALIZATIONS -->
<div class="box">
<div class="box" *ngIf="!error">
<div class="row">
<!-- MISSING TX RENDERING -->
<div class="col-sm" *ngIf="webGlEnabled">
<app-block-overview-graph #blockGraphTemplate [isLoading]="isLoading" [resolution]="75"
<h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.projected-block">Projected Block</h3>
<app-block-overview-graph #blockGraphProjected [isLoading]="isLoading" [resolution]="75"
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false"
(txClickEvent)="onTxClick($event)"></app-block-overview-graph>
</div>
<!-- ADDED TX RENDERING -->
<div class="col-sm" *ngIf="webGlEnabled && !isMobile">
<app-block-overview-graph #blockGraphMined [isLoading]="isLoading" [resolution]="75"
<h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.actual-block">Actual Block</h3>
<app-block-overview-graph #blockGraphActual [isLoading]="isLoading" [resolution]="75"
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false"
(txClickEvent)="onTxClick($event)"></app-block-overview-graph>
</div>
</div> <!-- row -->
</div> <!-- box -->
<ng-template #skeleton></ng-template>
</div>

View File

@@ -37,4 +37,8 @@
@media (min-width: 768px) {
max-width: 150px;
}
}
.block-subtitle {
text-align: center;
}

View File

@@ -1,7 +1,7 @@
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { Component, OnDestroy, OnInit, AfterViewInit, ViewChildren, QueryList } from '@angular/core';
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { map, share, switchMap, tap } from 'rxjs/operators';
import { Subscription, combineLatest } from 'rxjs';
import { map, switchMap, startWith, catchError } from 'rxjs/operators';
import { BlockAudit, TransactionStripped } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service';
import { StateService } from '../../services/state.service';
@@ -22,22 +22,30 @@ import { BlockOverviewGraphComponent } from '../block-overview-graph/block-overv
}
`],
})
export class BlockAuditComponent implements OnInit, OnDestroy {
export class BlockAuditComponent implements OnInit, AfterViewInit, OnDestroy {
blockAudit: BlockAudit = undefined;
transactions: string[];
auditObservable$: Observable<BlockAudit>;
auditSubscription: Subscription;
urlFragmentSubscription: Subscription;
paginationMaxSize: number;
page = 1;
itemsPerPage: number;
mode: 'missing' | 'added' = 'missing';
mode: 'projected' | 'actual' = 'projected';
error: any;
isLoading = true;
webGlEnabled = true;
isMobile = window.innerWidth <= 767.98;
@ViewChild('blockGraphTemplate') blockGraphTemplate: BlockOverviewGraphComponent;
@ViewChild('blockGraphMined') blockGraphMined: BlockOverviewGraphComponent;
childChangeSubscription: Subscription;
blockHash: string;
numMissing: number = 0;
numUnexpected: number = 0;
@ViewChildren('blockGraphProjected') blockGraphProjected: QueryList<BlockOverviewGraphComponent>;
@ViewChildren('blockGraphActual') blockGraphActual: QueryList<BlockOverviewGraphComponent>;
constructor(
private route: ActivatedRoute,
@@ -48,73 +56,137 @@ export class BlockAuditComponent implements OnInit, OnDestroy {
this.webGlEnabled = detectWebGL();
}
ngOnDestroy(): void {
ngOnDestroy() {
this.childChangeSubscription.unsubscribe();
this.urlFragmentSubscription.unsubscribe();
}
ngOnInit(): void {
this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
this.itemsPerPage = this.stateService.env.ITEMS_PER_PAGE;
this.auditObservable$ = this.route.paramMap.pipe(
this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => {
if (fragment === 'actual') {
this.mode = 'actual';
} else {
this.mode = 'projected'
}
this.setupBlockGraphs();
});
this.auditSubscription = this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
const blockHash: string = params.get('id') || '';
return this.apiService.getBlockAudit$(blockHash)
this.blockHash = params.get('id') || null;
if (!this.blockHash) {
return null;
}
return this.apiService.getBlockAudit$(this.blockHash)
.pipe(
map((response) => {
const blockAudit = response.body;
for (let i = 0; i < blockAudit.template.length; ++i) {
if (blockAudit.missingTxs.includes(blockAudit.template[i].txid)) {
blockAudit.template[i].status = 'missing';
} else if (blockAudit.addedTxs.includes(blockAudit.template[i].txid)) {
blockAudit.template[i].status = 'added';
const inTemplate = {};
const inBlock = {};
const isAdded = {};
const isCensored = {};
const isMissing = {};
const isSelected = {};
this.numMissing = 0;
this.numUnexpected = 0;
for (const tx of blockAudit.template) {
inTemplate[tx.txid] = true;
}
for (const tx of blockAudit.transactions) {
inBlock[tx.txid] = true;
}
for (const txid of blockAudit.addedTxs) {
isAdded[txid] = true;
}
for (const txid of blockAudit.missingTxs) {
isCensored[txid] = true;
}
// set transaction statuses
for (const tx of blockAudit.template) {
if (isCensored[tx.txid]) {
tx.status = 'censored';
} else if (inBlock[tx.txid]) {
tx.status = 'found';
} else {
blockAudit.template[i].status = 'found';
tx.status = 'missing';
isMissing[tx.txid] = true;
this.numMissing++;
}
}
for (let i = 0; i < blockAudit.transactions.length; ++i) {
if (blockAudit.missingTxs.includes(blockAudit.transactions[i].txid)) {
blockAudit.transactions[i].status = 'missing';
} else if (blockAudit.addedTxs.includes(blockAudit.transactions[i].txid)) {
blockAudit.transactions[i].status = 'added';
for (const [index, tx] of blockAudit.transactions.entries()) {
if (isAdded[tx.txid]) {
tx.status = 'added';
} else if (index === 0 || inTemplate[tx.txid]) {
tx.status = 'found';
} else {
blockAudit.transactions[i].status = 'found';
tx.status = 'selected';
isSelected[tx.txid] = true;
this.numUnexpected++;
}
}
for (const tx of blockAudit.transactions) {
inBlock[tx.txid] = true;
}
return blockAudit;
}),
tap((blockAudit) => {
this.changeMode(this.mode);
if (this.blockGraphTemplate) {
this.blockGraphTemplate.destroy();
this.blockGraphTemplate.setup(blockAudit.template);
}
if (this.blockGraphMined) {
this.blockGraphMined.destroy();
this.blockGraphMined.setup(blockAudit.transactions);
}
this.isLoading = false;
}),
})
);
}),
share()
);
catchError((err) => {
console.log(err);
this.error = err;
this.isLoading = false;
return null;
}),
).subscribe((blockAudit) => {
this.blockAudit = blockAudit;
this.setupBlockGraphs();
this.isLoading = false;
});
}
ngAfterViewInit() {
this.childChangeSubscription = combineLatest([this.blockGraphProjected.changes.pipe(startWith(null)), this.blockGraphActual.changes.pipe(startWith(null))]).subscribe(() => {
this.setupBlockGraphs();
})
}
setupBlockGraphs() {
if (this.blockAudit) {
this.blockGraphProjected.forEach(graph => {
graph.destroy();
if (this.isMobile && this.mode === 'actual') {
graph.setup(this.blockAudit.transactions);
} else {
graph.setup(this.blockAudit.template);
}
})
this.blockGraphActual.forEach(graph => {
graph.destroy();
graph.setup(this.blockAudit.transactions);
})
}
}
onResize(event: any) {
this.isMobile = event.target.innerWidth <= 767.98;
const isMobile = event.target.innerWidth <= 767.98;
const changed = isMobile !== this.isMobile;
this.isMobile = isMobile;
this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5;
if (changed) {
this.changeMode(this.mode);
}
}
changeMode(mode: 'missing' | 'added') {
changeMode(mode: 'projected' | 'actual') {
this.router.navigate([], { fragment: mode });
this.mode = mode;
}
onTxClick(event: TransactionStripped): void {
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`);
this.router.navigate([url]);
}
pageChange(page: number, target: HTMLElement) {
}
}

View File

@@ -7,6 +7,15 @@ import { feeLevels, mempoolFeeColors } from '../../app.constants';
const hoverTransitionTime = 300;
const defaultHoverColor = hexToColor('1bd8f4');
const feeColors = mempoolFeeColors.map(hexToColor);
const auditFeeColors = feeColors.map((color) => desaturate(color, 0.3));
const auditColors = {
censored: hexToColor('f344df'),
missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7),
added: hexToColor('03E1E5'),
selected: darken(desaturate(hexToColor('039BE5'), 0.3), 0.7),
}
// convert from this class's update format to TxSprite's update format
function toSpriteUpdate(params: ViewUpdateParams): SpriteUpdateParams {
return {
@@ -25,7 +34,7 @@ export default class TxView implements TransactionStripped {
vsize: number;
value: number;
feerate: number;
status?: 'found' | 'missing' | 'added';
status?: 'found' | 'missing' | 'added' | 'censored' | 'selected';
initialised: boolean;
vertexArray: FastVertexArray;
@@ -142,16 +151,23 @@ export default class TxView implements TransactionStripped {
}
getColor(): Color {
// Block audit
if (this.status === 'missing') {
return hexToColor('039BE5');
} else if (this.status === 'added') {
return hexToColor('D81B60');
}
// Block component
const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, this.feerate) < feeLvl) - 1;
return hexToColor(mempoolFeeColors[feeLevelIndex] || mempoolFeeColors[mempoolFeeColors.length - 1]);
const feeLevelColor = feeColors[feeLevelIndex] || feeColors[mempoolFeeColors.length - 1];
// Block audit
switch(this.status) {
case 'censored':
return auditColors.censored;
case 'missing':
return auditColors.missing;
case 'added':
return auditColors.added;
case 'selected':
return auditColors.selected;
case 'found':
return auditFeeColors[feeLevelIndex] || auditFeeColors[mempoolFeeColors.length - 1];
default:
return feeLevelColor;
}
}
}
@@ -163,3 +179,22 @@ function hexToColor(hex: string): Color {
a: 1
};
}
function desaturate(color: Color, amount: number): Color {
const gray = (color.r + color.g + color.b) / 6;
return {
r: color.r + ((gray - color.r) * amount),
g: color.g + ((gray - color.g) * amount),
b: color.b + ((gray - color.b) * amount),
a: color.a,
};
}
function darken(color: Color, amount: number): Color {
return {
r: color.r * amount,
g: color.g * amount,
b: color.b * amount,
a: color.a,
}
}

View File

@@ -32,6 +32,16 @@
<td class="td-width" i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td>
<td [innerHTML]="'&lrm;' + (vsize | vbytes: 2)"></td>
</tr>
<tr *ngIf="tx && tx.status && tx.status.length">
<td class="td-width" i18n="transaction.audit-status">Audit status</td>
<ng-container [ngSwitch]="tx?.status">
<td *ngSwitchCase="'found'" i18n="transaction.audit.match">match</td>
<td *ngSwitchCase="'censored'" i18n="transaction.audit.removed">removed</td>
<td *ngSwitchCase="'missing'" i18n="transaction.audit.missing">missing</td>
<td *ngSwitchCase="'added'" i18n="transaction.audit.added">added</td>
<td *ngSwitchCase="'selected'" i18n="transaction.audit.included">included</td>
</ng-container>
</tr>
</tbody>
</table>
</div>

View File

@@ -110,6 +110,13 @@
</span>
</td>
</tr>
<tr *ngIf="indexingAvailable">
<td i18n="block.health">Block health</td>
<td>
<a *ngIf="block.extras?.matchRate != null" [routerLink]="['/block-audit/' | relativeUrl, blockHash]">{{ block.extras.matchRate }}%</a>
<span *ngIf="block.extras?.matchRate == null" i18n="unknown">Unknown</span>
</td>
</tr>
</ng-template>
</tbody>
</table>

View File

@@ -47,6 +47,7 @@ export class BlockComponent implements OnInit, OnDestroy {
transactionsError: any = null;
overviewError: any = null;
webGlEnabled = true;
indexingAvailable = false;
transactionSubscription: Subscription;
overviewSubscription: Subscription;
@@ -86,6 +87,9 @@ export class BlockComponent implements OnInit, OnDestroy {
this.timeLtr = !!ltr;
});
this.indexingAvailable = (this.stateService.env.BASE_MODULE === 'mempool' &&
this.stateService.env.MINING_DASHBOARD === true);
this.txsLoadingStatus$ = this.route.paramMap
.pipe(
switchMap(() => this.stateService.loadingIndicators$),

View File

@@ -14,6 +14,8 @@
i18n-ngbTooltip="mining.pool-name" ngbTooltip="Pool" placement="bottom" #miningpool [disableTooltip]="!isEllipsisActive(miningpool)">Pool</th>
<th class="timestamp" i18n="latest-blocks.timestamp" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'">Timestamp</th>
<th class="mined" i18n="latest-blocks.mined" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'">Mined</th>
<th *ngIf="indexingAvailable" class="health text-left" i18n="latest-blocks.health" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"
i18n-ngbTooltip="latest-blocks.health" ngbTooltip="Health" placement="bottom" #health [disableTooltip]="!isEllipsisActive(health)">Health</th>
<th *ngIf="indexingAvailable" class="reward text-right" i18n="latest-blocks.reward" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"
i18n-ngbTooltip="latest-blocks.reward" ngbTooltip="Reward" placement="bottom" #reward [disableTooltip]="!isEllipsisActive(reward)">Reward</th>
<th *ngIf="indexingAvailable && !widget" class="fees text-right" i18n="latest-blocks.fees" [class]="indexingAvailable ? '' : 'legacy'">Fees</th>
@@ -37,12 +39,30 @@
<span *ngIf="!widget" class="tooltiptext badge badge-secondary scriptmessage">{{ block.extras.coinbaseRaw | hex2ascii }}</span>
</div>
</td>
<td class="timestamp" *ngIf="!widget">
<td class="timestamp" *ngIf="!widget" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
&lrm;{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}
</td>
<td class="mined" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'">
<app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since>
</td>
<td *ngIf="indexingAvailable" class="health text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
<a *ngIf="block.extras?.matchRate != null" class="clear-link" [routerLink]="['/block-audit/' | relativeUrl, block.id]">
<div class="progress progress-health">
<div class="progress-bar progress-bar-health" role="progressbar"
[ngStyle]="{'width': (100 - (block.extras?.matchRate || 0)) + '%' }"></div>
<div class="progress-text">
<span>{{ block.extras.matchRate }}%</span>
</div>
</div>
</a>
<div *ngIf="block.extras?.matchRate == null" class="progress progress-health">
<div class="progress-bar progress-bar-health" role="progressbar"
[ngStyle]="{'width': '100%' }"></div>
<div class="progress-text">
<span>~</span>
</div>
</div>
</td>
<td *ngIf="indexingAvailable" class="reward text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
<app-amount [satoshis]="block.extras.reward" [noFiat]="true" digitsInfo="1.2-2"></app-amount>
</td>
@@ -77,6 +97,9 @@
<td class="mined" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'">
<span class="skeleton-loader" style="max-width: 125px"></span>
</td>
<td *ngIf="indexingAvailable" class="health text-left" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
<span class="skeleton-loader" style="max-width: 75px"></span>
</td>
<td *ngIf="indexingAvailable" class="reward text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
<span class="skeleton-loader" style="max-width: 75px"></span>
</td>

View File

@@ -63,7 +63,7 @@ tr, td, th {
}
.height {
width: 10%;
width: 8%;
}
.height.widget {
width: 15%;
@@ -77,12 +77,18 @@ tr, td, th {
.timestamp {
width: 18%;
@media (max-width: 900px) {
@media (max-width: 1100px) {
display: none;
}
}
.timestamp.legacy {
width: 20%;
@media (max-width: 1100px) {
display: table-cell;
}
@media (max-width: 850px) {
display: none;
}
}
.mined {
@@ -93,6 +99,10 @@ tr, td, th {
}
.mined.legacy {
width: 15%;
@media (max-width: 1000px) {
padding-right: 20px;
width: 20%;
}
@media (max-width: 576px) {
display: table-cell;
}
@@ -100,6 +110,7 @@ tr, td, th {
.txs {
padding-right: 40px;
width: 8%;
@media (max-width: 1100px) {
padding-right: 10px;
}
@@ -113,17 +124,21 @@ tr, td, th {
}
.txs.widget {
padding-right: 0;
display: none;
@media (max-width: 650px) {
display: none;
}
}
.txs.legacy {
padding-right: 80px;
width: 10%;
width: 18%;
display: table-cell;
@media (max-width: 1000px) {
padding-right: 20px;
}
}
.fees {
width: 10%;
width: 8%;
@media (max-width: 650px) {
display: none;
}
@@ -133,7 +148,7 @@ tr, td, th {
}
.reward {
width: 10%;
width: 8%;
@media (max-width: 576px) {
width: 7%;
padding-right: 30px;
@@ -152,8 +167,11 @@ tr, td, th {
}
.size {
width: 12%;
width: 10%;
@media (max-width: 1000px) {
width: 13%;
}
@media (max-width: 950px) {
width: 15%;
}
@media (max-width: 650px) {
@@ -164,12 +182,34 @@ tr, td, th {
}
}
.size.legacy {
width: 20%;
width: 30%;
@media (max-width: 576px) {
display: table-cell;
}
}
.health {
width: 10%;
@media (max-width: 1000px) {
width: 13%;
}
@media (max-width: 950px) {
display: none;
}
}
.health.widget {
width: 25%;
@media (max-width: 1000px) {
display: none;
}
@media (max-width: 767px) {
display: table-cell;
}
@media (max-width: 500px) {
display: none;
}
}
/* Tooltip text */
.tooltip-custom {
position: relative;

View File

@@ -3,8 +3,8 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AssetsService } from '../../services/assets.service';
import { StateService } from '../../services/state.service';
import { Observable, of, Subject, zip, BehaviorSubject } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, catchError, map } from 'rxjs/operators';
import { Observable, of, Subject, zip, BehaviorSubject, combineLatest } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, catchError, map, startWith, tap } from 'rxjs/operators';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { ApiService } from '../../services/api.service';
@@ -34,7 +34,7 @@ export class SearchFormComponent implements OnInit {
}
}
regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100})$/;
regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[A-z]{2,5}1[a-zA-HJ-NP-Z0-9]{39,59})$/;
regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/;
regexTransaction = /^([a-fA-F0-9]{64})(:\d+)?$/;
regexBlockheight = /^[0-9]{1,9}$/;
@@ -43,7 +43,7 @@ export class SearchFormComponent implements OnInit {
@Output() searchTriggered = new EventEmitter();
@ViewChild('searchResults') searchResults: SearchResultsComponent;
@HostListener('keydown', ['$event']) keydown($event) {
@HostListener('keydown', ['$event']) keydown($event): void {
this.handleKeyDown($event);
}
@@ -58,7 +58,7 @@ export class SearchFormComponent implements OnInit {
private elementRef: ElementRef,
) { }
ngOnInit() {
ngOnInit(): void {
this.stateService.networkChanged$.subscribe((network) => this.network = network);
this.searchForm = this.formBuilder.group({
@@ -72,70 +72,111 @@ export class SearchFormComponent implements OnInit {
});
}
this.typeAhead$ = this.searchForm.get('searchText').valueChanges
.pipe(
map((text) => {
if (this.network === 'bisq' && text.match(/^(b)[^c]/i)) {
return text.substr(1);
}
return text.trim();
}),
debounceTime(200),
distinctUntilChanged(),
switchMap((text) => {
if (!text.length) {
return of([
'',
[],
{
nodes: [],
channels: [],
}
]);
}
this.isTypeaheading$.next(true);
if (!this.stateService.env.LIGHTNING) {
return zip(
of(text),
this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))),
[{ nodes: [], channels: [] }],
of(this.regexBlockheight.test(text)),
);
}
const searchText$ = this.searchForm.get('searchText').valueChanges
.pipe(
map((text) => {
if (this.network === 'bisq' && text.match(/^(b)[^c]/i)) {
return text.substr(1);
}
return text.trim();
}),
distinctUntilChanged(),
);
const searchResults$ = searchText$.pipe(
debounceTime(200),
switchMap((text) => {
if (!text.length) {
return of([
[],
{ nodes: [], channels: [] }
]);
}
this.isTypeaheading$.next(true);
if (!this.stateService.env.LIGHTNING) {
return zip(
of(text),
this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))),
this.apiService.lightningSearch$(text).pipe(catchError(() => of({
[{ nodes: [], channels: [] }],
);
}
return zip(
this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))),
this.apiService.lightningSearch$(text).pipe(catchError(() => of({
nodes: [],
channels: [],
}))),
);
}),
tap((result: any[]) => {
this.isTypeaheading$.next(false);
})
);
this.typeAhead$ = combineLatest(
[
searchText$,
searchResults$.pipe(
startWith([
[],
{
nodes: [],
channels: [],
}
]))
]
).pipe(
map((latestData) => {
const searchText = latestData[0];
if (!searchText.length) {
return {
searchText: '',
hashQuickMatch: false,
blockHeight: false,
txId: false,
address: false,
addresses: [],
nodes: [],
channels: [],
}))),
);
}),
map((result: any[]) => {
this.isTypeaheading$.next(false);
if (this.network === 'bisq') {
return result[0].map((address: string) => 'B' + address);
};
}
const result = latestData[1];
const addressPrefixSearchResults = result[0];
const lightningResults = result[1];
if (this.network === 'bisq') {
return searchText.map((address: string) => 'B' + address);
}
const matchesBlockHeight = this.regexBlockheight.test(searchText);
const matchesTxId = this.regexTransaction.test(searchText) && !this.regexBlockhash.test(searchText);
const matchesBlockHash = this.regexBlockhash.test(searchText);
const matchesAddress = this.regexAddress.test(searchText);
return {
searchText: result[0],
blockHeight: this.regexBlockheight.test(result[0]) ? [parseInt(result[0], 10)] : [],
addresses: result[1],
nodes: result[2].nodes,
channels: result[2].channels,
totalResults: result[1].length + result[2].nodes.length + result[2].channels.length,
searchText: searchText,
hashQuickMatch: +(matchesBlockHeight || matchesBlockHash || matchesTxId || matchesAddress),
blockHeight: matchesBlockHeight,
txId: matchesTxId,
blockHash: matchesBlockHash,
address: matchesAddress,
addresses: addressPrefixSearchResults,
nodes: lightningResults.nodes,
channels: lightningResults.channels,
};
})
);
}
handleKeyDown($event) {
handleKeyDown($event): void {
this.searchResults.handleKeyDown($event);
}
itemSelected() {
itemSelected(): void {
setTimeout(() => this.search());
}
selectedResult(result: any) {
selectedResult(result: any): void {
if (typeof result === 'string') {
this.search(result);
} else if (typeof result === 'number') {
@@ -147,7 +188,7 @@ export class SearchFormComponent implements OnInit {
}
}
search(result?: string) {
search(result?: string): void {
const searchText = result || this.searchForm.value.searchText.trim();
if (searchText) {
this.isSearching = true;
@@ -181,7 +222,7 @@ export class SearchFormComponent implements OnInit {
}
}
navigate(url: string, searchText: string, extras?: any) {
navigate(url: string, searchText: string, extras?: any): void {
this.router.navigate([this.relativeUrlPipe.transform(url), searchText], extras);
this.searchTriggered.emit();
this.searchForm.setValue({

View File

@@ -1,14 +1,32 @@
<div class="dropdown-menu show" *ngIf="results" [hidden]="!results.blockHeight.length && !results.addresses.length && !results.nodes.length && !results.channels.length">
<ng-template [ngIf]="results.blockHeight.length">
<div class="dropdown-menu show" *ngIf="results" [hidden]="!results.hashQuickMatch && !results.addresses.length && !results.nodes.length && !results.channels.length">
<ng-template [ngIf]="results.blockHeight">
<div class="card-title">Bitcoin Block Height</div>
<button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item">
Go to "{{ results.searchText }}"
</button>
</ng-template>
<ng-template [ngIf]="results.txId">
<div class="card-title">Bitcoin Transaction</div>
<button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item">
Go to "{{ results.searchText | shortenString : 13 }}"
</button>
</ng-template>
<ng-template [ngIf]="results.address">
<div class="card-title">Bitcoin Address</div>
<button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item">
Go to "{{ results.searchText | shortenString : isMobile ? 20 : 30 }}"
</button>
</ng-template>
<ng-template [ngIf]="results.blockHash">
<div class="card-title">Bitcoin Block</div>
<button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item">
Go to "{{ results.searchText | shortenString : 13 }}"
</button>
</ng-template>
<ng-template [ngIf]="results.addresses.length">
<div class="card-title" *ngIf="stateService.env.LIGHTNING">Bitcoin Addresses</div>
<div class="card-title">Bitcoin Addresses</div>
<ng-template ngFor [ngForOf]="results.addresses" let-address let-i="index">
<button (click)="clickItem(results.blockHeight.length + i)" [class.active]="(results.blockHeight.length + i) === activeIdx" type="button" role="option" class="dropdown-item">
<button (click)="clickItem(results.hashQuickMatch + i)" [class.active]="(results.hashQuickMatch + i) === activeIdx" type="button" role="option" class="dropdown-item">
<ngb-highlight [result]="address | shortenString : isMobile ? 25 : 36" [term]="results.searchText"></ngb-highlight>
</button>
</ng-template>
@@ -16,7 +34,7 @@
<ng-template [ngIf]="results.nodes.length">
<div class="card-title">Lightning Nodes</div>
<ng-template ngFor [ngForOf]="results.nodes" let-node let-i="index">
<button (click)="clickItem(results.blockHeight.length + results.addresses.length + i)" [class.inactive]="node.status === 0" [class.active]="results.blockHeight.length + results.addresses.length + i === activeIdx" [routerLink]="['/lightning/node' | relativeUrl, node.public_key]" type="button" role="option" class="dropdown-item">
<button (click)="clickItem(results.hashQuickMatch + results.addresses.length + i)" [class.inactive]="node.status === 0" [class.active]="results.hashQuickMatch + results.addresses.length + i === activeIdx" [routerLink]="['/lightning/node' | relativeUrl, node.public_key]" type="button" role="option" class="dropdown-item">
<ngb-highlight [result]="node.alias" [term]="results.searchText"></ngb-highlight> &nbsp;<span class="symbol">{{ node.public_key | shortenString : 10 }}</span>
</button>
</ng-template>
@@ -24,7 +42,7 @@
<ng-template [ngIf]="results.channels.length">
<div class="card-title">Lightning Channels</div>
<ng-template ngFor [ngForOf]="results.channels" let-channel let-i="index">
<button (click)="clickItem(results.blockHeight.length + results.addresses.length + results.nodes.length + i)" [class.inactive]="channel.status === 2" [class.active]="results.blockHeight.length + results.addresses.length + results.nodes.length + i === activeIdx" type="button" role="option" class="dropdown-item">
<button (click)="clickItem(results.hashQuickMatch + results.addresses.length + results.nodes.length + i)" [class.inactive]="channel.status === 2" [class.active]="results.hashQuickMatch + results.addresses.length + results.nodes.length + i === activeIdx" type="button" role="option" class="dropdown-item">
<ngb-highlight [result]="channel.short_id" [term]="results.searchText"></ngb-highlight> &nbsp;<span class="symbol">{{ channel.id }}</span>
</button>
</ng-template>

View File

@@ -22,7 +22,7 @@ export class SearchResultsComponent implements OnChanges {
ngOnChanges() {
this.activeIdx = 0;
if (this.results) {
this.resultsFlattened = [...this.results.blockHeight, ...this.results.addresses, ...this.results.nodes, ...this.results.channels];
this.resultsFlattened = [...(this.results.hashQuickMatch ? [this.results.searchText] : []), ...this.results.addresses, ...this.results.nodes, ...this.results.channels];
}
}

View File

@@ -11,11 +11,15 @@
[showZoom]="false"
></app-mempool-graph>
</div>
<div class="blockchain-wrapper">
<div class="blockchain-wrapper" [dir]="timeLtr ? 'rtl' : 'ltr'" [class.time-ltr]="timeLtr">
<div class="position-container">
<app-mempool-blocks></app-mempool-blocks>
<app-blockchain-blocks></app-blockchain-blocks>
<div id="divider"></div>
<span>
<div class="blocks-wrapper">
<app-mempool-blocks></app-mempool-blocks>
<app-blockchain-blocks></app-blockchain-blocks>
</div>
<div id="divider"></div>
</span>
</div>
</div>
</div>

View File

@@ -31,8 +31,9 @@
.position-container {
position: absolute;
left: 50%;
left: 0;
bottom: 170px;
transform: translateX(50vw);
}
#divider {
@@ -47,9 +48,33 @@
top: -28px;
}
}
&.time-ltr {
.blocks-wrapper {
transform: scaleX(-1);
}
}
}
:host-context(.ltr-layout) {
.blockchain-wrapper.time-ltr .blocks-wrapper,
.blockchain-wrapper .blocks-wrapper {
direction: ltr;
}
}
:host-context(.rtl-layout) {
.blockchain-wrapper.time-ltr .blocks-wrapper,
.blockchain-wrapper .blocks-wrapper {
direction: rtl;
}
}
.tv-container {
display: flex;
margin-top: 0px;
flex-direction: column;
}

View File

@@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, OnDestroy } from '@angular/core';
import { WebsocketService } from '../../services/websocket.service';
import { OptimizedMempoolStats } from '../../interfaces/node-api.interface';
import { StateService } from '../../services/state.service';
@@ -6,7 +6,7 @@ import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service';
import { ActivatedRoute } from '@angular/router';
import { map, scan, startWith, switchMap, tap } from 'rxjs/operators';
import { interval, merge, Observable } from 'rxjs';
import { interval, merge, Observable, Subscription } from 'rxjs';
import { ChangeDetectionStrategy } from '@angular/core';
@Component({
@@ -15,11 +15,13 @@ import { ChangeDetectionStrategy } from '@angular/core';
styleUrls: ['./television.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TelevisionComponent implements OnInit {
export class TelevisionComponent implements OnInit, OnDestroy {
mempoolStats: OptimizedMempoolStats[] = [];
statsSubscription$: Observable<OptimizedMempoolStats[]>;
fragment: string;
timeLtrSubscription: Subscription;
timeLtr: boolean = this.stateService.timeLtr.value;
constructor(
private websocketService: WebsocketService,
@@ -37,6 +39,10 @@ export class TelevisionComponent implements OnInit {
this.seoService.setTitle($localize`:@@46ce8155c9ab953edeec97e8950b5a21e67d7c4e:TV view`);
this.websocketService.want(['blocks', 'live-2h-chart', 'mempool-blocks']);
this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => {
this.timeLtr = !!ltr;
});
this.statsSubscription$ = merge(
this.stateService.live2Chart$.pipe(map(stats => [stats])),
this.route.fragment
@@ -70,4 +76,8 @@ export class TelevisionComponent implements OnInit {
})
);
}
ngOnDestroy() {
this.timeLtrSubscription.unsubscribe();
}
}

View File

@@ -190,7 +190,7 @@
<br>
<ng-container *ngIf="showFlow; else flowPlaceholder">
<ng-container *ngIf="flowEnabled; else flowPlaceholder">
<div class="title float-left">
<h2 id="flow" i18n="transaction.flow|Transaction flow">Flow</h2>
</div>
@@ -210,8 +210,6 @@
[network]="network"
[tooltip]="true"
[inputIndex]="inputIndex" [outputIndex]="outputIndex"
(selectInput)="selectInput($event)"
(selectOutput)="selectOutput($event)"
>
</tx-bowtie-graph>
</div>
@@ -238,7 +236,7 @@
</div>
<div class="title-buttons">
<button *ngIf="!showFlow" type="button" class="btn btn-outline-info flow-toggle btn-sm" (click)="toggleGraph()" i18n="show-diagram">Show diagram</button>
<button *ngIf="!flowEnabled" type="button" class="btn btn-outline-info flow-toggle btn-sm" (click)="toggleGraph()" i18n="show-diagram">Show diagram</button>
<button type="button" class="btn btn-outline-info btn-sm" (click)="txList.toggleDetails()" i18n="transaction.details|Transaction Details">Details</button>
</div>
</div>
@@ -329,7 +327,7 @@
<br>
<ng-container *ngIf="showFlow">
<ng-container *ngIf="flowEnabled">
<div class="title">
<h2 i18n="transaction.flow|Transaction flow">Flow</h2>
</div>

View File

@@ -18,6 +18,7 @@ import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service';
import { BlockExtended, CpfpInfo } from '../../interfaces/node-api.interface';
import { LiquidUnblinding } from './liquid-ublinding';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
@Component({
selector: 'app-transaction',
@@ -40,6 +41,8 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
txReplacedSubscription: Subscription;
blocksSubscription: Subscription;
queryParamsSubscription: Subscription;
urlFragmentSubscription: Subscription;
fragmentParams: URLSearchParams;
rbfTransaction: undefined | Transaction;
cpfpInfo: CpfpInfo | null;
showCpfpDetails = false;
@@ -49,12 +52,15 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
liquidUnblinding = new LiquidUnblinding();
inputIndex: number;
outputIndex: number;
showFlow: boolean = true;
graphExpanded: boolean = false;
graphWidth: number = 1000;
graphHeight: number = 360;
inOutLimit: number = 150;
maxInOut: number = 0;
flowPrefSubscription: Subscription;
hideFlow: boolean = this.stateService.hideFlow.value;
overrideFlowPreference: boolean = null;
flowEnabled: boolean;
tooltipPosition: { x: number, y: number };
@@ -64,6 +70,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
constructor(
private route: ActivatedRoute,
private router: Router,
private relativeUrlPipe: RelativeUrlPipe,
private electrsApiService: ElectrsApiService,
private stateService: StateService,
private websocketService: WebsocketService,
@@ -78,12 +85,26 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
(network) => (this.network = network)
);
this.setFlowEnabled();
this.flowPrefSubscription = this.stateService.hideFlow.subscribe((hide) => {
this.hideFlow = !!hide;
this.setFlowEnabled();
});
this.timeAvg$ = timer(0, 1000)
.pipe(
switchMap(() => this.stateService.difficultyAdjustment$),
map((da) => da.timeAvg)
);
this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => {
this.fragmentParams = new URLSearchParams(fragment || '');
const vin = parseInt(this.fragmentParams.get('vin'), 10);
const vout = parseInt(this.fragmentParams.get('vout'), 10);
this.inputIndex = (!isNaN(vin) && vin >= 0) ? vin : null;
this.outputIndex = (!isNaN(vout) && vout >= 0) ? vout : null;
});
this.fetchCpfpSubscription = this.fetchCpfp$
.pipe(
switchMap((txId) =>
@@ -123,13 +144,29 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
switchMap((params: ParamMap) => {
const urlMatch = (params.get('id') || '').split(':');
if (urlMatch.length === 2 && urlMatch[1].length === 64) {
this.inputIndex = parseInt(urlMatch[0], 10);
this.outputIndex = null;
const vin = parseInt(urlMatch[0], 10);
this.txId = urlMatch[1];
// rewrite legacy vin syntax
if (!isNaN(vin)) {
this.fragmentParams.set('vin', vin.toString());
this.fragmentParams.delete('vout');
}
this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.txId], {
queryParamsHandling: 'merge',
fragment: this.fragmentParams.toString(),
});
} else {
this.txId = urlMatch[0];
this.outputIndex = urlMatch[1] === undefined ? null : parseInt(urlMatch[1], 10);
this.inputIndex = null;
const vout = parseInt(urlMatch[1], 10);
if (urlMatch.length > 1 && !isNaN(vout)) {
// rewrite legacy vout syntax
this.fragmentParams.set('vout', vout.toString());
this.fragmentParams.delete('vin');
this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.txId], {
queryParamsHandling: 'merge',
fragment: this.fragmentParams.toString(),
});
}
}
this.seoService.setTitle(
$localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`
@@ -213,6 +250,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.fetchCpfp$.next(this.tx.txid);
}
}
setTimeout(() => { this.applyFragment(); }, 0);
},
(error) => {
this.error = error;
@@ -245,11 +283,14 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.queryParamsSubscription = this.route.queryParams.subscribe((params) => {
if (params.showFlow === 'false') {
this.showFlow = false;
this.overrideFlowPreference = false;
} else if (params.showFlow === 'true') {
this.overrideFlowPreference = true;
} else {
this.showFlow = true;
this.setGraphSize();
this.overrideFlowPreference = null;
}
this.setFlowEnabled();
this.setGraphSize();
});
}
@@ -325,15 +366,20 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
}
toggleGraph() {
this.showFlow = !this.showFlow;
const showFlow = !this.flowEnabled;
this.stateService.hideFlow.next(!showFlow);
this.router.navigate([], {
relativeTo: this.route,
queryParams: { showFlow: this.showFlow },
queryParams: { showFlow: showFlow },
queryParamsHandling: 'merge',
fragment: 'flow'
});
}
setFlowEnabled() {
this.flowEnabled = (this.overrideFlowPreference != null ? this.overrideFlowPreference : !this.hideFlow);
}
expandGraph() {
this.graphExpanded = true;
}
@@ -342,14 +388,15 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.graphExpanded = false;
}
selectInput(input) {
this.inputIndex = input;
this.outputIndex = null;
}
selectOutput(output) {
this.outputIndex = output;
this.inputIndex = null;
// simulate normal anchor fragment behavior
applyFragment(): void {
const anchor = Array.from(this.fragmentParams.entries()).find(([frag, value]) => value === '');
if (anchor) {
const anchorElement = document.getElementById(anchor[0]);
if (anchorElement) {
anchorElement.scrollIntoView();
}
}
}
@HostListener('window:resize', ['$event'])
@@ -365,6 +412,8 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.txReplacedSubscription.unsubscribe();
this.blocksSubscription.unsubscribe();
this.queryParamsSubscription.unsubscribe();
this.flowPrefSubscription.unsubscribe();
this.urlFragmentSubscription.unsubscribe();
this.leaveTransaction();
}
}

View File

@@ -43,7 +43,7 @@
</ng-template>
</ng-template>
<ng-template #defaultPrevout>
<a [routerLink]="['/tx/' | relativeUrl, vin.txid + ':' + vin.vout]" class="red">
<a [routerLink]="['/tx/' | relativeUrl, vin.txid]" [fragment]="'vout=' + vin.vout" class="red">
<fa-icon [icon]="['fas', 'arrow-alt-circle-right']" [fixedWidth]="true"></fa-icon>
</a>
</ng-template>
@@ -220,7 +220,7 @@
<fa-icon [icon]="['fas', 'arrow-alt-circle-right']" [fixedWidth]="true"></fa-icon>
</span>
<ng-template #spent>
<a *ngIf="tx._outspends[vindex].txid else outputNoTxId" [routerLink]="['/tx/' | relativeUrl, tx._outspends[vindex].vin + ':' + tx._outspends[vindex].txid]" class="red">
<a *ngIf="tx._outspends[vindex].txid else outputNoTxId" [routerLink]="['/tx/' | relativeUrl, tx._outspends[vindex].txid]" [fragment]="'vin=' + tx._outspends[vindex].vin" class="red">
<fa-icon [icon]="['fas', 'arrow-alt-circle-right']" [fixedWidth]="true"></fa-icon>
</a>
<ng-template #outputNoTxId>

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, Input, Output, EventEmitter, OnChanges, HostListener } from '@angular/core';
import { Component, OnInit, Input, OnChanges, HostListener } from '@angular/core';
import { StateService } from '../../services/state.service';
import { Outspend, Transaction } from '../../interfaces/electrs.interface';
import { Router } from '@angular/router';
@@ -43,9 +43,6 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
@Input() inputIndex: number;
@Input() outputIndex: number;
@Output() selectInput = new EventEmitter<number>();
@Output() selectOutput = new EventEmitter<number>();
inputData: Xput[];
outputData: Xput[];
inputs: SvgLine[];
@@ -368,24 +365,42 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
onClick(event, side, index): void {
if (side === 'input') {
const input = this.tx.vin[index];
if (input && input.txid && input.vout != null) {
this.router.navigate([this.relativeUrlPipe.transform('/tx'), input.txid + ':' + input.vout], {
if (input && !input.is_coinbase && !input.is_pegin && input.txid && input.vout != null) {
this.router.navigate([this.relativeUrlPipe.transform('/tx'), input.txid], {
queryParamsHandling: 'merge',
fragment: 'flow'
fragment: (new URLSearchParams({
flow: '',
vout: input.vout.toString(),
})).toString(),
});
} else if (index != null) {
this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.tx.txid], {
queryParamsHandling: 'merge',
fragment: (new URLSearchParams({
flow: '',
vin: index.toString(),
})).toString(),
});
} else {
this.selectInput.emit(index);
}
} else {
const output = this.tx.vout[index];
const outspend = this.outspends[index];
if (output && outspend && outspend.spent && outspend.txid) {
this.router.navigate([this.relativeUrlPipe.transform('/tx'), outspend.vin + ':' + outspend.txid], {
this.router.navigate([this.relativeUrlPipe.transform('/tx'), outspend.txid], {
queryParamsHandling: 'merge',
fragment: 'flow'
fragment: (new URLSearchParams({
flow: '',
vin: outspend.vin.toString(),
})).toString(),
});
} else if (index != null) {
this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.tx.txid], {
queryParamsHandling: 'merge',
fragment: (new URLSearchParams({
flow: '',
vout: index.toString(),
})).toString(),
});
} else {
this.selectOutput.emit(index);
}
}
}

View File

@@ -13,7 +13,10 @@
<ng-template #notFullyTaproot>
<span *ngIf="segwitGains.realizedTaprootGains && segwitGains.potentialTaprootGains; else noTaproot" class="badge badge-warning mr-1" i18n-ngbTooltip="Tooltip about fees that saved and could be saved with taproot" ngbTooltip="This transaction uses Taproot and already saved at least {{ segwitGains.realizedTaprootGains * 100 | number: '1.0-0' }}% on fees, but could save an additional {{ segwitGains.potentialTaprootGains * 100 | number: '1.0-0' }}% by fully using Taproot" placement="bottom" i18n="tx-features.tag.taproot|Taproot">Taproot</span>
<ng-template #noTaproot>
<span *ngIf="segwitGains.potentialTaprootGains; else taprootButNoGains" class="badge badge-danger mr-1" i18n-ngbTooltip="Tooltip about fees that could be saved with taproot" ngbTooltip="This transaction could save {{ segwitGains.potentialTaprootGains * 100 | number: '1.0-0' }}% on fees by using Taproot" placement="bottom"><del i18n="tx-features.tag.taproot|Taproot">Taproot</del></span>
<span *ngIf="segwitGains.potentialTaprootGains && segwitGains.potentialTaprootGains > 0; else negativeTaprootGains" class="badge badge-danger mr-1" i18n-ngbTooltip="Tooltip about fees that could be saved with taproot" ngbTooltip="This transaction could save {{ segwitGains.potentialTaprootGains * 100 | number: '1.0-0' }}% on fees by using Taproot" placement="bottom"><del i18n="tx-features.tag.taproot|Taproot">Taproot</del></span>
<ng-template #negativeTaprootGains>
<span *ngIf="!isTaproot; else taprootButNoGains" class="badge badge-danger mr-1" i18n-ngbTooltip="Tooltip about using taproot" ngbTooltip="This transaction does not use Taproot" placement="bottom"><del i18n="tx-features.tag.taproot|Taproot">Taproot</del></span>
</ng-template>
<ng-template #taprootButNoGains>
<span *ngIf="isTaproot" class="badge badge-success mr-1" i18n-ngbTooltip="Tooltip about taproot" ngbTooltip="This transaction uses Taproot" placement="bottom" i18n="tx-features.tag.taproot|Taproot">Taproot</span>
</ng-template>