This commit is contained in:
Rodrigo Bonilla 2026-08-13 11:34:31 +07:00 committed by GitHub
commit cf2f6d36ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 990 additions and 1 deletions

View file

@ -0,0 +1,193 @@
<div class="container-xl">
@if (!error || loadingCachedTx) {
<div class="title-block">
<h1 i18n="shared.bitcoin.payment">Bitcoin payment</h1>
<span class="tx-link">
<span class="txid">
<app-truncate [text]="txId" [lastChars]="12" [link]="['/tx/' | relativeUrl, txId]">
<app-clipboard [text]="txId"></app-clipboard>
</app-truncate>
</span>
</span>
</div>
<div class="clearfix"></div>
<div class="box">
<div class="row">
@if (isMobile) {
<div class="col-sm">
<ng-container *ngTemplateOutlet="amountSection"></ng-container>
<ng-container *ngTemplateOutlet="trackerPanel"></ng-container>
<table class="table table-borderless table-striped">
<tbody>
<ng-container *ngTemplateOutlet="rows"></ng-container>
</tbody>
</table>
</div>
} @else {
<div class="col-sm">
<ng-container *ngTemplateOutlet="trackerPanel"></ng-container>
<table class="table table-borderless table-striped">
<tbody>
<ng-container *ngTemplateOutlet="rows"></ng-container>
</tbody>
</table>
</div>
<div class="col-sm amount-side">
<ng-container *ngTemplateOutlet="amountSection"></ng-container>
</div>
}
</div>
</div>
<div class="details-link">
<button type="button" class="btn btn-sm btn-outline-info" (click)="viewFullDetails()">
<span i18n="transaction.view-full-details">View full transaction details</span>
</button>
</div>
}
@if (error && !loadingCachedTx) {
<app-http-error [error]="error">
<span i18n="transaction.error.loading-transaction-data">Error loading transaction data.</span>
</app-http-error>
}
</div>
<ng-template #amountSection>
<div style="position: relative;">
@if (viewAmountMode$ | async; as mode) {
<div class="toggler">
<a href="" (click)="changeMode('btc')" class="toggler-option"
[class.inactive]="mode === 'sats'"><small>BTC</small></a>
<span style="color: var(--transparent-fg); font-size: 8px"> | </span>
<a href="" (click)="changeMode('sats')" class="toggler-option"
[class.inactive]="mode !== 'sats'"><small>Sats</small></a>
</div>
<div class="amount-col">
<span class="text" i18n="transaction.amount|Amount">Amount</span>
<a class="amount" href="" (click)="changeMode(mode === 'sats' ? 'btc' : 'sats')">
<app-amount [satoshis]="amount" [blockConversion]="tx.price" [noFiat]="true"></app-amount>
</a>
<app-fiat class="fiat" [value]="amount" [blockConversion]="tx.price"></app-fiat>
</div>
}
</div>
</ng-template>
<ng-template #rows>
<ng-container *ngTemplateOutlet="addressRow"></ng-container><!--
<ng-container *ngTemplateOutlet="amountRow"></ng-container> -->
<ng-container *ngTemplateOutlet="statusRow"></ng-container>
</ng-template>
<ng-template #addressRow>
@if (isValidView) {
<tr>
<td class="td-width" i18n="shared.address">Address</td>
<td>
<app-truncate class="address" [text]="destination" [lastChars]="4"
[link]="['/address/' | relativeUrl, destination]"></app-truncate>
</td>
</tr>
} @else {
<ng-container *ngTemplateOutlet="skeletonRow"></ng-container>
}
</ng-template>
<ng-template #amountRow>
@if (tx) {
<tr>
<td class="td-width" i18n="transaction.amount|Amount">Amount</td>
<td class="wrap-cell">
<app-amount [satoshis]="amount" [blockConversion]="tx.price" [noFiat]="true"></app-amount>
<span class="fiat">
<app-fiat [value]="amount" [blockConversion]="tx.price"></app-fiat>
</span>
</td>
</tr>
} @else {
<ng-container *ngTemplateOutlet="skeletonRow"></ng-container>
}
</ng-template>
<ng-template #statusRow>
@if (tx && !replaced) {
<tr>
<td class="td-width" i18n="transaction.status|Transaction Status">Status</td>
<td>
@if (!tx.status?.confirmed) {
<ng-container *ngIf="(ETA$ | async) as eta; else etaSkeleton">
@if (eta.blocks >= 7) {
<span i18n="transaction.eta.not-any-time-soon|Transaction ETA mot any time soon">Not any time soon</span>
} @else {
<app-time kind="until" [time]="eta.time" [fastRender]="false"></app-time>
}
</ng-container>
<ng-template #etaSkeleton>
<span class="skeleton-loader"></span>
</ng-template>
} @else {
<ng-container *ngTemplateOutlet="confs"></ng-container>
}
</td>
</tr>
} @else if (replaced) {
} @else {
<ng-container *ngTemplateOutlet="skeletonRow"></ng-container>
}
</ng-template>
<ng-template #confs>
@if (settled) {
<span class="settled" i18n="transaction.status.settled|Settled">Settled</span>
} @else if (isValidView && confsRequired > 1) {
<span class="confs-cell">
<span>
<span>{{ confirmations }} <span i18n="shared.of">of</span> {{ confsRequired }} </span><span i18n="shared.confirmations">Confirmations</span>
</span>
<ng-container *ngTemplateOutlet="confsProgress"></ng-container>
</span>
} @else {
<span class="skeleton-loader"></span>
}
</ng-template>
<ng-template #confsProgress>
<span class="confs">
@for (conf of confsRequiredArr; track $index) {
<span>
<svg width="25px" height="25px" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<path class="confirmation" [class.pending]="($index + 1) > confirmations"
[class.animate]="($index + 1) === (confirmations + 1)" d="M23.05 23.05V488.9H488.9V23.05H23.05z" />
</svg>
</span>
}
</span>
</ng-template>
<ng-template #trackerPanel>
@if (replaced) {
<div class="alert-replaced" role="alert">
<span i18n="transaction.rbf.replacement|RBF replacement">This transaction has been replaced by:</span>
<a [routerLink]="['/tx/' | relativeUrl, latestReplacement]" [queryParams]="{ mode: 'status' }">{{ latestReplacement
}}</a>
</div>
} @else if (tx && !isCached) {
<div class="tracker-bar">
<app-tracker-bar [stage]="trackerStage"></app-tracker-bar>
</div>
} @else {
<ng-container *ngTemplateOutlet="skeletonRow"></ng-container>
}
</ng-template>
<ng-template #skeletonRow>
<tr>
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
</ng-template>

View file

@ -0,0 +1,242 @@
.title-block {
flex-wrap: wrap;
align-items: baseline;
@media (min-width: 650px) {
flex-direction: row;
}
h1 {
margin: 0;
margin-right: 15px;
line-height: 1;
}
}
.tx-link {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: baseline;
width: 0;
max-width: 100%;
margin-top: 8px;
@media (min-width: 651px) {
flex-grow: 1;
margin-right: 1em;
top: 1px;
position: relative;
}
@media (max-width: 650px) {
width: 100%;
order: 3;
}
.txid {
width: 200px;
min-width: 200px;
flex-grow: 1;
}
}
.container-buttons {
align-self: center;
}
.row {
flex-direction: column;
@media (min-width: 850px) {
flex-direction: row;
}
}
.td-width {
width: 150px;
@media (max-width: 768px) {
width: 100px;
}
}
.table {
width: 100%;
table-layout: fixed;
tr td {
padding: 0.75rem 0.5rem;
@media (min-width: 576px) {
padding: 0.75rem 0.75rem;
}
&:last-child {
text-align: right;
@media (min-width: 850px) {
text-align: left;
}
}
&.wrap-cell {
white-space: normal;
}
}
}
.address ::ng-deep .truncate {
justify-content: end !important;
@media (min-width: 850px) {
justify-content: start !important;
}
}
.fiat {
display: block;
@media (min-width: 768px) {
display: inline-block;
margin-left: 15px;
text-align: left;
}
}
.badge {
position: relative;
top: -1px;
}
.details-link {
margin-top: 20px;
text-align: center;
}
.tracker-bar {
padding: 1em 0.5em 0;
margin-bottom: 1em;
width: 100%;
}
.settled {
color: var(--green);
opacity: 0.8;
}
.confs-cell {
display: flex;
gap: 1em;
flex-direction: row;
align-items: center;
justify-content: end;
flex-wrap: wrap;
@media (min-width: 850px) {
justify-content: start;
}
}
.confs {
display: flex;
gap: 2px;
}
.confirmation {
fill: var(--green);
fill-opacity: 0.8;
&.pending {
fill-opacity: 0.25;
&.animate {
animation: pulse 2s ease-in-out infinite;
}
}
}
.alert-replaced {
display: flex;
flex-direction: column;
min-width: 0;
background: var(--red);
margin-bottom: 1em;
padding: 0.5em;
border-radius: 0.5em;
@media (max-width: 850px) {
margin-top: 1em;
}
a {
overflow-wrap: anywhere;
}
}
.amount-side {
@media (min-width: 850px) {
display: flex;
flex-direction: column;
justify-content: center;
}
}
.amount-col {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 1.5rem 1rem;
background-color: var(--stat-box-bg);
.text {
font-size: 0.8125rem;
letter-spacing: 0.05em;
color: var(--transparent-fg);
}
app-amount {
font-size: 2rem;
font-weight: 600;
line-height: 1.1;
}
.fiat {
display: block;
margin-left: 0;
font-size: 1rem;
text-align: center;
color: var(--transparent-fg);
}
}
.toggler {
font-size: 12px;
position: absolute;
top: -20px;
right: 3px;
text-align: right;
@media (max-width: 850px) {
top: 2px;
}
}
.toggler-option {
text-decoration: none;
}
.inactive {
color: var(--transparent-fg);
}
.amount {
text-decoration: none;
color: white;
width: 100%;
text-align: center;
}
@keyframes pulse {
0% {
fill-opacity: 0.30;
}
50% {
fill-opacity: 0.6;
}
100% {
fill-opacity: 0.30;
}
}

View file

@ -0,0 +1,482 @@
import { Component, OnInit, OnDestroy, HostListener, ChangeDetectorRef, Inject } from '@angular/core';
import { ElectrsApiService } from '@app/services/electrs-api.service';
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { switchMap, filter, catchError, map, startWith, distinctUntilChanged, tap } from 'rxjs/operators';
import { Transaction } from '@interfaces/electrs.interface';
import { of, merge, Subscription, Observable, combineLatest, BehaviorSubject, Subject } from 'rxjs';
import { StateService } from '@app/services/state.service';
import { AudioService } from '@app/services/audio.service';
import { CacheService } from '@app/services/cache.service';
import { WebsocketService } from '@app/services/websocket.service';
import { SeoService } from '@app/services/seo.service';
import { seoDescriptionNetwork } from '@app/shared/common.utils';
import { getUnacceleratedFeeRate } from '@app/shared/transaction.utils';
import { BlockExtended, MempoolPosition, AccelerationPosition, RbfTree } from '@interfaces/node-api.interface';
import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe';
import { ZONE_SERVICE } from '@app/injection-tokens';
import { MiningService, MiningStats } from '@app/services/mining.service';
import { ETA, EtaService } from '@app/services/eta.service';
import { getRegex } from '@app/shared/regex.utils';
import { TrackerStage } from '@components/tracker/tracker-bar.component';
import { ApiService } from '@app/services/api.service';
import { StorageService } from '@app/services/storage.service';
@Component({
selector: 'app-payment',
templateUrl: './payment.component.html',
styleUrls: ['./payment.component.scss'],
standalone: false,
})
export class PaymentComponent implements OnInit, OnDestroy {
network = '';
tx: Transaction;
txId: string;
mempoolPosition: MempoolPosition;
accelerationPositions: AccelerationPosition[];
latestBlock: BlockExtended;
isLoadingTx = true;
error: any = undefined;
waitingForTransaction = false;
isMobile: boolean;
isValidView = false;
rbfTransaction: Transaction | null;
destination = '';
confsRequired = 1;
amount = 0;
confirmations = 0;
settled = false;
replaced = false;
trackerStage: TrackerStage = 'waiting';
miningStats: MiningStats;
ETA$: Observable<ETA | null>;
txChanged$ = new BehaviorSubject<boolean>(false);
isAccelerated$ = new BehaviorSubject<boolean>(false);
isCached: boolean;
rbfInfo: RbfTree;
fetchRbfHistory$ = new Subject<string>();
fetchCachedTx$ = new Subject<string>();
loadingCachedTx: boolean;
subscription: Subscription;
networkChangedSubscription: Subscription;
blocksSubscription: Subscription;
mempoolPositionSubscription: Subscription;
txConfirmedSubscription: Subscription;
txReplacedSubscription: Subscription;
fetchRbfSubscription: Subscription;
fetchCachedTxSubscription: Subscription;
txRbfInfoSubscription: Subscription;
latestReplacement: string;
amountMode: string;
viewAmountMode$: Observable<'btc' | 'sats' | 'fiat'>;
constructor(
private route: ActivatedRoute,
private router: Router,
private relativeUrlPipe: RelativeUrlPipe,
private apiService: ApiService,
private electrsApiService: ElectrsApiService,
public stateService: StateService,
private audioService: AudioService,
private cacheService: CacheService,
private websocketService: WebsocketService,
private seoService: SeoService,
private miningService: MiningService,
private etaService: EtaService,
private storageService: StorageService,
private cd: ChangeDetectorRef,
@Inject(ZONE_SERVICE) private zoneService: any,
) {}
ngOnInit(): void {
this.onResize();
this.viewAmountMode$ = this.stateService.viewAmountMode$.asObservable();
if (!this.stateService.isLiquid()) {
this.miningService.getMiningStats('1m').subscribe(stats => {
this.miningStats = stats;
});
}
this.websocketService.want(['blocks', 'mempool-blocks']);
this.networkChangedSubscription = this.stateService.networkChanged$.subscribe((network) => {
this.network = network;
});
this.blocksSubscription = this.stateService.blocks$.subscribe((blocks) => {
this.latestBlock = blocks[0];
this.updateConfirmations();
this.cd.markForCheck();
});
this.mempoolPositionSubscription = this.stateService.mempoolTxPosition$.subscribe(txPosition => {
if (txPosition && txPosition.txid === this.txId && txPosition.position) {
this.mempoolPosition = txPosition.position;
this.accelerationPositions = txPosition.accelerationPositions;
this.isAccelerated$.next(!!(this.tx?.acceleration || txPosition.position?.accelerated));
if (this.tx && !this.tx.status.confirmed) {
this.markBlock();
}
} else {
this.mempoolPosition = null;
this.accelerationPositions = null;
this.isAccelerated$.next(false);
}
});
this.fetchRbfSubscription = this.fetchRbfHistory$
.pipe(
switchMap((txId) =>
this.apiService
.getRbfHistory$(txId)
),
catchError(() => {
return of(null);
})
).subscribe((rbfResponse) => {
this.rbfInfo = rbfResponse?.replacements;
if (this.rbfInfo) {
// link to the latest pending version
this.latestReplacement = this.rbfInfo.tx.txid;
// or traverse the rbf tree to find a confirmed version
if (this.rbfInfo.mined) {
const stack = [this.rbfInfo];
let found = false;
while (stack.length && !found) {
const top = stack.pop();
if (top?.tx.mined) {
found = true;
this.latestReplacement = top.tx.txid;
break;
} else {
stack.push(...top.replaces);
}
}
}
}
});
this.fetchCachedTxSubscription = this.fetchCachedTx$
.pipe(
tap(() => {
this.loadingCachedTx = true;
}),
switchMap((txId) =>
this.apiService
.getRbfCachedTx$(txId)
),
catchError(() => {
return of(null);
})
).subscribe((tx) => {
this.loadingCachedTx = false;
if (!tx) {
this.seoService.logSoft404();
return;
}
this.seoService.clearSoft404();
if (!this.tx) {
this.tx = tx;
this.isCached = true;
if (tx.fee === undefined) {
this.tx.fee = 0;
}
this.tx.feePerVsize = tx.fee / (tx.weight / 4);
this.isLoadingTx = false;
this.error = undefined;
this.waitingForTransaction = false;
if (!this.isValidDestination(tx)) {
this.viewFullDetails();
return;
}
this.fetchRbfHistory$.next(this.tx.txid);
this.txRbfInfoSubscription = this.stateService.txRbfInfo$.subscribe((rbfInfo) => {
if (this.tx) {
this.rbfInfo = rbfInfo;
}
});
this.setAmount();
this.txChanged$.next(true);
}
});
this.subscription = this.zoneService.wrapObservable(this.route.paramMap
.pipe(
switchMap((params: ParamMap) => {
this.txId = params.get('id');
this.seoService.setTitle($localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`);
const network = this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet' ? 'Liquid' : 'Bitcoin';
const seoDescription = seoDescriptionNetwork(this.stateService.network);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.transaction:Get real-time status, addresses, fees, script info, and more for ${network}${seoDescription} transaction with txid ${this.txId}.`);
this.resetTransaction();
return merge(
of(true),
this.stateService.connectionState$.pipe(
filter((state) => state === 2 && this.tx && !this.tx.status?.confirmed)
)
);
}),
switchMap(() => {
let transactionObservable$: Observable<Transaction>;
const cached = this.cacheService.getTxFromCache(this.txId);
if (cached && cached.fee !== -1) {
transactionObservable$ = of(cached);
} else {
transactionObservable$ = this.electrsApiService
.getTransaction$(this.txId)
.pipe(catchError(this.handleLoadElectrsTransactionError.bind(this)));
}
return merge(transactionObservable$, this.stateService.mempoolTransactions$);
}),
))
.subscribe((tx: Transaction) => {
if (!tx) {
this.fetchCachedTx$.next(this.txId);
this.seoService.logSoft404();
return;
}
this.seoService.clearSoft404();
this.tx = tx;
this.isCached = false;
this.isAccelerated$.next(!!(tx.acceleration || this.mempoolPosition?.accelerated));
if (tx.fee === undefined) {
this.tx.fee = 0;
}
this.tx.feePerVsize = tx.fee / (tx.weight / 4);
this.isLoadingTx = false;
this.error = undefined;
this.waitingForTransaction = false;
// The payment view only makes sense for a real output of this transaction.
// Otherwise fall back to the full transaction page.
if (!this.isValidDestination(tx)) {
this.viewFullDetails();
return;
}
if (!tx.status.confirmed) {
this.trackerStage = 'pending';
} else {
this.trackerStage = 'confirmed';
}
this.websocketService.startTrackTransaction(tx.txid);
this.setAmount();
this.updateConfirmations();
this.txChanged$.next(true);
this.markBlock();
this.cd.detectChanges();
},
(error) => {
this.error = error;
this.seoService.logSoft404();
this.isLoadingTx = false;
});
this.txConfirmedSubscription = this.stateService.txConfirmed$.subscribe(([txConfirmed, block]) => {
if (txConfirmed && this.tx && !this.tx.status.confirmed && txConfirmed === this.tx.txid) {
this.tx.status = {
confirmed: true,
block_height: block.height,
block_hash: block.id,
block_time: block.timestamp,
};
this.txChanged$.next(true);
this.updateConfirmations();
this.stateService.markBlock$.next({ blockHeight: block.height });
this.trackerStage = 'confirmed';
this.audioService.playSound('magic');
this.cd.markForCheck();
}
});
this.txReplacedSubscription = this.stateService.txReplaced$.subscribe((rbfTx) => {
if (!this.tx) {
this.error = new Error();
this.loadingCachedTx = false;
this.waitingForTransaction = false;
}
this.rbfTransaction = rbfTx;
this.replaced = true;
this.trackerStage = 'replaced';
if (!this.rbfInfo && rbfTx) {
this.latestReplacement = rbfTx.txid;
}
this.stateService.markBlock$.next({});
if (rbfTx && !this.tx) {
this.fetchCachedTx$.next(this.txId);
}
this.cd.markForCheck();
});
this.ETA$ = combineLatest([
this.stateService.mempoolTxPosition$.pipe(startWith(null)),
this.stateService.mempoolBlocks$.pipe(startWith(null)),
this.stateService.difficultyAdjustment$.pipe(startWith(null)),
this.isAccelerated$,
this.txChanged$,
]).pipe(
map(([position, mempoolBlocks, da, isAccelerated]) => {
if (!this.tx || this.tx.status?.confirmed || !position || position.txid !== this.tx.txid) {
return null;
}
return this.etaService.calculateETA(
this.network,
this.tx,
mempoolBlocks,
position,
da,
this.miningStats,
isAccelerated,
this.accelerationPositions,
);
}),
distinctUntilChanged((prev: ETA | null, curr: ETA | null) => {
return prev === curr || (prev && curr && prev.time === curr.time && prev.blocks === curr.blocks);
}),
tap((eta) => {
if (this.replaced) {
this.trackerStage = 'replaced';
} else if (eta?.blocks === 0) {
this.trackerStage = 'next';
} else if (eta?.blocks < 3){
this.trackerStage = 'soon';
} else {
this.trackerStage = 'pending';
}
})
);
}
isValidDestination(tx: Transaction): boolean {
const network = (this.network || 'mainnet') as any;
if (!this.destination || !getRegex('address', network).test(this.destination)) {
return false;
}
this.isValidView = tx?.vout?.some(vout => vout.scriptpubkey_address === this.destination);
return this.isValidView;
}
setAmount(): void {
this.amount = (this.tx?.vout || []).reduce((total, vout) => {
return vout.scriptpubkey_address === this.destination ? total + vout.value : total;
}, 0);
}
updateConfirmations(): void {
if (this.tx?.status?.confirmed && this.latestBlock?.height != null) {
this.confirmations = Math.max(1, this.latestBlock.height - this.tx.status.block_height + 1);
} else {
this.confirmations = 0;
}
this.settled = this.confirmations >= this.confsRequired;
}
markBlock(): void {
if (this.tx?.status?.confirmed) {
this.stateService.markBlock$.next({ blockHeight: this.tx.status.block_height });
} else if (this.tx) {
const txFeePerVSize = getUnacceleratedFeeRate(this.tx, this.tx.acceleration || this.mempoolPosition?.accelerated);
this.stateService.markBlock$.next({
txid: this.tx.txid,
txFeePerVSize,
mempoolPosition: this.mempoolPosition,
accelerationPositions: this.accelerationPositions,
});
}
}
viewFullDetails(): void {
this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.txId]);
}
get confsRequiredArr(): number[] {
return Array(this.confsRequired);
}
handleLoadElectrsTransactionError(error: any): Observable<any> {
if (error.status === 404 && /^[a-fA-F0-9]{64}$/.test(this.txId)) {
this.websocketService.startMultiTrackTransaction(this.txId);
this.waitingForTransaction = true;
}
this.error = error;
this.seoService.logSoft404();
this.isLoadingTx = false;
return of(false);
}
getFragment(destination: string, confs: number = 1): string {
return `destination=${destination}&confs=${confs}`;
}
resetTransaction(): void {
const fragmentParams = new URLSearchParams(this.route.snapshot.fragment || '');
this.destination = fragmentParams.get('destination') || '';
const parsedConfs = Math.min(Math.ceil(parseInt((fragmentParams.get('confs') || '1'), 10)), 6); // Capped to 6 confs
this.confsRequired = (!isNaN(parsedConfs) && parsedConfs >= 1) ? parsedConfs : 1;
this.error = undefined;
this.tx = null;
this.txChanged$.next(true);
this.waitingForTransaction = false;
this.isLoadingTx = true;
this.isValidView = false;
this.replaced = false;
this.rbfTransaction = null;
this.rbfInfo = null;
this.latestReplacement = '';
this.isCached = false;
this.loadingCachedTx = false;
this.trackerStage = 'waiting';
this.amount = 0;
this.confirmations = 0;
this.settled = false;
this.mempoolPosition = null;
this.accelerationPositions = null;
this.isAccelerated$.next(false);
this.txRbfInfoSubscription?.unsubscribe();
this.leaveTransaction();
}
leaveTransaction(): void {
this.websocketService.stopTrackingTransaction();
this.stateService.markBlock$.next({});
}
@HostListener('window:resize', ['$event'])
onResize(): void {
this.isMobile = window.innerWidth < 850;
}
ngOnDestroy(): void {
this.fetchRbfSubscription?.unsubscribe();
this.fetchCachedTxSubscription?.unsubscribe();
this.subscription?.unsubscribe();
this.networkChangedSubscription?.unsubscribe();
this.blocksSubscription?.unsubscribe();
this.mempoolPositionSubscription?.unsubscribe();
this.txConfirmedSubscription?.unsubscribe();
this.txReplacedSubscription?.unsubscribe();
this.leaveTransaction();
}
changeMode(mode: 'btc' | 'sats'): boolean {
this.storageService.setValue('view-amount-mode', mode);
this.stateService.viewAmountMode$.next(mode);
this.amountMode = mode;
return false;
}
}

View file

@ -0,0 +1,50 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Routes, RouterModule } from '@angular/router';
import { SharedModule } from '@app/shared/shared.module';
import { PaymentComponent } from '@components/payment/payment.component';
import { TrackerModule } from '@components/tracker/tracker.module';
const routes: Routes = [
{
path: '',
redirectTo: '/',
pathMatch: 'full',
},
{
path: ':id',
component: PaymentComponent,
data: {
ogImage: true
}
}
];
@NgModule({
imports: [
RouterModule.forChild(routes)
],
exports: [
RouterModule
]
})
export class PaymentRoutingModule { }
@NgModule({
imports: [
CommonModule,
PaymentRoutingModule,
SharedModule,
TrackerModule
],
declarations: [
PaymentComponent
]
})
export class PaymentModule { }

View file

@ -40,6 +40,9 @@ export class TrackerRoutingModule { }
declarations: [
TrackerComponent,
TrackerBarComponent,
],
exports: [
TrackerBarComponent
]
})
export class TrackerModule { }

View file

@ -17,6 +17,7 @@ import { ServerStatusComponent } from '@components/server-health/server-status.c
import { FaucetComponent } from '@components/faucet/faucet.component';
import { SimpleProofWidgetComponent } from '@components/simpleproof-widget/simpleproof-widget.component';
import { SimpleProofCuboWidgetComponent } from '@components/simpleproof-widget/simpleproof-cubo-widget.component';
import { PaymentGuard } from '@app/route-guards';
const browserWindow = window || {};
// @ts-ignore
@ -93,6 +94,15 @@ const routes: Routes = [
{
path: 'tx',
component: StartComponent,
canMatch: [PaymentGuard],
runGuardsAndResolvers: 'always',
data: { preload: false, networkSpecific: true },
loadChildren: () => import('@components/payment/payment.module').then(m => m.PaymentModule),
},
{
path: 'tx',
component: StartComponent,
runGuardsAndResolvers: 'always',
data: { preload: true, networkSpecific: true },
loadChildren: () => import('@components/transaction/transaction.module').then(m => m.TransactionModule),
},

View file

@ -14,10 +14,19 @@ class GuardService {
trackerGuard(route: Route, segments: UrlSegment[]): boolean {
const preferredRoute = this.router.getCurrentNavigation()?.extractedUrl.queryParams?.mode;
const path = this.router.getCurrentNavigation()?.extractedUrl.root.children.primary.segments;
return (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98 && !(path.length === 2 && ['push', 'test', 'preview'].includes(path[1].path));
return (!this.paymentGuard() && (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98 && !(path.length === 2 && ['push', 'test', 'preview'].includes(path[1].path)));
}
paymentGuard(route?: Route, segments?: UrlSegment[]): boolean {
const fragmentParams = new URLSearchParams(this.router.getCurrentNavigation()?.extractedUrl.fragment || '');
return (fragmentParams.has('destination'));
}
}
export const TrackerGuard: CanMatchFn = (route: Route, segments: UrlSegment[]): boolean => {
return inject(GuardService).trackerGuard(route, segments);
};
export const PaymentGuard: CanMatchFn = (route: Route, segments: UrlSegment[]): boolean => {
return inject(GuardService).paymentGuard(route, segments);
};