Merge branch 'master' into natsoni/add-blocks-logo
This commit is contained in:
		
						commit
						46d99db167
					
				| @ -75,6 +75,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|   @Output() changeMode = new EventEmitter<boolean>(); | ||||
| 
 | ||||
|   calculating = true; | ||||
|   processing = false; | ||||
|   selectedOption: 'wait' | 'accel'; | ||||
|   cantPayReason = ''; | ||||
|   quoteError = ''; // error fetching estimate or initial data
 | ||||
| @ -380,9 +381,10 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|    * Account-based acceleration request | ||||
|    */ | ||||
|   accelerateWithMempoolAccount(): void { | ||||
|     if (!this.canPay || this.calculating) { | ||||
|     if (!this.canPay || this.calculating || this.processing) { | ||||
|       return; | ||||
|     } | ||||
|     this.processing = true; | ||||
|     if (this.accelerationSubscription) { | ||||
|       this.accelerationSubscription.unsubscribe(); | ||||
|     } | ||||
| @ -392,6 +394,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|       this.accelerationUUID | ||||
|     ).subscribe({ | ||||
|       next: () => { | ||||
|         this.processing = false; | ||||
|         this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); | ||||
|         this.audioService.playSound('ascend-chime-cartoon'); | ||||
|         this.showSuccess = true; | ||||
| @ -399,6 +402,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|         this.moveToStep('paid'); | ||||
|       }, | ||||
|       error: (response) => { | ||||
|         this.processing = false; | ||||
|         this.accelerateError = response.error; | ||||
|       } | ||||
|     }); | ||||
| @ -468,10 +472,14 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|    * APPLE PAY | ||||
|    */ | ||||
|   async requestApplePayPayment(): Promise<void> { | ||||
|     if (this.processing) { | ||||
|       return; | ||||
|     } | ||||
|     if (this.conversionsSubscription) { | ||||
|       this.conversionsSubscription.unsubscribe(); | ||||
|     } | ||||
| 
 | ||||
|     this.processing = true; | ||||
|     this.conversionsSubscription = this.stateService.conversions$.subscribe( | ||||
|       async (conversions) => { | ||||
|         this.conversions = conversions; | ||||
| @ -496,6 +504,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|             console.error(`Unable to find apple pay button id='apple-pay-button'`); | ||||
|             // Try again
 | ||||
|             setTimeout(this.requestApplePayPayment.bind(this), 500); | ||||
|             this.processing = false; | ||||
|             return; | ||||
|           } | ||||
|           this.loadingApplePay = false; | ||||
| @ -507,6 +516,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|               if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { | ||||
|                 console.error(`Cannot retreive payment card details`); | ||||
|                 this.accelerateError = 'apple_pay_no_card_details'; | ||||
|                 this.processing = false; | ||||
|                 return; | ||||
|               } | ||||
|               const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); | ||||
| @ -518,6 +528,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|                 this.accelerationUUID | ||||
|               ).subscribe({ | ||||
|                 next: () => { | ||||
|                   this.processing = false; | ||||
|                   this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); | ||||
|                   this.audioService.playSound('ascend-chime-cartoon'); | ||||
|                   if (this.applePay) { | ||||
| @ -528,6 +539,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|                   }, 1000); | ||||
|                 }, | ||||
|                 error: (response) => { | ||||
|                   this.processing = false; | ||||
|                   this.accelerateError = response.error; | ||||
|                   if (!(response.status === 403 && response.error === 'not_available')) { | ||||
|                     setTimeout(() => { | ||||
| @ -539,6 +551,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|                 } | ||||
|               }); | ||||
|             } else { | ||||
|               this.processing = false; | ||||
|               let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; | ||||
|               if (tokenResult.errors) { | ||||
|                 errorMessage += ` and errors: ${JSON.stringify( | ||||
| @ -549,6 +562,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|             } | ||||
|           }); | ||||
|         } catch (e) { | ||||
|           this.processing = false; | ||||
|           console.error(e); | ||||
|         } | ||||
|       } | ||||
| @ -559,10 +573,14 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|    * GOOGLE PAY | ||||
|    */ | ||||
|   async requestGooglePayPayment(): Promise<void> { | ||||
|     if (this.processing) { | ||||
|       return; | ||||
|     } | ||||
|     if (this.conversionsSubscription) { | ||||
|       this.conversionsSubscription.unsubscribe(); | ||||
|     } | ||||
|      | ||||
|     this.processing = true; | ||||
|     this.conversionsSubscription = this.stateService.conversions$.subscribe( | ||||
|       async (conversions) => { | ||||
|         this.conversions = conversions; | ||||
| @ -597,6 +615,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|             if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { | ||||
|               console.error(`Cannot retreive payment card details`); | ||||
|               this.accelerateError = 'apple_pay_no_card_details'; | ||||
|               this.processing = false; | ||||
|               return; | ||||
|             } | ||||
|             const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); | ||||
| @ -608,6 +627,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|               this.accelerationUUID | ||||
|             ).subscribe({ | ||||
|               next: () => { | ||||
|                 this.processing = false; | ||||
|                 this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); | ||||
|                 this.audioService.playSound('ascend-chime-cartoon'); | ||||
|                 if (this.googlePay) { | ||||
| @ -618,6 +638,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|                 }, 1000); | ||||
|               }, | ||||
|               error: (response) => { | ||||
|                 this.processing = false; | ||||
|                 this.accelerateError = response.error; | ||||
|                 if (!(response.status === 403 && response.error === 'not_available')) { | ||||
|                   setTimeout(() => { | ||||
| @ -629,6 +650,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|               } | ||||
|             }); | ||||
|           } else { | ||||
|             this.processing = false; | ||||
|             let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; | ||||
|             if (tokenResult.errors) { | ||||
|               errorMessage += ` and errors: ${JSON.stringify( | ||||
| @ -646,10 +668,14 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|    * CASHAPP | ||||
|    */ | ||||
|   async requestCashAppPayment(): Promise<void> { | ||||
|     if (this.processing) { | ||||
|       return; | ||||
|     } | ||||
|     if (this.conversionsSubscription) { | ||||
|       this.conversionsSubscription.unsubscribe(); | ||||
|     } | ||||
| 
 | ||||
|     this.processing = true; | ||||
|     this.conversionsSubscription = this.stateService.conversions$.subscribe( | ||||
|       async (conversions) => { | ||||
|         this.conversions = conversions; | ||||
| @ -680,6 +706,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|         this.cashAppPay.addEventListener('ontokenization', event => { | ||||
|           const { tokenResult, error } = event.detail; | ||||
|           if (error) { | ||||
|             this.processing = false; | ||||
|             this.accelerateError = error; | ||||
|           } else if (tokenResult.status === 'OK') { | ||||
|             this.servicesApiService.accelerateWithCashApp$( | ||||
| @ -690,6 +717,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|               this.accelerationUUID | ||||
|             ).subscribe({ | ||||
|               next: () => { | ||||
|                 this.processing = false; | ||||
|                 this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); | ||||
|                 this.audioService.playSound('ascend-chime-cartoon'); | ||||
|                 if (this.cashAppPay) { | ||||
| @ -704,6 +732,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { | ||||
|                 }, 1000); | ||||
|               }, | ||||
|               error: (response) => { | ||||
|                 this.processing = false; | ||||
|                 this.accelerateError = response.error; | ||||
|                 if (!(response.status === 403 && response.error === 'not_available')) { | ||||
|                   setTimeout(() => { | ||||
|  | ||||
| @ -47,13 +47,14 @@ | ||||
|       <tr *ngIf="['accelerated', 'mined'].includes(accelerationInfo.status) && hasPoolsData()"> | ||||
|         <td class="label" i18n="transaction.accelerated-by-hashrate|Accelerated to hashrate">Accelerated by</td> | ||||
|         <td class="value" *ngIf="accelerationInfo.pools"> | ||||
|           <ng-container *ngFor="let pool of accelerationInfo.pools"> | ||||
|           <ng-container *ngFor="let pool of accelerationInfo.pools; let i = index;"> | ||||
|             <img *ngIf="accelerationInfo.poolsData[pool]"  | ||||
|               class="pool-logo"  | ||||
|               [style.opacity]="accelerationInfo?.minedByPoolUniqueId && pool !== accelerationInfo?.minedByPoolUniqueId ? '0.3' : '1'" | ||||
|               [src]="'/resources/mining-pools/' + accelerationInfo.poolsData[pool].slug + '.svg'"  | ||||
|               onError="this.src = '/resources/mining-pools/default.svg'"  | ||||
|               [alt]="'Logo of ' + pool.name + ' mining pool'"> | ||||
|             <br *ngIf="i % 6 === 5"> | ||||
|           </ng-container> | ||||
|         </td> | ||||
|       </tr> | ||||
|  | ||||
| @ -23,6 +23,7 @@ | ||||
| 
 | ||||
|   .label { | ||||
|     padding-right: 30px; | ||||
|     vertical-align: top; | ||||
|   } | ||||
| 
 | ||||
|   .pool-logo { | ||||
| @ -30,7 +31,8 @@ | ||||
|     height: 22px; | ||||
|     position: relative; | ||||
|     top: -1px; | ||||
|     margin-right: 3px; | ||||
|     margin-right: 4px; | ||||
|     margin-bottom: 4px; | ||||
|   } | ||||
| 
 | ||||
|   .oobFees { | ||||
|  | ||||
| @ -94,6 +94,20 @@ | ||||
|       </div> | ||||
|     </ng-container> | ||||
| 
 | ||||
|     <ng-container *ngIf="(stateService.backend$ | async) === 'esplora' && address && utxos && utxos.length > 2"> | ||||
|       <br> | ||||
|       <div class="title-tx"> | ||||
|         <h2 class="text-left" i18n="address.unspent-outputs">Unspent Outputs</h2> | ||||
|       </div> | ||||
|       <div class="box"> | ||||
|         <div class="row"> | ||||
|           <div class="col-md"> | ||||
|             <app-utxo-graph [utxos]="utxos" left="80" /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </ng-container> | ||||
| 
 | ||||
|     <br> | ||||
|     <div class="title-tx"> | ||||
|       <h2 class="text-left"> | ||||
|  | ||||
| @ -2,12 +2,12 @@ import { Component, OnInit, OnDestroy, HostListener } from '@angular/core'; | ||||
| import { ActivatedRoute, ParamMap } from '@angular/router'; | ||||
| import { ElectrsApiService } from '../../services/electrs-api.service'; | ||||
| import { switchMap, filter, catchError, map, tap } from 'rxjs/operators'; | ||||
| import { Address, ChainStats, Transaction, Vin } from '../../interfaces/electrs.interface'; | ||||
| import { Address, ChainStats, Transaction, Utxo, Vin } from '../../interfaces/electrs.interface'; | ||||
| import { WebsocketService } from '../../services/websocket.service'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { AudioService } from '../../services/audio.service'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { of, merge, Subscription, Observable } from 'rxjs'; | ||||
| import { of, merge, Subscription, Observable, forkJoin } from 'rxjs'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||
| import { AddressInformation } from '../../interfaces/node-api.interface'; | ||||
| @ -104,6 +104,7 @@ export class AddressComponent implements OnInit, OnDestroy { | ||||
|   addressString: string; | ||||
|   isLoadingAddress = true; | ||||
|   transactions: Transaction[]; | ||||
|   utxos: Utxo[]; | ||||
|   isLoadingTransactions = true; | ||||
|   retryLoadMore = false; | ||||
|   error: any; | ||||
| @ -159,6 +160,7 @@ export class AddressComponent implements OnInit, OnDestroy { | ||||
|           this.address = null; | ||||
|           this.isLoadingTransactions = true; | ||||
|           this.transactions = null; | ||||
|           this.utxos = null; | ||||
|           this.addressInfo = null; | ||||
|           this.exampleChannel = null; | ||||
|           document.body.scrollTo(0, 0); | ||||
| @ -212,11 +214,19 @@ export class AddressComponent implements OnInit, OnDestroy { | ||||
|           this.updateChainStats(); | ||||
|           this.isLoadingAddress = false; | ||||
|           this.isLoadingTransactions = true; | ||||
|           return address.is_pubkey | ||||
|           const utxoCount = this.chainStats.utxos + this.mempoolStats.utxos; | ||||
|           return forkJoin([ | ||||
|             address.is_pubkey | ||||
|               ? this.electrsApiService.getScriptHashTransactions$((address.address.length === 66 ? '21' : '41') + address.address + 'ac') | ||||
|               : this.electrsApiService.getAddressTransactions$(address.address); | ||||
|               : this.electrsApiService.getAddressTransactions$(address.address), | ||||
|             utxoCount >= 2 && utxoCount <= 500 ? (address.is_pubkey | ||||
|               ? this.electrsApiService.getScriptHashUtxos$((address.address.length === 66 ? '21' : '41') + address.address + 'ac') | ||||
|               : this.electrsApiService.getAddressUtxos$(address.address)) : of([]) | ||||
|           ]); | ||||
|         }), | ||||
|         switchMap((transactions) => { | ||||
|         switchMap(([transactions, utxos]) => { | ||||
|           this.utxos = utxos; | ||||
| 
 | ||||
|           this.tempTransactions = transactions; | ||||
|           if (transactions.length) { | ||||
|             this.lastTransactionTxId = transactions[transactions.length - 1].txid; | ||||
| @ -334,6 +344,23 @@ export class AddressComponent implements OnInit, OnDestroy { | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // update utxos in-place
 | ||||
|     for (const vin of transaction.vin) { | ||||
|       const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === vin.txid && utxo.vout === vin.vout); | ||||
|       if (utxoIndex !== -1) { | ||||
|         this.utxos.splice(utxoIndex, 1); | ||||
|       } | ||||
|     } | ||||
|     for (const [index, vout] of transaction.vout.entries()) { | ||||
|       if (vout.scriptpubkey_address === this.address.address) { | ||||
|         this.utxos.push({ | ||||
|           txid: transaction.txid, | ||||
|           vout: index, | ||||
|           value: vout.value, | ||||
|           status: JSON.parse(JSON.stringify(transaction.status)), | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|     return true; | ||||
|   } | ||||
| 
 | ||||
| @ -346,6 +373,26 @@ export class AddressComponent implements OnInit, OnDestroy { | ||||
|     this.transactions.splice(index, 1); | ||||
|     this.transactions = this.transactions.slice(); | ||||
| 
 | ||||
|     // update utxos in-place
 | ||||
|     for (const vin of transaction.vin) { | ||||
|       if (vin.prevout?.scriptpubkey_address === this.address.address) { | ||||
|         this.utxos.push({ | ||||
|           txid: vin.txid, | ||||
|           vout: vin.vout, | ||||
|           value: vin.prevout.value, | ||||
|           status: { confirmed: true }, // Assuming the input was confirmed
 | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|     for (const [index, vout] of transaction.vout.entries()) { | ||||
|       if (vout.scriptpubkey_address === this.address.address) { | ||||
|         const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === transaction.txid && utxo.vout === index); | ||||
|         if (utxoIndex !== -1) { | ||||
|           this.utxos.splice(utxoIndex, 1); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return true; | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -12,7 +12,7 @@ | ||||
|           <div class="input-group-prepend"> | ||||
|             <span class="input-group-text">{{ currency$ | async }}</span> | ||||
|           </div> | ||||
|           <input type="text" class="form-control" formControlName="fiat" (input)="transformInput('fiat')" (click)="selectAll($event)"> | ||||
|           <input type="text" inputmode="numeric" class="form-control" formControlName="fiat" (input)="transformInput('fiat')" (click)="selectAll($event)"> | ||||
|           <app-clipboard [button]="true" [text]="form.get('fiat').value" [class]="'btn btn-lg btn-secondary ml-1'"></app-clipboard> | ||||
|         </div> | ||||
| 
 | ||||
| @ -20,7 +20,7 @@ | ||||
|           <div class="input-group-prepend"> | ||||
|             <span class="input-group-text">BTC</span> | ||||
|           </div> | ||||
|           <input type="text" class="form-control" formControlName="bitcoin" (input)="transformInput('bitcoin')" (click)="selectAll($event)"> | ||||
|           <input type="text" inputmode="numeric" class="form-control" formControlName="bitcoin" (input)="transformInput('bitcoin')" (click)="selectAll($event)"> | ||||
|           <app-clipboard [button]="true" [text]="form.get('bitcoin').value" [class]="'btn btn-lg btn-secondary ml-1'"></app-clipboard> | ||||
|         </div> | ||||
| 
 | ||||
| @ -28,7 +28,7 @@ | ||||
|           <div class="input-group-prepend"> | ||||
|             <span class="input-group-text" i18n="shared.sats">sats</span> | ||||
|           </div> | ||||
|           <input type="text" class="form-control" formControlName="satoshis" (input)="transformInput('satoshis')" (click)="selectAll($event)"> | ||||
|           <input type="text" inputmode="numeric" class="form-control" formControlName="satoshis" (input)="transformInput('satoshis')" (click)="selectAll($event)"> | ||||
|           <app-clipboard [button]="true" [text]="form.get('satoshis').value" [class]="'btn btn-lg btn-secondary ml-1'"></app-clipboard> | ||||
|         </div> | ||||
|       </form> | ||||
|  | ||||
| @ -65,23 +65,25 @@ | ||||
|               } | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="field narrower"> | ||||
|             <div class="label" i18n="transaction.eta|Transaction ETA">ETA</div> | ||||
|             <div class="value"> | ||||
|               <ng-container *ngIf="(ETA$ | async) as eta; else etaSkeleton"> | ||||
|                 <span class="justify-content-end d-flex align-items-center"> | ||||
|                   @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" [fixedRender]="true"></app-time> | ||||
|                   } | ||||
|                 </span> | ||||
|               </ng-container> | ||||
|               <ng-template #etaSkeleton> | ||||
|                 <span class="skeleton-loader" style="max-width: 200px;"></span> | ||||
|               </ng-template> | ||||
|           @if (!replaced) { | ||||
|             <div class="field narrower"> | ||||
|               <div class="label" i18n="transaction.eta|Transaction ETA">ETA</div> | ||||
|               <div class="value"> | ||||
|                 <ng-container *ngIf="(ETA$ | async) as eta; else etaSkeleton"> | ||||
|                   <span class="justify-content-end d-flex align-items-center"> | ||||
|                     @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" [fixedRender]="true"></app-time> | ||||
|                     } | ||||
|                   </span> | ||||
|                 </ng-container> | ||||
|                 <ng-template #etaSkeleton> | ||||
|                   <span class="skeleton-loader" style="max-width: 200px;"></span> | ||||
|                 </ng-template> | ||||
|               </div> | ||||
|             </div>   | ||||
|           </div> | ||||
|           } | ||||
|         } @else if (tx && tx.status?.confirmed) { | ||||
|           <div class="field narrower mt-2"> | ||||
|             <div class="label" i18n="transaction.confirmed-at">Confirmed at</div> | ||||
|  | ||||
| @ -192,7 +192,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|     this.hideAccelerationSummary = this.stateService.isMempoolSpaceBuild ? this.storageService.getValue('hide-accelerator-pref') == 'true' : true; | ||||
| 
 | ||||
|     if (!this.stateService.isLiquid()) { | ||||
|       this.miningService.getMiningStats('1w').subscribe(stats => { | ||||
|       this.miningService.getMiningStats('1m').subscribe(stats => { | ||||
|         this.miningStats = stats; | ||||
|       }); | ||||
|     } | ||||
| @ -491,7 +491,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { | ||||
|           if (this.stateService.network === '') { | ||||
|             if (!this.mempoolPosition.accelerated) { | ||||
|               if (!this.accelerationFlowCompleted && !this.hideAccelerationSummary && !this.showAccelerationSummary) { | ||||
|                 this.miningService.getMiningStats('1w').subscribe(stats => { | ||||
|                 this.miningService.getMiningStats('1m').subscribe(stats => { | ||||
|                   this.miningStats = stats; | ||||
|                 }); | ||||
|               } | ||||
|  | ||||
| @ -0,0 +1,21 @@ | ||||
| <app-indexing-progress *ngIf="!widget"></app-indexing-progress> | ||||
| 
 | ||||
| <div [class.full-container]="!widget"> | ||||
|   <ng-container *ngIf="!error"> | ||||
|     <div [class]="!widget ? 'chart' : 'chart-widget'" *browserOnly [style]="{ height: widget ? ((height + 20) + 'px') : null, paddingBottom: !widget}" echarts [initOpts]="chartInitOptions" [options]="chartOptions" | ||||
|       (chartInit)="onChartInit($event)"> | ||||
|     </div> | ||||
|     <div class="text-center loadingGraphs" *ngIf="isLoading"> | ||||
|       <div class="spinner-border text-light"></div> | ||||
|     </div> | ||||
|   </ng-container> | ||||
|   <ng-container *ngIf="error"> | ||||
|     <div class="error-wrapper"> | ||||
|       <p class="error">{{ error }}</p> | ||||
|     </div> | ||||
|   </ng-container> | ||||
| 
 | ||||
|   <div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading"> | ||||
|     <div class="spinner-border text-light"></div> | ||||
|   </div> | ||||
| </div> | ||||
| @ -0,0 +1,59 @@ | ||||
| .card-header { | ||||
|   border-bottom: 0; | ||||
|   font-size: 18px; | ||||
|   @media (min-width: 465px) { | ||||
|     font-size: 20px; | ||||
|   } | ||||
|   @media (min-width: 992px) { | ||||
|     height: 40px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .main-title { | ||||
|   position: relative; | ||||
|   color: var(--fg); | ||||
|   opacity: var(--opacity); | ||||
|   margin-top: -13px; | ||||
|   font-size: 10px; | ||||
|   text-transform: uppercase; | ||||
|   font-weight: 500; | ||||
|   text-align: center; | ||||
|   padding-bottom: 3px; | ||||
| } | ||||
| 
 | ||||
| .full-container { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   padding: 0px; | ||||
|   width: 100%; | ||||
|   height: 400px; | ||||
| } | ||||
| 
 | ||||
| .error-wrapper { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
| 
 | ||||
|   font-size: 15px; | ||||
|   color: grey; | ||||
|   font-weight: bold; | ||||
| } | ||||
| 
 | ||||
| .chart { | ||||
|   display: flex; | ||||
|   flex: 1; | ||||
|   width: 100%; | ||||
|   padding-right: 10px; | ||||
| } | ||||
| .chart-widget { | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
| } | ||||
| 
 | ||||
| .disabled { | ||||
|   pointer-events: none; | ||||
|   opacity: 0.5; | ||||
| } | ||||
							
								
								
									
										285
									
								
								frontend/src/app/components/utxo-graph/utxo-graph.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										285
									
								
								frontend/src/app/components/utxo-graph/utxo-graph.component.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,285 @@ | ||||
| import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, NgZone, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; | ||||
| import { EChartsOption } from '../../graphs/echarts'; | ||||
| import { BehaviorSubject, Subscription } from 'rxjs'; | ||||
| import { Utxo } from '../../interfaces/electrs.interface'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { Router } from '@angular/router'; | ||||
| import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; | ||||
| import { renderSats } from '../../shared/common.utils'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-utxo-graph', | ||||
|   templateUrl: './utxo-graph.component.html', | ||||
|   styleUrls: ['./utxo-graph.component.scss'], | ||||
|   styles: [` | ||||
|     .loadingGraphs { | ||||
|       position: absolute; | ||||
|       top: 50%; | ||||
|       left: calc(50% - 15px); | ||||
|       z-index: 99; | ||||
|     } | ||||
|   `],
 | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class UtxoGraphComponent implements OnChanges, OnDestroy { | ||||
|   @Input() utxos: Utxo[]; | ||||
|   @Input() height: number = 200; | ||||
|   @Input() right: number | string = 10; | ||||
|   @Input() left: number | string = 70; | ||||
|   @Input() widget: boolean = false; | ||||
| 
 | ||||
|   subscription: Subscription; | ||||
|   redraw$: BehaviorSubject<boolean> = new BehaviorSubject(false); | ||||
| 
 | ||||
|   chartOptions: EChartsOption = {}; | ||||
|   chartInitOptions = { | ||||
|     renderer: 'svg', | ||||
|   }; | ||||
| 
 | ||||
|   error: any; | ||||
|   isLoading = true; | ||||
|   chartInstance: any = undefined; | ||||
| 
 | ||||
|   constructor( | ||||
|     public stateService: StateService, | ||||
|     private cd: ChangeDetectorRef, | ||||
|     private zone: NgZone, | ||||
|     private router: Router, | ||||
|     private relativeUrlPipe: RelativeUrlPipe, | ||||
|   ) {} | ||||
| 
 | ||||
|   ngOnChanges(changes: SimpleChanges): void { | ||||
|     this.isLoading = true; | ||||
|     if (!this.utxos) { | ||||
|       return; | ||||
|     } | ||||
|     if (changes.utxos) { | ||||
|       this.prepareChartOptions(this.utxos); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   prepareChartOptions(utxos: Utxo[]) { | ||||
|     if (!utxos || utxos.length === 0) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.isLoading = false; | ||||
| 
 | ||||
|     // Helper functions
 | ||||
|     const distance = (x1: number, y1: number, x2: number, y2: number): number => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); | ||||
|     const intersectionPoints = (x1: number, y1: number, r1: number, x2: number, y2: number, r2: number): [number, number][] => { | ||||
|       const d = distance(x1, y1, x2, y2); | ||||
|       const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d); | ||||
|       const h = Math.sqrt(r1 * r1 - a * a); | ||||
|       const x3 = x1 + a * (x2 - x1) / d; | ||||
|       const y3 = y1 + a * (y2 - y1) / d; | ||||
|       return [ | ||||
|         [x3 + h * (y2 - y1) / d, y3 - h * (x2 - x1) / d], | ||||
|         [x3 - h * (y2 - y1) / d, y3 + h * (x2 - x1) / d] | ||||
|       ]; | ||||
|     }; | ||||
| 
 | ||||
|     // Naive algorithm to pack circles as tightly as possible without overlaps
 | ||||
|     const placedCircles: { x: number, y: number, r: number, utxo: Utxo, distances: number[] }[] = []; | ||||
|     // Pack in descending order of value, and limit to the top 500 to preserve performance
 | ||||
|     const sortedUtxos = utxos.sort((a, b) => b.value - a.value).slice(0, 500); | ||||
|     let centerOfMass = { x: 0, y: 0 }; | ||||
|     let weightOfMass = 0; | ||||
|     sortedUtxos.forEach((utxo, index) => { | ||||
|       // area proportional to value
 | ||||
|       const r = Math.sqrt(utxo.value); | ||||
| 
 | ||||
|       // special cases for the first two utxos
 | ||||
|       if (index === 0) { | ||||
|         placedCircles.push({ x: 0, y: 0, r, utxo, distances: [0] }); | ||||
|         return; | ||||
|       } | ||||
|       if (index === 1) { | ||||
|         const c = placedCircles[0]; | ||||
|         placedCircles.push({ x: c.r + r, y: 0, r, utxo, distances: [c.r + r, 0] }); | ||||
|         c.distances.push(c.r + r); | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       // The best position will be touching two other circles
 | ||||
|       // generate a list of candidate points by finding all such positions
 | ||||
|       // where the circle can be placed without overlapping other circles
 | ||||
|       const candidates: [number, number, number[]][] = []; | ||||
|       const numCircles = placedCircles.length; | ||||
|       for (let i = 0; i < numCircles; i++) { | ||||
|         for (let j = i + 1; j < numCircles; j++) { | ||||
|           const c1 = placedCircles[i]; | ||||
|           const c2 = placedCircles[j]; | ||||
|           if (c1.distances[j] > (c1.r + c2.r + r + r)) { | ||||
|             // too far apart for new circle to touch both
 | ||||
|             continue; | ||||
|           } | ||||
|           const points = intersectionPoints(c1.x, c1.y, c1.r + r, c2.x, c2.y, c2.r + r); | ||||
|           points.forEach(([x, y]) => { | ||||
|             const distances: number[] = []; | ||||
|             let valid = true; | ||||
|             for (let k = 0; k < numCircles; k++) { | ||||
|               const c = placedCircles[k]; | ||||
|               const d = distance(x, y, c.x, c.y); | ||||
|               if (k !== i && k !== j && d < (r + c.r)) { | ||||
|                 valid = false; | ||||
|                 break; | ||||
|               } else { | ||||
|                 distances.push(d); | ||||
|               } | ||||
|             } | ||||
|             if (valid) { | ||||
|               candidates.push([x, y, distances]); | ||||
|             } | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       // Pick the candidate closest to the center of mass
 | ||||
|       const [x, y, distances] = candidates.length ? candidates.reduce((closest, candidate) => | ||||
|         distance(candidate[0], candidate[1], centerOfMass[0], centerOfMass[1]) < | ||||
|         distance(closest[0], closest[1], centerOfMass[0], centerOfMass[1]) | ||||
|           ? candidate | ||||
|           : closest | ||||
|       ) : [0, 0, []]; | ||||
| 
 | ||||
|       placedCircles.push({ x, y, r, utxo, distances }); | ||||
|       for (let i = 0; i < distances.length; i++) { | ||||
|         placedCircles[i].distances.push(distances[i]); | ||||
|       } | ||||
|       distances.push(0); | ||||
| 
 | ||||
|       // Update center of mass
 | ||||
|       centerOfMass = { | ||||
|         x: (centerOfMass.x * weightOfMass + x) / (weightOfMass + r), | ||||
|         y: (centerOfMass.y * weightOfMass + y) / (weightOfMass + r), | ||||
|       }; | ||||
|       weightOfMass += r; | ||||
|     }); | ||||
| 
 | ||||
|     // Precompute the bounding box of the graph
 | ||||
|     const minX = Math.min(...placedCircles.map(d => d.x - d.r)); | ||||
|     const maxX = Math.max(...placedCircles.map(d => d.x + d.r)); | ||||
|     const minY = Math.min(...placedCircles.map(d => d.y - d.r)); | ||||
|     const maxY = Math.max(...placedCircles.map(d => d.y + d.r)); | ||||
|     const width = maxX - minX; | ||||
|     const height = maxY - minY; | ||||
| 
 | ||||
|     const data = placedCircles.map((circle, index) => [ | ||||
|       circle.utxo, | ||||
|       index, | ||||
|       circle.x, | ||||
|       circle.y, | ||||
|       circle.r | ||||
|     ]); | ||||
| 
 | ||||
|     this.chartOptions = { | ||||
|       series: [{ | ||||
|         type: 'custom', | ||||
|         coordinateSystem: undefined, | ||||
|         data, | ||||
|         renderItem: (params, api) => { | ||||
|           const idx = params.dataIndex; | ||||
|           const datum = data[idx]; | ||||
|           const utxo = datum[0] as Utxo; | ||||
|           const chartWidth = api.getWidth(); | ||||
|           const chartHeight = api.getHeight(); | ||||
|           const scale = Math.min(chartWidth / width, chartHeight / height); | ||||
|           const scaledWidth = width * scale; | ||||
|           const scaledHeight = height * scale; | ||||
|           const offsetX = (chartWidth - scaledWidth) / 2 - minX * scale; | ||||
|           const offsetY = (chartHeight - scaledHeight) / 2 - minY * scale; | ||||
|           const x = datum[2] as number; | ||||
|           const y = datum[3] as number; | ||||
|           const r = datum[4] as number; | ||||
|           if (r * scale < 3) { | ||||
|             // skip items too small to render cleanly
 | ||||
|             return; | ||||
|           } | ||||
|           const valueStr = renderSats(utxo.value, this.stateService.network); | ||||
|           const elements: any[] = [ | ||||
|             { | ||||
|               type: 'circle', | ||||
|               autoBatch: true, | ||||
|               shape: { | ||||
|                 cx: (x * scale) + offsetX, | ||||
|                 cy: (y * scale) + offsetY, | ||||
|                 r: (r * scale) - 1, | ||||
|               }, | ||||
|               style: { | ||||
|                 fill: '#5470c6', | ||||
|               } | ||||
|             }, | ||||
|           ]; | ||||
|           const labelFontSize = Math.min(36, r * scale * 0.25); | ||||
|           if (labelFontSize > 8) { | ||||
|             elements.push({ | ||||
|               type: 'text', | ||||
|               x: (x * scale) + offsetX, | ||||
|               y: (y * scale) + offsetY, | ||||
|               style: { | ||||
|                 text: valueStr, | ||||
|                 fontSize: labelFontSize, | ||||
|                 fill: '#fff', | ||||
|                 align: 'center', | ||||
|                 verticalAlign: 'middle', | ||||
|               }, | ||||
|             }); | ||||
|           } | ||||
|           return { | ||||
|             type: 'group', | ||||
|             children: elements, | ||||
|           }; | ||||
|         } | ||||
|       }], | ||||
|       tooltip: { | ||||
|         backgroundColor: 'rgba(17, 19, 31, 1)', | ||||
|         borderRadius: 4, | ||||
|         shadowColor: 'rgba(0, 0, 0, 0.5)', | ||||
|         textStyle: { | ||||
|           color: 'var(--tooltip-grey)', | ||||
|           align: 'left', | ||||
|         }, | ||||
|         borderColor: '#000', | ||||
|         formatter: (params: any): string => { | ||||
|           const utxo = params.data[0] as Utxo; | ||||
|           const valueStr = renderSats(utxo.value, this.stateService.network); | ||||
|           return ` | ||||
|           <b style="color: white;">${utxo.txid.slice(0, 6)}...${utxo.txid.slice(-6)}:${utxo.vout}</b> | ||||
|           <br> | ||||
|           ${valueStr}`;
 | ||||
|         }, | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|     this.cd.markForCheck(); | ||||
|   } | ||||
| 
 | ||||
|   onChartClick(e): void { | ||||
|     if (e.data?.[0]?.txid) { | ||||
|       this.zone.run(() => { | ||||
|         const url = this.relativeUrlPipe.transform(`/tx/${e.data[0].txid}`); | ||||
|         if (e.event.event.shiftKey || e.event.event.ctrlKey || e.event.event.metaKey) { | ||||
|           window.open(url + '?mode=details#vout=' + e.data[0].vout); | ||||
|         } else { | ||||
|           this.router.navigate([url], { fragment: `vout=${e.data[0].vout}` }); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onChartInit(ec): void { | ||||
|     this.chartInstance = ec; | ||||
|     this.chartInstance.on('click', 'series', this.onChartClick.bind(this)); | ||||
|   } | ||||
| 
 | ||||
|   ngOnDestroy(): void { | ||||
|     if (this.subscription) { | ||||
|       this.subscription.unsubscribe(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   isMobile(): boolean { | ||||
|     return (window.innerWidth <= 767.98); | ||||
|   } | ||||
| } | ||||
| @ -1,6 +1,6 @@ | ||||
| // Import tree-shakeable echarts
 | ||||
| import * as echarts from 'echarts/core'; | ||||
| import { LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart } from 'echarts/charts'; | ||||
| import { LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart, CustomChart } from 'echarts/charts'; | ||||
| import { TitleComponent, TooltipComponent, GridComponent, LegendComponent, GeoComponent, DataZoomComponent, VisualMapComponent, MarkLineComponent } from 'echarts/components'; | ||||
| import { SVGRenderer, CanvasRenderer } from 'echarts/renderers'; | ||||
| // Typescript interfaces
 | ||||
| @ -12,6 +12,7 @@ echarts.use([ | ||||
|   TitleComponent, TooltipComponent, GridComponent, | ||||
|   LegendComponent, GeoComponent, DataZoomComponent, | ||||
|   VisualMapComponent, MarkLineComponent, | ||||
|   LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart | ||||
|   LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart, | ||||
|   CustomChart, | ||||
| ]); | ||||
| export { echarts, EChartsOption, TreemapSeriesOption, LineSeriesOption, PieSeriesOption }; | ||||
| @ -36,6 +36,7 @@ import { HashrateChartPoolsComponent } from '../components/hashrates-chart-pools | ||||
| import { BlockHealthGraphComponent } from '../components/block-health-graph/block-health-graph.component'; | ||||
| import { AddressComponent } from '../components/address/address.component'; | ||||
| import { AddressGraphComponent } from '../components/address-graph/address-graph.component'; | ||||
| import { UtxoGraphComponent } from '../components/utxo-graph/utxo-graph.component'; | ||||
| import { ActiveAccelerationBox } from '../components/acceleration/active-acceleration-box/active-acceleration-box.component'; | ||||
| import { CommonModule } from '@angular/common'; | ||||
| 
 | ||||
| @ -76,6 +77,7 @@ import { CommonModule } from '@angular/common'; | ||||
|     HashrateChartPoolsComponent, | ||||
|     BlockHealthGraphComponent, | ||||
|     AddressGraphComponent, | ||||
|     UtxoGraphComponent, | ||||
|     ActiveAccelerationBox, | ||||
|   ], | ||||
|   imports: [ | ||||
|  | ||||
| @ -233,3 +233,10 @@ interface AssetStats { | ||||
|   peg_out_amount: number; | ||||
|   burn_count: number; | ||||
| } | ||||
| 
 | ||||
| export interface Utxo { | ||||
|   txid: string; | ||||
|   vout: number; | ||||
|   value: number; | ||||
|   status: Status; | ||||
| } | ||||
| @ -13,7 +13,8 @@ class GuardService { | ||||
| 
 | ||||
|   trackerGuard(route: Route, segments: UrlSegment[]): boolean { | ||||
|     const preferredRoute = this.router.getCurrentNavigation()?.extractedUrl.queryParams?.mode; | ||||
|     return (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98; | ||||
|     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'].includes(path[1].path)); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { HttpClient, HttpParams } from '@angular/common/http'; | ||||
| import { BehaviorSubject, Observable, catchError, filter, from, of, shareReplay, switchMap, take, tap } from 'rxjs'; | ||||
| import { Transaction, Address, Outspend, Recent, Asset, ScriptHash, AddressTxSummary } from '../interfaces/electrs.interface'; | ||||
| import { Transaction, Address, Outspend, Recent, Asset, ScriptHash, AddressTxSummary, Utxo } from '../interfaces/electrs.interface'; | ||||
| import { StateService } from './state.service'; | ||||
| import { BlockExtended } from '../interfaces/node-api.interface'; | ||||
| import { calcScriptHash$ } from '../bitcoin.utils'; | ||||
| @ -166,6 +166,16 @@ export class ElectrsApiService { | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   getAddressUtxos$(address: string): Observable<Utxo[]> { | ||||
|     return this.httpClient.get<Utxo[]>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/utxo'); | ||||
|   } | ||||
| 
 | ||||
|   getScriptHashUtxos$(script: string): Observable<Utxo[]> { | ||||
|     return from(calcScriptHash$(script)).pipe( | ||||
|       switchMap(scriptHash => this.httpClient.get<Utxo[]>(this.apiBaseUrl + this.apiBasePath + '/api/scripthash/' + scriptHash + '/utxo')), | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   getAsset$(assetId: string): Observable<Asset> { | ||||
|     return this.httpClient.get<Asset>(this.apiBaseUrl + this.apiBasePath + '/api/asset/' + assetId); | ||||
|   } | ||||
|  | ||||
| @ -28,7 +28,7 @@ export class EtaService { | ||||
|     return combineLatest([ | ||||
|       this.stateService.mempoolTxPosition$.pipe(map(p => p?.position)), | ||||
|       this.stateService.difficultyAdjustment$, | ||||
|       miningStats ? of(miningStats) : this.miningService.getMiningStats('1w'), | ||||
|       miningStats ? of(miningStats) : this.miningService.getMiningStats('1m'), | ||||
|     ]).pipe( | ||||
|       map(([mempoolPosition, da, miningStats]) => { | ||||
|         if (!mempoolPosition || !estimate?.pools?.length || !miningStats || !da) { | ||||
| @ -166,7 +166,7 @@ export class EtaService { | ||||
|         pools[pool.poolUniqueId] = pool; | ||||
|       } | ||||
|       const unacceleratedPosition = this.mempoolPositionFromFees(getUnacceleratedFeeRate(tx, true), mempoolBlocks); | ||||
|       const totalAcceleratedHashrate = accelerationPositions.reduce((total, pos) => total + (pools[pos.poolId].lastEstimatedHashrate), 0); | ||||
|       const totalAcceleratedHashrate = accelerationPositions.reduce((total, pos) => total + (pools[pos.poolId]?.lastEstimatedHashrate || 0), 0); | ||||
|       const shares = [ | ||||
|         { | ||||
|           block: unacceleratedPosition.block, | ||||
| @ -174,7 +174,7 @@ export class EtaService { | ||||
|         }, | ||||
|         ...accelerationPositions.map(pos => ({ | ||||
|           block: pos.block, | ||||
|           hashrateShare: ((pools[pos.poolId].lastEstimatedHashrate) / miningStats.lastEstimatedHashrate) | ||||
|           hashrateShare: ((pools[pos.poolId]?.lastEstimatedHashrate || 0) / miningStats.lastEstimatedHashrate) | ||||
|         })) | ||||
|       ]; | ||||
|       return this.calculateETAFromShares(shares, da); | ||||
|  | ||||
| @ -1,5 +1,7 @@ | ||||
| import { MempoolBlockDelta, MempoolBlockDeltaCompressed, MempoolDeltaChange, TransactionCompressed } from "../interfaces/websocket.interface"; | ||||
| import { TransactionStripped } from "../interfaces/node-api.interface"; | ||||
| import { AmountShortenerPipe } from "./pipes/amount-shortener.pipe"; | ||||
| const amountShortenerPipe = new AmountShortenerPipe(); | ||||
| 
 | ||||
| export function isMobile(): boolean { | ||||
|   return (window.innerWidth <= 767.98); | ||||
| @ -184,6 +186,33 @@ export function uncompressDeltaChange(block: number, delta: MempoolBlockDeltaCom | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export function renderSats(value: number, network: string, mode: 'sats' | 'btc' | 'auto' = 'auto'): string { | ||||
|   let prefix = ''; | ||||
|   switch (network) { | ||||
|     case 'liquid': | ||||
|       prefix = 'L'; | ||||
|       break; | ||||
|     case 'liquidtestnet': | ||||
|       prefix = 'tL'; | ||||
|       break; | ||||
|     case 'testnet': | ||||
|     case 'testnet4': | ||||
|       prefix = 't'; | ||||
|       break; | ||||
|     case 'signet': | ||||
|       prefix = 's'; | ||||
|       break; | ||||
|   } | ||||
|   if (mode === 'btc' || (mode === 'auto' && value >= 1000000)) { | ||||
|     return `${amountShortenerPipe.transform(value / 100000000)} ${prefix}BTC`; | ||||
|   } else { | ||||
|     if (prefix.length) { | ||||
|       prefix += '-'; | ||||
|     } | ||||
|     return `${amountShortenerPipe.transform(value)} ${prefix}sats`; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export function insecureRandomUUID(): string { | ||||
|   const hexDigits = '0123456789abcdef'; | ||||
|   const uuidLengths = [8, 4, 4, 4, 12]; | ||||
|  | ||||
| @ -13,8 +13,13 @@ | ||||
|         </div> | ||||
|         @if (!enterpriseInfo?.footer_img) { | ||||
|           <p class="explore-tagline-mobile"> | ||||
|             <ng-container i18n="@@7deec1c1520f06170e1f8e8ddfbe4532312f638f">Explore the full Bitcoin ecosystem</ng-container> | ||||
|             <ng-template [ngIf]="locale.substr(0, 2) === 'en'">®</ng-template> | ||||
|             @if (officialMempoolSpace) { | ||||
|               <ng-container i18n="@@7deec1c1520f06170e1f8e8ddfbe4532312f638f">Explore the full Bitcoin ecosystem</ng-container> | ||||
|               <ng-template [ngIf]="locale.substr(0, 2) === 'en'">®</ng-template>   | ||||
|             } @else { | ||||
|               <ng-container i18n="shared.be-your-own-explorer">Be your own explorer</ng-container> | ||||
|               <ng-template [ngIf]="locale.substr(0, 2) === 'en'">™</ng-template> | ||||
|             } | ||||
|           </p> | ||||
|         } | ||||
|         <div class="site-options language-selector d-flex justify-content-center align-items-center" [class]="{'services': isServicesPage}"> | ||||
| @ -52,8 +57,13 @@ | ||||
|             <span *ngIf="!user" i18n="shared.sign-in" class="nowrap">Sign In</span> | ||||
|           </a> | ||||
|           <p class="explore-tagline-desktop"> | ||||
|             <ng-container i18n="@@7deec1c1520f06170e1f8e8ddfbe4532312f638f">Explore the full Bitcoin ecosystem</ng-container> | ||||
|             <ng-template [ngIf]="locale.substr(0, 2) === 'en'">®</ng-template> | ||||
|             @if (officialMempoolSpace) { | ||||
|               <ng-container i18n="@@7deec1c1520f06170e1f8e8ddfbe4532312f638f">Explore the full Bitcoin ecosystem</ng-container> | ||||
|               <ng-template [ngIf]="locale.substr(0, 2) === 'en'">®</ng-template>   | ||||
|             } @else { | ||||
|               <ng-container i18n="shared.be-your-own-explorer">Be your own explorer</ng-container> | ||||
|               <ng-template [ngIf]="locale.substr(0, 2) === 'en'">™</ng-template> | ||||
|             } | ||||
|           </p> | ||||
|         } | ||||
|       </div> | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user