add wallet unfurler preview
This commit is contained in:
		
							parent
							
								
									1d2a5e9c94
								
							
						
					
					
						commit
						dcae94ba66
					
				| @ -0,0 +1,31 @@ | |||||||
|  | <div class="box preview-box" *ngIf="(walletAddresses$ | async) as walletAddresses"> | ||||||
|  |   <app-preview-title> | ||||||
|  |     <span i18n="shared.wallet">Wallet</span> | ||||||
|  |   </app-preview-title> | ||||||
|  |   <div> | ||||||
|  |     <div class="table-col"> | ||||||
|  |       <table class="table table-borderless dual-col-striped table-fixed wallet-table" *ngIf="(walletStats$ | async) as walletStats"> | ||||||
|  |         <tbody> | ||||||
|  |           <tr> | ||||||
|  |             <td i18n="address.number-addresses">Addresses</td> | ||||||
|  |             <td class="wrap-cell">{{ addressStrings.length }}</td> | ||||||
|  |             <td class="spacer"></td> | ||||||
|  |             <td i18n="address.utxos">UTXOs</td> | ||||||
|  |             <td class="wrap-cell">{{ walletStats.utxos }}</td> | ||||||
|  |           </tr> | ||||||
|  |           <tr> | ||||||
|  |             <td i18n="wallet.balance-btc">Balance (BTC)</td> | ||||||
|  |             <td class="wrap-cell"><app-amount [satoshis]="walletStats.balance" [noFiat]="true" [digitsInfo]="walletStats.balance > 1_000_000_000 ? '1.4-4' : '1.8-8'"></app-amount></td> | ||||||
|  |             <td class="spacer"></td> | ||||||
|  |             <td i18n="wallet.balance-usd">Balance (USD)</td> | ||||||
|  |             <td class="wrap-cell"><span class="fiat"><app-fiat [value]="walletStats.balance"></app-fiat></span></td> | ||||||
|  |           </tr> | ||||||
|  |         </tbody> | ||||||
|  |       </table> | ||||||
|  |     </div> | ||||||
|  |     <div class="w-100 d-block d-md-none"></div> | ||||||
|  |     <div class="col-md graph-col"> | ||||||
|  |       <app-address-graph [addressSummary$]="walletSummary$" period="all" [widget]="true" [defaultFiat]="true" [height]="330" [left]="-40" [right]="-40" [showLegend]="false" [showYAxis]="false"/> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
| @ -0,0 +1,31 @@ | |||||||
|  | .title-wrapper { | ||||||
|  |   padding: 0 15px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .graph-col { | ||||||
|  |   height: 350px; | ||||||
|  |   text-align: center; | ||||||
|  |   padding: 0; | ||||||
|  |   margin-left: 2px; | ||||||
|  |   margin-right: 15px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .table-col { | ||||||
|  |   overflow: hidden; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .table { | ||||||
|  |   font-size: 32px; | ||||||
|  | 
 | ||||||
|  |   ::ng-deep .symbol { | ||||||
|  |     font-size: 24px; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   .spacer { | ||||||
|  |     background: none; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .fiat { | ||||||
|  |   display: block; | ||||||
|  | } | ||||||
							
								
								
									
										245
									
								
								frontend/src/app/components/wallet/wallet-preview.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										245
									
								
								frontend/src/app/components/wallet/wallet-preview.component.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,245 @@ | |||||||
|  | import { Component, OnInit, OnDestroy } from '@angular/core'; | ||||||
|  | import { ActivatedRoute, ParamMap } from '@angular/router'; | ||||||
|  | import { switchMap, catchError, map, tap, shareReplay, startWith, scan } from 'rxjs/operators'; | ||||||
|  | import { Address, AddressTxSummary, ChainStats, Transaction } from '@interfaces/electrs.interface'; | ||||||
|  | import { StateService } from '@app/services/state.service'; | ||||||
|  | import { ApiService } from '@app/services/api.service'; | ||||||
|  | import { of, Observable, Subscription } from 'rxjs'; | ||||||
|  | import { SeoService } from '@app/services/seo.service'; | ||||||
|  | import { seoDescriptionNetwork } from '@app/shared/common.utils'; | ||||||
|  | import { WalletAddress } from '@interfaces/node-api.interface'; | ||||||
|  | import { OpenGraphService } from '../../services/opengraph.service'; | ||||||
|  | import { WebsocketService } from '../../services/websocket.service'; | ||||||
|  | 
 | ||||||
|  | class WalletStats implements ChainStats { | ||||||
|  |   addresses: string[]; | ||||||
|  |   funded_txo_count: number; | ||||||
|  |   funded_txo_sum: number; | ||||||
|  |   spent_txo_count: number; | ||||||
|  |   spent_txo_sum: number; | ||||||
|  |   tx_count: number; | ||||||
|  | 
 | ||||||
|  |   constructor (stats: ChainStats[], addresses: string[]) { | ||||||
|  |     Object.assign(this, stats.reduce((acc, stat) => { | ||||||
|  |         acc.funded_txo_count += stat.funded_txo_count; | ||||||
|  |         acc.funded_txo_sum += stat.funded_txo_sum; | ||||||
|  |         acc.spent_txo_count += stat.spent_txo_count; | ||||||
|  |         acc.spent_txo_sum += stat.spent_txo_sum; | ||||||
|  |         return acc; | ||||||
|  |       }, { | ||||||
|  |         funded_txo_count: 0, | ||||||
|  |         funded_txo_sum: 0, | ||||||
|  |         spent_txo_count: 0, | ||||||
|  |         spent_txo_sum: 0, | ||||||
|  |         tx_count: 0, | ||||||
|  |       }) | ||||||
|  |     ); | ||||||
|  |     this.addresses = addresses; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public addTx(tx: Transaction): void { | ||||||
|  |     for (const vin of tx.vin) { | ||||||
|  |       if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) { | ||||||
|  |         this.spendTxo(vin.prevout.value); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     for (const vout of tx.vout) { | ||||||
|  |       if (this.addresses.includes(vout.scriptpubkey_address)) { | ||||||
|  |         this.fundTxo(vout.value); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     this.tx_count++; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public removeTx(tx: Transaction): void { | ||||||
|  |     for (const vin of tx.vin) { | ||||||
|  |       if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) { | ||||||
|  |         this.unspendTxo(vin.prevout.value); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     for (const vout of tx.vout) { | ||||||
|  |       if (this.addresses.includes(vout.scriptpubkey_address)) { | ||||||
|  |         this.unfundTxo(vout.value); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     this.tx_count--; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private fundTxo(value: number): void { | ||||||
|  |     this.funded_txo_sum += value; | ||||||
|  |     this.funded_txo_count++; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private unfundTxo(value: number): void { | ||||||
|  |     this.funded_txo_sum -= value; | ||||||
|  |     this.funded_txo_count--; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private spendTxo(value: number): void { | ||||||
|  |     this.spent_txo_sum += value; | ||||||
|  |     this.spent_txo_count++; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private unspendTxo(value: number): void { | ||||||
|  |     this.spent_txo_sum -= value; | ||||||
|  |     this.spent_txo_count--; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   get balance(): number { | ||||||
|  |     return this.funded_txo_sum - this.spent_txo_sum; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   get totalReceived(): number { | ||||||
|  |     return this.funded_txo_sum; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   get utxos(): number { | ||||||
|  |     return this.funded_txo_count - this.spent_txo_count; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | @Component({ | ||||||
|  |   selector: 'app-wallet-preview', | ||||||
|  |   templateUrl: './wallet-preview.component.html', | ||||||
|  |   styleUrls: ['./wallet-preview.component.scss'] | ||||||
|  | }) | ||||||
|  | export class WalletPreviewComponent implements OnInit, OnDestroy { | ||||||
|  |   network = ''; | ||||||
|  | 
 | ||||||
|  |   addresses: Address[] = []; | ||||||
|  |   addressStrings: string[] = []; | ||||||
|  |   walletName: string; | ||||||
|  |   isLoadingWallet = true; | ||||||
|  |   wallet$: Observable<Record<string, WalletAddress>>; | ||||||
|  |   walletAddresses$: Observable<Record<string, Address>>; | ||||||
|  |   walletSummary$: Observable<AddressTxSummary[]>; | ||||||
|  |   walletStats$: Observable<WalletStats>; | ||||||
|  |   error: any; | ||||||
|  |   walletSubscription: Subscription; | ||||||
|  | 
 | ||||||
|  |   collapseAddresses: boolean = true; | ||||||
|  | 
 | ||||||
|  |   fullyLoaded = false; | ||||||
|  |   txCount = 0; | ||||||
|  |   received = 0; | ||||||
|  |   sent = 0; | ||||||
|  |   chainBalance = 0; | ||||||
|  | 
 | ||||||
|  |   constructor( | ||||||
|  |     private route: ActivatedRoute, | ||||||
|  |     private stateService: StateService, | ||||||
|  |     private apiService: ApiService, | ||||||
|  |     private seoService: SeoService, | ||||||
|  |     private websocketService: WebsocketService, | ||||||
|  |     private openGraphService: OpenGraphService, | ||||||
|  |   ) { } | ||||||
|  | 
 | ||||||
|  |   ngOnInit(): void { | ||||||
|  |     this.websocketService.want(['blocks', 'stats']); | ||||||
|  |     this.stateService.networkChanged$.subscribe((network) => this.network = network); | ||||||
|  |     this.wallet$ = this.route.paramMap.pipe( | ||||||
|  |       map((params: ParamMap) => params.get('wallet') as string), | ||||||
|  |       tap((walletName: string) => { | ||||||
|  |         this.walletName = walletName; | ||||||
|  |         this.openGraphService.waitFor('wallet-addresses-' + this.walletName); | ||||||
|  |         this.openGraphService.waitFor('wallet-data-' + this.walletName); | ||||||
|  |         this.openGraphService.waitFor('wallet-txs-' + this.walletName); | ||||||
|  |         this.seoService.setTitle($localize`:@@wallet.component.browser-title:Wallet: ${walletName}:INTERPOLATION:`); | ||||||
|  |         this.seoService.setDescription($localize`:@@meta.description.bitcoin.wallet:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} wallet ${walletName}:INTERPOLATION:.`); | ||||||
|  |       }), | ||||||
|  |       switchMap((walletName: string) => this.apiService.getWallet$(walletName).pipe( | ||||||
|  |         catchError((err) => { | ||||||
|  |           this.error = err; | ||||||
|  |           this.seoService.logSoft404(); | ||||||
|  |           console.log(err); | ||||||
|  |           this.openGraphService.fail('wallet-addresses-' + this.walletName); | ||||||
|  |           this.openGraphService.fail('wallet-data-' + this.walletName); | ||||||
|  |           this.openGraphService.fail('wallet-txs-' + this.walletName); | ||||||
|  |           return of({}); | ||||||
|  |         }) | ||||||
|  |       )), | ||||||
|  |       shareReplay(1), | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     this.walletAddresses$ = this.wallet$.pipe( | ||||||
|  |       map(wallet => { | ||||||
|  |         const walletInfo: Record<string, Address> = {}; | ||||||
|  |         for (const address of Object.keys(wallet)) { | ||||||
|  |           walletInfo[address] = { | ||||||
|  |             address, | ||||||
|  |             chain_stats: wallet[address].stats, | ||||||
|  |             mempool_stats: { | ||||||
|  |               funded_txo_count: 0, | ||||||
|  |               funded_txo_sum: 0, | ||||||
|  |               spent_txo_count: 0, spent_txo_sum: 0, tx_count: 0 | ||||||
|  |             }, | ||||||
|  |           }; | ||||||
|  |         } | ||||||
|  |         return walletInfo; | ||||||
|  |       }), | ||||||
|  |       tap(() => { | ||||||
|  |         this.isLoadingWallet = false; | ||||||
|  |       }) | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     this.walletSubscription = this.walletAddresses$.subscribe(wallet => { | ||||||
|  |       this.addressStrings = Object.keys(wallet); | ||||||
|  |       this.addresses = Object.values(wallet); | ||||||
|  |       this.openGraphService.waitOver('wallet-addresses-' + this.walletName); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     this.walletSummary$ = this.wallet$.pipe( | ||||||
|  |       map(wallet => this.deduplicateWalletTransactions(Object.values(wallet).flatMap(address => address.transactions))), | ||||||
|  |       tap(() => { | ||||||
|  |         this.openGraphService.waitOver('wallet-txs-' + this.walletName); | ||||||
|  |       }) | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     this.walletStats$ = this.wallet$.pipe( | ||||||
|  |       switchMap(wallet => { | ||||||
|  |         const walletStats = new WalletStats(Object.values(wallet).map(w => w.stats), Object.keys(wallet)); | ||||||
|  |         return this.stateService.walletTransactions$.pipe( | ||||||
|  |           startWith([]), | ||||||
|  |           scan((stats, newTransactions) => { | ||||||
|  |             for (const tx of newTransactions) { | ||||||
|  |               stats.addTx(tx); | ||||||
|  |             } | ||||||
|  |             return stats; | ||||||
|  |           }, walletStats), | ||||||
|  |         ); | ||||||
|  |       }), | ||||||
|  |       tap(() => { | ||||||
|  |         this.openGraphService.waitOver('wallet-data-' + this.walletName); | ||||||
|  |       }) | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] { | ||||||
|  |     const transactions = new Map<string, AddressTxSummary>(); | ||||||
|  |     for (const tx of walletTransactions) { | ||||||
|  |       if (transactions.has(tx.txid)) { | ||||||
|  |         transactions.get(tx.txid).value += tx.value; | ||||||
|  |       } else { | ||||||
|  |         transactions.set(tx.txid, tx); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return Array.from(transactions.values()).sort((a, b) => { | ||||||
|  |       if (a.height === b.height) { | ||||||
|  |         return b.tx_position - a.tx_position; | ||||||
|  |       } | ||||||
|  |       return b.height - a.height; | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   normalizeAddress(address: string): string { | ||||||
|  |     if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}$/.test(address)) { | ||||||
|  |       return address.toLowerCase(); | ||||||
|  |     } else { | ||||||
|  |       return address; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   ngOnDestroy(): void { | ||||||
|  |     this.walletSubscription.unsubscribe(); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -36,6 +36,7 @@ import { HashrateChartPoolsComponent } from '@components/hashrates-chart-pools/h | |||||||
| import { BlockHealthGraphComponent } from '@components/block-health-graph/block-health-graph.component'; | import { BlockHealthGraphComponent } from '@components/block-health-graph/block-health-graph.component'; | ||||||
| import { AddressComponent } from '@components/address/address.component'; | import { AddressComponent } from '@components/address/address.component'; | ||||||
| import { WalletComponent } from '@components/wallet/wallet.component'; | import { WalletComponent } from '@components/wallet/wallet.component'; | ||||||
|  | import { WalletPreviewComponent } from '@components/wallet/wallet-preview.component'; | ||||||
| import { AddressGraphComponent } from '@components/address-graph/address-graph.component'; | import { AddressGraphComponent } from '@components/address-graph/address-graph.component'; | ||||||
| import { UtxoGraphComponent } from '@components/utxo-graph/utxo-graph.component'; | import { UtxoGraphComponent } from '@components/utxo-graph/utxo-graph.component'; | ||||||
| import { ActiveAccelerationBox } from '@components/acceleration/active-acceleration-box/active-acceleration-box.component'; | import { ActiveAccelerationBox } from '@components/acceleration/active-acceleration-box/active-acceleration-box.component'; | ||||||
| @ -49,6 +50,7 @@ import { CommonModule } from '@angular/common'; | |||||||
|     MempoolBlockComponent, |     MempoolBlockComponent, | ||||||
|     AddressComponent, |     AddressComponent, | ||||||
|     WalletComponent, |     WalletComponent, | ||||||
|  |     WalletPreviewComponent, | ||||||
| 
 | 
 | ||||||
|     MiningDashboardComponent, |     MiningDashboardComponent, | ||||||
|     AcceleratorDashboardComponent, |     AcceleratorDashboardComponent, | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ import { RouterModule, Routes } from '@angular/router'; | |||||||
| import { TransactionPreviewComponent } from '@components/transaction/transaction-preview.component'; | import { TransactionPreviewComponent } from '@components/transaction/transaction-preview.component'; | ||||||
| import { BlockPreviewComponent } from '@components/block/block-preview.component'; | import { BlockPreviewComponent } from '@components/block/block-preview.component'; | ||||||
| import { AddressPreviewComponent } from '@components/address/address-preview.component'; | import { AddressPreviewComponent } from '@components/address/address-preview.component'; | ||||||
|  | import { WalletPreviewComponent } from '@components/wallet/wallet-preview.component'; | ||||||
| import { PoolPreviewComponent } from '@components/pool/pool-preview.component'; | import { PoolPreviewComponent } from '@components/pool/pool-preview.component'; | ||||||
| import { MasterPagePreviewComponent } from '@components/master-page-preview/master-page-preview.component'; | import { MasterPagePreviewComponent } from '@components/master-page-preview/master-page-preview.component'; | ||||||
| 
 | 
 | ||||||
| @ -20,6 +21,11 @@ const routes: Routes = [ | |||||||
|         children: [], |         children: [], | ||||||
|         component: AddressPreviewComponent |         component: AddressPreviewComponent | ||||||
|       }, |       }, | ||||||
|  |       { | ||||||
|  |         path: 'wallet/:wallet', | ||||||
|  |         children: [], | ||||||
|  |         component: WalletPreviewComponent | ||||||
|  |       }, | ||||||
|       { |       { | ||||||
|         path: 'tx/:id', |         path: 'tx/:id', | ||||||
|         children: [], |         children: [], | ||||||
|  | |||||||
| @ -85,6 +85,13 @@ const routes = { | |||||||
|       return `Address: ${path[0]}`; |       return `Address: ${path[0]}`; | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  |   wallet: { | ||||||
|  |     render: true, | ||||||
|  |     params: 1, | ||||||
|  |     getTitle(path) { | ||||||
|  |       return `Wallet: ${path[0]}`; | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|   blocks: { |   blocks: { | ||||||
|     title: "Blocks", |     title: "Blocks", | ||||||
|     fallbackImg: '/resources/previews/blocks.jpg', |     fallbackImg: '/resources/previews/blocks.jpg', | ||||||
| @ -289,6 +296,7 @@ export const networks = { | |||||||
|     routes: { // only dynamic routes supported
 |     routes: { // only dynamic routes supported
 | ||||||
|       block: routes.block, |       block: routes.block, | ||||||
|       address: routes.address, |       address: routes.address, | ||||||
|  |       wallet: routes.wallet, | ||||||
|       tx: routes.tx, |       tx: routes.tx, | ||||||
|       mining: { |       mining: { | ||||||
|         title: "Mining", |         title: "Mining", | ||||||
| @ -309,6 +317,7 @@ export const networks = { | |||||||
|     routes: { // only dynamic routes supported
 |     routes: { // only dynamic routes supported
 | ||||||
|       block: routes.block, |       block: routes.block, | ||||||
|       address: routes.address, |       address: routes.address, | ||||||
|  |       wallet: routes.wallet, | ||||||
|       tx: routes.tx, |       tx: routes.tx, | ||||||
|       mining: { |       mining: { | ||||||
|         title: "Mining", |         title: "Mining", | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user