Merge pull request #5085 from mempool/simon/refactor-block-page

Refactor block transactions
This commit is contained in:
softsimon 2024-05-22 22:36:48 +07:00 committed by GitHub
commit 28477cc433
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 324 additions and 221 deletions

View File

@ -45,6 +45,7 @@ describe('Liquid', () => {
it('loads a specific block page', () => { it('loads a specific block page', () => {
cy.visit(`${basePath}/block/7e1369a23a5ab861e7bdede2aadcccae4ea873ffd9caf11c7c5541eb5bcdff54`); cy.visit(`${basePath}/block/7e1369a23a5ab861e7bdede2aadcccae4ea873ffd9caf11c7c5541eb5bcdff54`);
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
}); });

View File

@ -46,7 +46,8 @@ describe('Liquid Testnet', () => {
}); });
it('loads a specific block page', () => { it('loads a specific block page', () => {
cy.visit(`${basePath}/block/7e1369a23a5ab861e7bdede2aadcccae4ea873ffd9caf11c7c5541eb5bcdff54`); cy.visit(`${basePath}/block/fb4cbcbff3993ca4bf8caf657d55a23db5ed4ab1cfa33c489303c2e04e1c38e0`);
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
}); });

View File

@ -103,6 +103,7 @@ describe('Mainnet', () => {
it('check op_return tx tooltip', () => { it('check op_return tx tooltip', () => {
cy.visit('/block/00000000000000000003c5f542bed265319c6cf64238cf1f1bb9bca3ebf686d2'); cy.visit('/block/00000000000000000003c5f542bed265319c6cf64238cf1f1bb9bca3ebf686d2');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('onmouseover'); cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('onmouseover');
cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('mouseenter'); cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('mouseenter');
@ -111,6 +112,7 @@ describe('Mainnet', () => {
it('check op_return coinbase tooltip', () => { it('check op_return coinbase tooltip', () => {
cy.visit('/block/00000000000000000003c5f542bed265319c6cf64238cf1f1bb9bca3ebf686d2'); cy.visit('/block/00000000000000000003c5f542bed265319c6cf64238cf1f1bb9bca3ebf686d2');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('onmouseover'); cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('onmouseover');
cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('mouseenter'); cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('mouseenter');
@ -283,6 +285,7 @@ describe('Mainnet', () => {
it('loads genesis block and keypress arrow right', () => { it('loads genesis block and keypress arrow right', () => {
cy.viewport('macbook-16'); cy.viewport('macbook-16');
cy.visit('/block/0'); cy.visit('/block/0');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.waitForPageIdle(); cy.waitForPageIdle();
@ -295,6 +298,7 @@ describe('Mainnet', () => {
it('loads genesis block and keypress arrow left', () => { it('loads genesis block and keypress arrow left', () => {
cy.viewport('macbook-16'); cy.viewport('macbook-16');
cy.visit('/block/0'); cy.visit('/block/0');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.waitForPageIdle(); cy.waitForPageIdle();
@ -323,6 +327,7 @@ describe('Mainnet', () => {
it('loads genesis block and click on the arrow left', () => { it('loads genesis block and click on the arrow left', () => {
cy.viewport('macbook-16'); cy.viewport('macbook-16');
cy.visit('/block/0'); cy.visit('/block/0');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.waitForPageIdle(); cy.waitForPageIdle();
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible'); cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
@ -439,6 +444,7 @@ describe('Mainnet', () => {
describe('blocks', () => { describe('blocks', () => {
it('shows empty blocks properly', () => { it('shows empty blocks properly', () => {
cy.visit('/block/0000000000000000000bd14f744ef2e006e61c32214670de7eb891a5732ee775'); cy.visit('/block/0000000000000000000bd14f744ef2e006e61c32214670de7eb891a5732ee775');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.waitForPageIdle(); cy.waitForPageIdle();
cy.get('h2').invoke('text').should('equal', '1 transaction'); cy.get('h2').invoke('text').should('equal', '1 transaction');
@ -446,6 +452,7 @@ describe('Mainnet', () => {
it('expands and collapses the block details', () => { it('expands and collapses the block details', () => {
cy.visit('/block/0'); cy.visit('/block/0');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.waitForPageIdle(); cy.waitForPageIdle();
cy.get('.btn.btn-outline-info').click().then(() => { cy.get('.btn.btn-outline-info').click().then(() => {
@ -458,6 +465,7 @@ describe('Mainnet', () => {
}); });
it('shows blocks with no pagination', () => { it('shows blocks with no pagination', () => {
cy.visit('/block/00000000000000000001ba40caf1ad4cec0ceb77692662315c151953bfd7c4c4'); cy.visit('/block/00000000000000000001ba40caf1ad4cec0ceb77692662315c151953bfd7c4c4');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.waitForPageIdle(); cy.waitForPageIdle();
cy.get('.block-tx-title h2').invoke('text').should('equal', '19 transactions'); cy.get('.block-tx-title h2').invoke('text').should('equal', '19 transactions');
@ -467,6 +475,7 @@ describe('Mainnet', () => {
it('supports pagination on the block screen', () => { it('supports pagination on the block screen', () => {
// 41 txs // 41 txs
cy.visit('/block/00000000000000000009f9b7b0f63ad50053ad12ec3b7f5ca951332f134f83d8'); cy.visit('/block/00000000000000000009f9b7b0f63ad50053ad12ec3b7f5ca951332f134f83d8');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.get('.pagination-container a').invoke('text').then((text1) => { cy.get('.pagination-container a').invoke('text').then((text1) => {
cy.get('.active + li').first().click().then(() => { cy.get('.active + li').first().click().then(() => {
@ -482,6 +491,7 @@ describe('Mainnet', () => {
it('shows blocks pagination with 5 pages (desktop)', () => { it('shows blocks pagination with 5 pages (desktop)', () => {
cy.viewport(760, 800); cy.viewport(760, 800);
cy.visit('/block/000000000000000000049281946d26fcba7d99fdabc1feac524bc3a7003d69b3').then(() => { cy.visit('/block/000000000000000000049281946d26fcba7d99fdabc1feac524bc3a7003d69b3').then(() => {
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.waitForPageIdle(); cy.waitForPageIdle();
}); });
@ -493,6 +503,7 @@ describe('Mainnet', () => {
it('shows blocks pagination with 3 pages (mobile)', () => { it('shows blocks pagination with 3 pages (mobile)', () => {
cy.viewport(669, 800); cy.viewport(669, 800);
cy.visit('/block/000000000000000000049281946d26fcba7d99fdabc1feac524bc3a7003d69b3').then(() => { cy.visit('/block/000000000000000000049281946d26fcba7d99fdabc1feac524bc3a7003d69b3').then(() => {
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.waitForPageIdle(); cy.waitForPageIdle();
}); });

View File

@ -95,12 +95,14 @@ describe('Signet', () => {
describe('blocks', () => { describe('blocks', () => {
it('shows empty blocks properly', () => { it('shows empty blocks properly', () => {
cy.visit('/signet/block/00000133d54e4589f6436703b067ec23209e0a21b8a9b12f57d0592fd85f7a42'); cy.visit('/signet/block/00000133d54e4589f6436703b067ec23209e0a21b8a9b12f57d0592fd85f7a42');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.get('h2').invoke('text').should('equal', '1 transaction'); cy.get('h2').invoke('text').should('equal', '1 transaction');
}); });
it('expands and collapses the block details', () => { it('expands and collapses the block details', () => {
cy.visit('/signet/block/0'); cy.visit('/signet/block/0');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.get('.btn.btn-outline-info').click().then(() => { cy.get('.btn.btn-outline-info').click().then(() => {
cy.get('#details').should('be.visible'); cy.get('#details').should('be.visible');
@ -113,6 +115,7 @@ describe('Signet', () => {
it('shows blocks with no pagination', () => { it('shows blocks with no pagination', () => {
cy.visit('/signet/block/00000078f920a96a69089877b934ce7fd009ab55e3170920a021262cb258e7cc'); cy.visit('/signet/block/00000078f920a96a69089877b934ce7fd009ab55e3170920a021262cb258e7cc');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.get('h2').invoke('text').should('equal', '13 transactions'); cy.get('h2').invoke('text').should('equal', '13 transactions');
cy.get('ul.pagination').first().children().should('have.length', 5); cy.get('ul.pagination').first().children().should('have.length', 5);
@ -121,6 +124,7 @@ describe('Signet', () => {
it('supports pagination on the block screen', () => { it('supports pagination on the block screen', () => {
// 43 txs // 43 txs
cy.visit('/signet/block/00000094bd52f73bdbfc4bece3a94c21fec2dc968cd54210496e69e4059d66a6'); cy.visit('/signet/block/00000094bd52f73bdbfc4bece3a94c21fec2dc968cd54210496e69e4059d66a6');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.get('.header-bg.box > a').invoke('text').then((text1) => { cy.get('.header-bg.box > a').invoke('text').then((text1) => {
cy.get('.active + li').first().click().then(() => { cy.get('.active + li').first().click().then(() => {

View File

@ -95,12 +95,14 @@ describe('Testnet4', () => {
describe('blocks', () => { describe('blocks', () => {
it('shows empty blocks properly', () => { it('shows empty blocks properly', () => {
cy.visit('/testnet4/block/0'); cy.visit('/testnet4/block/0');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.get('h2').invoke('text').should('equal', '1 transaction'); cy.get('h2').invoke('text').should('equal', '1 transaction');
}); });
it('expands and collapses the block details', () => { it('expands and collapses the block details', () => {
cy.visit('/testnet4/block/0'); cy.visit('/testnet4/block/0');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.get('.btn.btn-outline-info').click().then(() => { cy.get('.btn.btn-outline-info').click().then(() => {
cy.get('#details').should('be.visible'); cy.get('#details').should('be.visible');
@ -113,6 +115,7 @@ describe('Testnet4', () => {
it('shows blocks with no pagination', () => { it('shows blocks with no pagination', () => {
cy.visit('/testnet4/block/000000000066e8b6cc78a93f8989587f5819624bae2eb1c05f535cadded19f99'); cy.visit('/testnet4/block/000000000066e8b6cc78a93f8989587f5819624bae2eb1c05f535cadded19f99');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.get('h2').invoke('text').should('equal', '18 transactions'); cy.get('h2').invoke('text').should('equal', '18 transactions');
cy.get('ul.pagination').first().children().should('have.length', 5); cy.get('ul.pagination').first().children().should('have.length', 5);
@ -121,6 +124,7 @@ describe('Testnet4', () => {
it('supports pagination on the block screen', () => { it('supports pagination on the block screen', () => {
// 48 txs // 48 txs
cy.visit('/testnet4/block/000000000000006982d53f8273bdff21dafc380c292eabc669b5ab6d732311c3'); cy.visit('/testnet4/block/000000000000006982d53f8273bdff21dafc380c292eabc669b5ab6d732311c3');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.get('.header-bg.box > a').invoke('text').then((text1) => { cy.get('.header-bg.box > a').invoke('text').then((text1) => {
cy.get('.active + li').first().click().then(() => { cy.get('.active + li').first().click().then(() => {

View File

@ -12,6 +12,7 @@ import { PriceService } from './services/price.service';
import { EnterpriseService } from './services/enterprise.service'; import { EnterpriseService } from './services/enterprise.service';
import { WebsocketService } from './services/websocket.service'; import { WebsocketService } from './services/websocket.service';
import { AudioService } from './services/audio.service'; import { AudioService } from './services/audio.service';
import { PreloadService } from './services/preload.service';
import { SeoService } from './services/seo.service'; import { SeoService } from './services/seo.service';
import { OpenGraphService } from './services/opengraph.service'; import { OpenGraphService } from './services/opengraph.service';
import { ZoneService } from './services/zone-shim.service'; import { ZoneService } from './services/zone-shim.service';
@ -46,6 +47,7 @@ const providers = [
CapAddressPipe, CapAddressPipe,
AppPreloadingStrategy, AppPreloadingStrategy,
ServicesApiServices, ServicesApiServices,
PreloadService,
{ provide: HTTP_INTERCEPTORS, useClass: HttpCacheInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: HttpCacheInterceptor, multi: true },
{ provide: ZONE_SERVICE, useClass: ZoneService }, { provide: ZONE_SERVICE, useClass: ZoneService },
]; ];

View File

@ -0,0 +1,53 @@
<div #blockTxTitle id="block-tx-title" class="block-tx-title">
<h2 class="text-left">
<ng-container *ngTemplateOutlet="txCount === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: txCount | number}"></ng-container>
<ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} transaction</ng-template>
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} transactions</ng-template>
</h2>
<ngb-pagination class="pagination-container float-right" [collectionSize]="txCount" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page, blockTxTitle)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination>
</div>
<div class="clearfix"></div>
<app-transactions-list *ngIf="transactions$ | async as transactions; else loading" [transactions]="transactions" [paginated]="true" [blockTime]="timestamp"></app-transactions-list>
<ng-template [ngIf]="transactionsError">
<br>
<app-http-error [error]="transactionsError">
<span i18n="error.general-loading-data">Error loading data.</span>
</app-http-error>
<br>
<br>
</ng-template>
<ng-template #loading>
<div class="text-center mb-4" class="tx-skeleton">
<ng-container *ngIf="(txsLoadingStatus$ | async) as txsLoadingStatus; else headerLoader">
<div class="header-bg box">
<div class="progress progress-dark" style="margin: 4px; height: 14px;">
<div class="progress-bar progress-light" role="progressbar" [ngStyle]="{'width': txsLoadingStatus + '%' }"></div>
</div>
</div>
</ng-container>
<div class="header-bg box">
<div class="row">
<div class="col-sm">
<span class="skeleton-loader"></span>
</div>
<div class="col-sm">
<span class="skeleton-loader"></span>
<span class="skeleton-loader"></span>
<span class="skeleton-loader"></span>
</div>
</div>
</div>
</div>
</ng-template>
<ng-template #headerLoader>
<div class="header-bg box">
<span class="skeleton-loader"></span>
</div>
</ng-template>
<ngb-pagination class="pagination-container float-right" [collectionSize]="txCount" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page, blockTxTitle)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination>

View File

@ -0,0 +1,37 @@
.block-tx-title {
display: flex;
justify-content: space-between;
flex-direction: column;
margin-top: -15px;
position: relative;
@media (min-width: 550px) {
margin-top: 1rem;
flex-direction: row;
}
h2 {
line-height: 1;
margin: 0;
position: relative;
padding-bottom: 10px;
@media (min-width: 550px) {
padding-bottom: 0px;
align-self: end;
}
}
}
.tx-skeleton {
margin-top: 10px;
margin-bottom: 10px;
.header-bg {
&:first-child {
padding: 10px;
margin-bottom: 10px;
}
&:nth-child(2) {
.row {
height: 107px;
}
}
}
}

View File

@ -0,0 +1,74 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { StateService } from '../../services/state.service';
import { Transaction, Vout } from '../../interfaces/electrs.interface';
import { Observable, Subscription, catchError, combineLatest, map, of, startWith, switchMap, tap } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { PreloadService } from '../../services/preload.service';
@Component({
selector: 'app-block-transactions',
templateUrl: './block-transactions.component.html',
styleUrl: './block-transactions.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BlockTransactionsComponent implements OnInit {
@Input() txCount: number;
@Input() timestamp: number;
@Input() blockHash: string;
@Input() previousBlockHash: string;
@Input() block$: Observable<any>;
@Input() paginationMaxSize: number;
@Output() blockReward = new EventEmitter<number>();
itemsPerPage = this.stateService.env.ITEMS_PER_PAGE;
page = 1;
transactions$: Observable<Transaction[]>;
isLoadingTransactions = true;
transactionsError: any = null;
transactionSubscription: Subscription;
txsLoadingStatus$: Observable<number>;
nextBlockTxListSubscription: Subscription;
constructor(
private stateService: StateService,
private route: ActivatedRoute,
private router: Router,
private electrsApiService: ElectrsApiService,
) { }
ngOnInit(): void {
this.transactions$ = combineLatest([this.block$, this.route.queryParams]).pipe(
tap(([_, queryParams]) => {
this.page = +queryParams['page'] || 1;
}),
switchMap(([block, _]) => this.electrsApiService.getBlockTransactions$(block.id, (this.page - 1) * this.itemsPerPage)
.pipe(
startWith(null),
catchError((err) => {
this.transactionsError = err;
return of([]);
}))
),
tap((transactions: Transaction[]) => {
// The block API doesn't contain the block rewards on Liquid
if (this.stateService.isLiquid() && transactions && transactions[0] && transactions[0].vin[0].is_coinbase) {
const blockReward = transactions[0].vout.reduce((acc: number, curr: Vout) => acc + curr.value, 0) / 100000000;
this.blockReward.emit(blockReward);
}
})
);
this.txsLoadingStatus$ = this.route.paramMap
.pipe(
switchMap(() => this.stateService.loadingIndicators$),
map((indicators) => indicators['blocktxs-' + this.blockHash] !== undefined ? indicators['blocktxs-' + this.blockHash] : 0)
);
}
pageChange(page: number, target: HTMLElement): void {
target.scrollIntoView(); // works for chrome
this.router.navigate([], { queryParams: { page: page }, queryParamsHandling: 'merge' });
}
}

View File

@ -325,53 +325,39 @@
>Details</button> >Details</button>
</div> </div>
<div #blockTxTitle id="block-tx-title" class="block-tx-title"> @defer (on viewport) {
<h2 class="text-left"> <app-block-transactions [paginationMaxSize]="paginationMaxSize" [block$]="block$" [txCount]="block.tx_count" [timestamp]="block.timestamp" [blockHash]="blockHash" [previousBlockHash]="block.previousblockhash" (blockReward)="updateBlockReward($event)"></app-block-transactions>
<ng-container *ngTemplateOutlet="block.tx_count === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: block.tx_count | number}"></ng-container> } @placeholder {
<ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} transaction</ng-template> <div>
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} transactions</ng-template> <div class="block-tx-title">
</h2> <h2 class="text-left">
<ng-container *ngTemplateOutlet="block.tx_count === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: block.tx_count | number}"></ng-container>
<ngb-pagination class="pagination-container float-right" [collectionSize]="block.tx_count" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page, blockTxTitle)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination> <ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} transaction</ng-template>
</div> <ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} transactions</ng-template>
<div class="clearfix"></div> </h2>
<ngb-pagination class="pagination-container float-right" [disabled]="true" [collectionSize]="block.tx_count" [rotate]="true" [pageSize]="stateService.env.ITEMS_PER_PAGE" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination>
<app-transactions-list [transactions]="transactions" [paginated]="true" [blockTime]="block.timestamp"></app-transactions-list> </div>
<div class="clearfix"></div>
<ng-template [ngIf]="transactionsError"> <div class="text-center mb-4" class="tx-skeleton">
<br>
<app-http-error [error]="transactionsError">
<span i18n="error.general-loading-data">Error loading data.</span>
</app-http-error>
<br>
<br>
</ng-template>
<ng-template [ngIf]="isLoadingTransactions && !transactionsError">
<div class="text-center mb-4" class="tx-skeleton">
<ng-container *ngIf="(txsLoadingStatus$ | async) as txsLoadingStatus; else headerLoader">
<div class="header-bg box"> <div class="header-bg box">
<div class="progress progress-dark" style="margin: 4px; height: 14px;"> <span class="skeleton-loader"></span>
<div class="progress-bar progress-light" role="progressbar" [ngStyle]="{'width': txsLoadingStatus + '%' }"></div>
</div>
</div> </div>
</ng-container> <div class="header-bg box">
<div class="row">
<div class="header-bg box"> <div class="col-sm">
<div class="row"> <span class="skeleton-loader"></span>
<div class="col-sm"> </div>
<span class="skeleton-loader"></span> <div class="col-sm">
<span class="skeleton-loader"></span> <span class="skeleton-loader"></span>
</div> <span class="skeleton-loader"></span>
<div class="col-sm"> <span class="skeleton-loader"></span>
<span class="skeleton-loader"></span> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</ng-template> }
<ngb-pagination class="pagination-container float-right" [collectionSize]="block.tx_count" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page, blockTxTitle)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination>
<div class="clearfix"></div> <div class="clearfix"></div>
<br> <br>
@ -382,12 +368,6 @@
</app-http-error> </app-http-error>
</ng-template> </ng-template>
<ng-template #headerLoader>
<div class="header-bg box">
<span class="skeleton-loader"></span>
</div>
</ng-template>
</div> </div>
<ng-template #emptyBlockInfo> <ng-template #emptyBlockInfo>

View File

@ -21,25 +21,6 @@
} }
} }
.qr-wrapper {
background-color: var(--fg);
padding: 10px;
padding-bottom: 5px;
display: inline-block;
}
.qrcode-col {
text-align: center;
}
.qrcode-col > div {
margin: 20px auto 5px;
@media (min-width: 768px) {
text-align: center;
margin: auto;
}
}
.fiat { .fiat {
display: block; display: block;
font-size: 13px; font-size: 13px;
@ -100,19 +81,7 @@ h1 {
} }
} }
.address-link { .row {
line-height: 26px;
margin-left: 0px;
top: 14px;
position: relative;
display: flex;
flex-direction: row;
@media (min-width: 768px) {
line-height: 38px;
}
}
.row{
flex-direction: column; flex-direction: column;
@media (min-width: 768px) { @media (min-width: 768px) {
flex-direction: row; flex-direction: row;
@ -140,28 +109,6 @@ h1 {
margin-right: .5em; margin-right: .5em;
} }
.block-tx-title {
display: flex;
justify-content: space-between;
flex-direction: column;
margin-top: -15px;
position: relative;
@media (min-width: 550px) {
margin-top: 1rem;
flex-direction: row;
}
h2 {
line-height: 1;
margin: 0;
position: relative;
padding-bottom: 10px;
@media (min-width: 550px) {
padding-bottom: 0px;
align-self: end;
}
}
}
.grow { .grow {
flex-grow: 1; flex-grow: 1;
} }
@ -204,22 +151,6 @@ h1 {
} }
} }
.tx-skeleton {
margin-top: 10px;
margin-bottom: 10px;
.header-bg {
&:first-child {
padding: 10px;
margin-bottom: 10px;
}
&:nth-child(2) {
.row {
height: 107px;
}
}
}
}
.chart-container{ .chart-container{
margin: 20px auto; margin: 20px auto;
@media (min-width: 768px) { @media (min-width: 768px) {
@ -303,3 +234,41 @@ h1 {
.graph-col { .graph-col {
flex-grow: 1.11; flex-grow: 1.11;
} }
.block-tx-title {
display: flex;
justify-content: space-between;
flex-direction: column;
margin-top: -15px;
position: relative;
@media (min-width: 550px) {
margin-top: 1rem;
flex-direction: row;
}
h2 {
line-height: 1;
margin: 0;
position: relative;
padding-bottom: 10px;
@media (min-width: 550px) {
padding-bottom: 0px;
align-self: end;
}
}
}
.tx-skeleton {
margin-top: 10px;
margin-bottom: 10px;
.header-bg {
&:first-child {
padding: 10px;
margin-bottom: 10px;
}
&:nth-child(2) {
.row {
height: 107px;
}
}
}
}

View File

@ -1,15 +1,14 @@
import { Component, OnInit, OnDestroy, ViewChildren, QueryList, Inject, PLATFORM_ID, ChangeDetectorRef } from '@angular/core'; import { Component, OnInit, OnDestroy, ViewChildren, QueryList, ChangeDetectorRef } from '@angular/core';
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { ActivatedRoute, ParamMap, Router } from '@angular/router'; import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { ElectrsApiService } from '../../services/electrs-api.service'; import { ElectrsApiService } from '../../services/electrs-api.service';
import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, filter } from 'rxjs/operators'; import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, filter } from 'rxjs/operators';
import { Transaction, Vout } from '../../interfaces/electrs.interface';
import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest, forkJoin } from 'rxjs'; import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest, forkJoin } from 'rxjs';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { WebsocketService } from '../../services/websocket.service'; import { WebsocketService } from '../../services/websocket.service';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { AccelerationInfo, BlockAudit, BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface'; import { BlockAudit, BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component';
import { detectWebGL } from '../../shared/graphs.utils'; import { detectWebGL } from '../../shared/graphs.utils';
@ -17,6 +16,7 @@ import { seoDescriptionNetwork } from '../../shared/common.utils';
import { PriceService, Price } from '../../services/price.service'; import { PriceService, Price } from '../../services/price.service';
import { CacheService } from '../../services/cache.service'; import { CacheService } from '../../services/cache.service';
import { ServicesApiServices } from '../../services/services-api.service'; import { ServicesApiServices } from '../../services/services-api.service';
import { PreloadService } from '../../services/preload.service';
@Component({ @Component({
selector: 'app-block', selector: 'app-block',
@ -42,23 +42,17 @@ export class BlockComponent implements OnInit, OnDestroy {
isLoadingBlock = true; isLoadingBlock = true;
latestBlock: BlockExtended; latestBlock: BlockExtended;
latestBlocks: BlockExtended[] = []; latestBlocks: BlockExtended[] = [];
transactions: Transaction[];
oobFees: number = 0; oobFees: number = 0;
isLoadingTransactions = true;
strippedTransactions: TransactionStripped[]; strippedTransactions: TransactionStripped[];
overviewTransitionDirection: string; overviewTransitionDirection: string;
isLoadingOverview = true; isLoadingOverview = true;
error: any; error: any;
blockSubsidy: number; blockSubsidy: number;
fees: number; fees: number;
paginationMaxSize: number; block$: Observable<any>;
page = 1;
itemsPerPage: number;
txsLoadingStatus$: Observable<number>;
showDetails = false; showDetails = false;
showPreviousBlocklink = true; showPreviousBlocklink = true;
showNextBlocklink = true; showNextBlocklink = true;
transactionsError: any = null;
overviewError: any = null; overviewError: any = null;
webGlEnabled = true; webGlEnabled = true;
auditParamEnabled: boolean = false; auditParamEnabled: boolean = false;
@ -69,20 +63,16 @@ export class BlockComponent implements OnInit, OnDestroy {
isMobile = window.innerWidth <= 767.98; isMobile = window.innerWidth <= 767.98;
hoverTx: string; hoverTx: string;
numMissing: number = 0; numMissing: number = 0;
paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
numUnexpected: number = 0; numUnexpected: number = 0;
mode: 'projected' | 'actual' = 'projected'; mode: 'projected' | 'actual' = 'projected';
transactionSubscription: Subscription;
overviewSubscription: Subscription; overviewSubscription: Subscription;
auditSubscription: Subscription;
keyNavigationSubscription: Subscription; keyNavigationSubscription: Subscription;
blocksSubscription: Subscription; blocksSubscription: Subscription;
cacheBlocksSubscription: Subscription; cacheBlocksSubscription: Subscription;
networkChangedSubscription: Subscription; networkChangedSubscription: Subscription;
queryParamsSubscription: Subscription; queryParamsSubscription: Subscription;
nextBlockSubscription: Subscription = undefined;
nextBlockSummarySubscription: Subscription = undefined;
nextBlockTxListSubscription: Subscription = undefined;
timeLtrSubscription: Subscription; timeLtrSubscription: Subscription;
timeLtr: boolean; timeLtr: boolean;
childChangeSubscription: Subscription; childChangeSubscription: Subscription;
@ -109,16 +99,14 @@ export class BlockComponent implements OnInit, OnDestroy {
private cacheService: CacheService, private cacheService: CacheService,
private servicesApiService: ServicesApiServices, private servicesApiService: ServicesApiServices,
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef,
@Inject(PLATFORM_ID) private platformId: Object, private preloadService: PreloadService,
) { ) {
this.webGlEnabled = this.stateService.isBrowser && detectWebGL(); this.webGlEnabled = this.stateService.isBrowser && detectWebGL();
} }
ngOnInit() { ngOnInit(): void {
this.websocketService.want(['blocks', 'mempool-blocks']); this.websocketService.want(['blocks', 'mempool-blocks']);
this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
this.network = this.stateService.network; this.network = this.stateService.network;
this.itemsPerPage = this.stateService.env.ITEMS_PER_PAGE;
this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => {
this.timeLtr = !!ltr; this.timeLtr = !!ltr;
@ -139,12 +127,6 @@ export class BlockComponent implements OnInit, OnDestroy {
}); });
} }
this.txsLoadingStatus$ = this.route.paramMap
.pipe(
switchMap(() => this.stateService.loadingIndicators$),
map((indicators) => indicators['blocktxs-' + this.blockHash] !== undefined ? indicators['blocktxs-' + this.blockHash] : 0)
);
this.cacheBlocksSubscription = this.cacheService.loadedBlocks$.subscribe((block) => { this.cacheBlocksSubscription = this.cacheService.loadedBlocks$.subscribe((block) => {
this.loadedCacheBlock(block); this.loadedCacheBlock(block);
}); });
@ -172,11 +154,10 @@ export class BlockComponent implements OnInit, OnDestroy {
} }
}); });
const block$ = this.route.paramMap.pipe( this.block$ = this.route.paramMap.pipe(
switchMap((params: ParamMap) => { switchMap((params: ParamMap) => {
const blockHash: string = params.get('id') || ''; const blockHash: string = params.get('id') || '';
this.block = undefined; this.block = undefined;
this.page = 1;
this.error = undefined; this.error = undefined;
this.fees = undefined; this.fees = undefined;
this.oobFees = 0; this.oobFees = 0;
@ -254,16 +235,11 @@ export class BlockComponent implements OnInit, OnDestroy {
} }
}), }),
tap((block: BlockExtended) => { tap((block: BlockExtended) => {
if (block.height > 0) { if (block.previousblockhash) {
// Preload previous block summary (execute the http query so the response will be cached) this.preloadService.block$.next(block.previousblockhash);
this.unsubscribeNextBlockSubscriptions(); if (this.auditSupported) {
setTimeout(() => { this.preloadService.blockAudit$.next(block.previousblockhash);
this.nextBlockSubscription = this.apiService.getBlock$(block.previousblockhash).subscribe(); }
this.nextBlockTxListSubscription = this.electrsApiService.getBlockTransactions$(block.previousblockhash).subscribe();
if (this.auditSupported) {
this.apiService.getBlockAudit$(block.previousblockhash);
}
}, 100);
} }
this.updateAuditAvailableFromBlockHeight(block.height); this.updateAuditAvailableFromBlockHeight(block.height);
this.block = block; this.block = block;
@ -288,9 +264,6 @@ export class BlockComponent implements OnInit, OnDestroy {
this.fees = block.extras.reward / 100000000 - this.blockSubsidy; this.fees = block.extras.reward / 100000000 - this.blockSubsidy;
} }
this.stateService.markBlock$.next({ blockHeight: this.blockHeight }); this.stateService.markBlock$.next({ blockHeight: this.blockHeight });
this.isLoadingTransactions = true;
this.transactions = null;
this.transactionsError = null;
this.isLoadingOverview = true; this.isLoadingOverview = true;
this.overviewError = null; this.overviewError = null;
@ -304,31 +277,8 @@ export class BlockComponent implements OnInit, OnDestroy {
throttleTime(300, asyncScheduler, { leading: true, trailing: true }), throttleTime(300, asyncScheduler, { leading: true, trailing: true }),
shareReplay(1) shareReplay(1)
); );
this.transactionSubscription = combineLatest([block$, this.route.queryParams]).pipe(
tap(([_, queryParams]) => this.page = +queryParams['page'] || 1),
switchMap(([block, _]) => this.electrsApiService.getBlockTransactions$(block.id, (this.page - 1) * this.itemsPerPage)
.pipe(
catchError((err) => {
this.transactionsError = err;
return of([]);
}))
),
)
.subscribe((transactions: Transaction[]) => {
if (this.fees === undefined && transactions[0]) {
this.fees = transactions[0].vout.reduce((acc: number, curr: Vout) => acc + curr.value, 0) / 100000000 - this.blockSubsidy;
}
this.transactions = transactions;
this.isLoadingTransactions = false;
this.cd.markForCheck();
},
(error) => {
this.error = error;
this.isLoadingBlock = false;
this.isLoadingOverview = false;
});
this.overviewSubscription = block$.pipe( this.overviewSubscription = this.block$.pipe(
switchMap((block) => { switchMap((block) => {
return forkJoin([ return forkJoin([
this.apiService.getStrippedBlockTransactions$(block.id) this.apiService.getStrippedBlockTransactions$(block.id)
@ -498,14 +448,14 @@ export class BlockComponent implements OnInit, OnDestroy {
this.cd.markForCheck(); this.cd.markForCheck();
}); });
this.oobSubscription = block$.pipe( this.oobSubscription = this.block$.pipe(
filter(() => this.stateService.env.PUBLIC_ACCELERATIONS === true && this.stateService.network === ''), filter(() => this.stateService.env.PUBLIC_ACCELERATIONS === true && this.stateService.network === ''),
switchMap((block) => this.apiService.getAccelerationsByHeight$(block.height) switchMap((block) => this.apiService.getAccelerationsByHeight$(block.height)
.pipe( .pipe(
map(accelerations => { map(accelerations => {
return { block, accelerations }; return { block, accelerations };
}), }),
catchError((err) => { catchError(() => {
return of({ block, accelerations: [] }); return of({ block, accelerations: [] });
})) }))
), ),
@ -560,7 +510,7 @@ export class BlockComponent implements OnInit, OnDestroy {
if (this.priceSubscription) { if (this.priceSubscription) {
this.priceSubscription.unsubscribe(); this.priceSubscription.unsubscribe();
} }
this.priceSubscription = combineLatest([this.stateService.fiatCurrency$, block$]).pipe( this.priceSubscription = combineLatest([this.stateService.fiatCurrency$, this.block$]).pipe(
switchMap(([currency, block]) => { switchMap(([currency, block]) => {
return this.priceService.getBlockPrice$(block.timestamp, true, currency).pipe( return this.priceService.getBlockPrice$(block.timestamp, true, currency).pipe(
tap((price) => { tap((price) => {
@ -577,52 +527,27 @@ export class BlockComponent implements OnInit, OnDestroy {
}); });
} }
ngOnDestroy() { ngOnDestroy(): void {
this.stateService.markBlock$.next({}); this.stateService.markBlock$.next({});
this.transactionSubscription?.unsubscribe();
this.overviewSubscription?.unsubscribe(); this.overviewSubscription?.unsubscribe();
this.auditSubscription?.unsubscribe();
this.keyNavigationSubscription?.unsubscribe(); this.keyNavigationSubscription?.unsubscribe();
this.blocksSubscription?.unsubscribe(); this.blocksSubscription?.unsubscribe();
this.cacheBlocksSubscription?.unsubscribe(); this.cacheBlocksSubscription?.unsubscribe();
this.networkChangedSubscription?.unsubscribe(); this.networkChangedSubscription?.unsubscribe();
this.queryParamsSubscription?.unsubscribe(); this.queryParamsSubscription?.unsubscribe();
this.timeLtrSubscription?.unsubscribe(); this.timeLtrSubscription?.unsubscribe();
this.auditSubscription?.unsubscribe();
this.unsubscribeNextBlockSubscriptions();
this.childChangeSubscription?.unsubscribe(); this.childChangeSubscription?.unsubscribe();
this.priceSubscription?.unsubscribe(); this.priceSubscription?.unsubscribe();
this.oobSubscription?.unsubscribe(); this.oobSubscription?.unsubscribe();
} }
unsubscribeNextBlockSubscriptions() {
if (this.nextBlockSubscription !== undefined) {
this.nextBlockSubscription.unsubscribe();
}
if (this.nextBlockSummarySubscription !== undefined) {
this.nextBlockSummarySubscription.unsubscribe();
}
if (this.nextBlockTxListSubscription !== undefined) {
this.nextBlockTxListSubscription.unsubscribe();
}
}
// TODO - Refactor this.fees/this.reward for liquid because it is not // TODO - Refactor this.fees/this.reward for liquid because it is not
// used anymore on Bitcoin networks (we use block.extras directly) // used anymore on Bitcoin networks (we use block.extras directly)
setBlockSubsidy() { setBlockSubsidy(): void {
this.blockSubsidy = 0; this.blockSubsidy = 0;
} }
pageChange(page: number, target: HTMLElement) { toggleShowDetails(): void {
const start = (page - 1) * this.itemsPerPage;
this.isLoadingTransactions = true;
this.transactions = null;
this.transactionsError = null;
target.scrollIntoView(); // works for chrome
this.router.navigate([], { queryParams: { page: page }, queryParamsHandling: 'merge' });
}
toggleShowDetails() {
if (this.showDetails) { if (this.showDetails) {
this.showDetails = false; this.showDetails = false;
this.router.navigate([], { this.router.navigate([], {
@ -654,7 +579,7 @@ export class BlockComponent implements OnInit, OnDestroy {
return this.block && this.block.height > 681393 && (new Date().getTime() / 1000) < 1628640000; return this.block && this.block.height > 681393 && (new Date().getTime() / 1000) < 1628640000;
} }
navigateToPreviousBlock() { navigateToPreviousBlock(): void {
if (!this.block) { if (!this.block) {
return; return;
} }
@ -663,13 +588,13 @@ export class BlockComponent implements OnInit, OnDestroy {
block ? block.id : this.block.previousblockhash], { state: { data: { block, blockHeight: this.nextBlockHeight - 2 } } }); block ? block.id : this.block.previousblockhash], { state: { data: { block, blockHeight: this.nextBlockHeight - 2 } } });
} }
navigateToNextBlock() { navigateToNextBlock(): void {
const block = this.latestBlocks.find((b) => b.height === this.nextBlockHeight); const block = this.latestBlocks.find((b) => b.height === this.nextBlockHeight);
this.router.navigate([this.relativeUrlPipe.transform('/block/'), this.router.navigate([this.relativeUrlPipe.transform('/block/'),
block ? block.id : this.nextBlockHeight], { state: { data: { block, blockHeight: this.nextBlockHeight } } }); block ? block.id : this.nextBlockHeight], { state: { data: { block, blockHeight: this.nextBlockHeight } } });
} }
setNextAndPreviousBlockLink(){ setNextAndPreviousBlockLink(): void {
if (this.latestBlock) { if (this.latestBlock) {
if (!this.blockHeight){ if (!this.blockHeight){
this.showPreviousBlocklink = false; this.showPreviousBlocklink = false;
@ -701,11 +626,12 @@ export class BlockComponent implements OnInit, OnDestroy {
} }
} }
onResize(event: any): void { onResize(event: Event): void {
const isMobile = event.target.innerWidth <= 767.98; const target = event.target as Window;
const isMobile = target.innerWidth <= 767.98;
const changed = isMobile !== this.isMobile; const changed = isMobile !== this.isMobile;
this.isMobile = isMobile; this.isMobile = isMobile;
this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5; this.paginationMaxSize = target.innerWidth < 670 ? 3 : 5;
if (changed) { if (changed) {
this.changeMode(this.mode); this.changeMode(this.mode);
@ -747,11 +673,11 @@ export class BlockComponent implements OnInit, OnDestroy {
this.stateService.hideAudit.next(this.auditModeEnabled); this.stateService.hideAudit.next(this.auditModeEnabled);
this.route.queryParams.subscribe(params => { this.route.queryParams.subscribe(params => {
let queryParams = { ...params }; const queryParams = { ...params };
delete queryParams['audit']; delete queryParams['audit'];
let newUrl = this.router.url.split('?')[0]; let newUrl = this.router.url.split('?')[0];
let queryString = new URLSearchParams(queryParams).toString(); const queryString = new URLSearchParams(queryParams).toString();
if (queryString) { if (queryString) {
newUrl += '?' + queryString; newUrl += '?' + queryString;
} }
@ -829,4 +755,10 @@ export class BlockComponent implements OnInit, OnDestroy {
this.block.canonical = block.id; this.block.canonical = block.id;
} }
} }
updateBlockReward(blockReward: number): void {
if (this.fees === undefined) {
this.fees = blockReward;
}
}
} }

View File

@ -2,6 +2,7 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Routes, RouterModule } from '@angular/router'; import { Routes, RouterModule } from '@angular/router';
import { BlockComponent } from './block.component'; import { BlockComponent } from './block.component';
import { BlockTransactionsComponent } from './block-transactions.component';
import { SharedModule } from '../../shared/shared.module'; import { SharedModule } from '../../shared/shared.module';
const routes: Routes = [ const routes: Routes = [
@ -32,6 +33,7 @@ export class BlockRoutingModule { }
], ],
declarations: [ declarations: [
BlockComponent, BlockComponent,
BlockTransactionsComponent,
] ]
}) })
export class BlockModule { } export class BlockModule { }

View File

@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import { ElectrsApiService } from '../services/electrs-api.service';
import { Subject, debounceTime, switchMap } from 'rxjs';
import { ApiService } from './api.service';
@Injectable({
providedIn: 'root'
})
export class PreloadService {
block$ = new Subject<string>;
blockAudit$ = new Subject<string>;
debounceTime = 250;
constructor(
private electrsApiService: ElectrsApiService,
private apiService: ApiService,
) {
this.block$
.pipe(
debounceTime(this.debounceTime),
switchMap((blockHash) => this.electrsApiService.getBlockTransactions$(blockHash))
)
.subscribe();
this.blockAudit$
.pipe(
debounceTime(this.debounceTime),
switchMap((blockHash) => this.apiService.getBlockAudit$(blockHash))
)
.subscribe();
}
}