diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 28faa9595..000a0f177 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -6,6 +6,7 @@ import { BlockComponent } from './components/block/block.component'; import { BlockAuditComponent } from './components/block-audit/block-audit.component'; import { BlockPreviewComponent } from './components/block/block-preview.component'; import { AddressComponent } from './components/address/address.component'; +import { AddressPreviewComponent } from './components/address/address-preview.component'; import { MasterPageComponent } from './components/master-page/master-page.component'; import { MasterPagePreviewComponent } from './components/master-page-preview/master-page-preview.component'; import { AboutComponent } from './components/about/about.component'; @@ -69,7 +70,10 @@ let routes: Routes = [ { path: 'address/:id', children: [], - component: AddressComponent + component: AddressComponent, + data: { + ogImage: true + } }, { path: 'tx', @@ -175,7 +179,10 @@ let routes: Routes = [ { path: 'address/:id', children: [], - component: AddressComponent + component: AddressComponent, + data: { + ogImage: true + } }, { path: 'tx', @@ -278,7 +285,10 @@ let routes: Routes = [ { path: 'address/:id', children: [], - component: AddressComponent + component: AddressComponent, + data: { + ogImage: true + } }, { path: 'tx', @@ -342,6 +352,21 @@ let routes: Routes = [ path: 'signet/block/:id', component: BlockPreviewComponent }, + { + path: 'address/:id', + children: [], + component: AddressPreviewComponent + }, + { + path: 'testnet/address/:id', + children: [], + component: AddressPreviewComponent + }, + { + path: 'signet/address/:id', + children: [], + component: AddressPreviewComponent + }, ], }, { @@ -415,7 +440,10 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') { { path: 'address/:id', children: [], - component: AddressComponent + component: AddressComponent, + data: { + ogImage: true + } }, { path: 'tx', @@ -522,7 +550,10 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') { { path: 'address/:id', children: [], - component: AddressComponent + component: AddressComponent, + data: { + ogImage: true + } }, { path: 'tx', @@ -595,6 +626,16 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') { path: 'testnet/block/:id', component: BlockPreviewComponent }, + { + path: 'address/:id', + children: [], + component: AddressPreviewComponent + }, + { + path: 'testnet/address/:id', + children: [], + component: AddressPreviewComponent + }, ], }, { diff --git a/frontend/src/app/components/address/address-preview.component.html b/frontend/src/app/components/address/address-preview.component.html new file mode 100644 index 000000000..bc73d064b --- /dev/null +++ b/frontend/src/app/components/address/address-preview.component.html @@ -0,0 +1,55 @@ +
+
+
+
+

Address

+
+ + {{addressString.slice(0,-4)}}{{addressString.slice(-4)}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Unconfidential + {{ addressInfo.unconfidential | shortenString : 14 }} + {{ addressInfo.unconfidential }} +
Total received
Total sent
Balance
Transactions{{ txCount | number }}
Unspent TXOs{{ totalUnspent | number }}
+
+
+
+
+ +
+
+
+
+ + + Confidential + diff --git a/frontend/src/app/components/address/address-preview.component.scss b/frontend/src/app/components/address/address-preview.component.scss new file mode 100644 index 000000000..f286c6ca1 --- /dev/null +++ b/frontend/src/app/components/address/address-preview.component.scss @@ -0,0 +1,46 @@ +h1 { + font-size: 42px; + margin: 0; +} + +.qr-wrapper { + background-color: #FFF; + padding: 10px; + padding-bottom: 5px; + display: inline-block; +} + +.qrcode-col { + width: 420px; + min-width: 420px; + flex-grow: 0; + flex-shrink: 0; + text-align: center; +} + +.table { + font-size: 24px; + + ::ng-deep .symbol { + font-size: 18px; + } +} + +.address-link { + font-size: 20px; + margin-bottom: 0.5em; + display: flex; + flex-direction: row; + align-items: baseline; + .truncated-address { + text-overflow: ellipsis; + overflow: hidden; + max-width: calc(505px - 4em); + display: inline-block; + white-space: nowrap; + } + .last-four { + display: inline-block; + white-space: nowrap; + } +} diff --git a/frontend/src/app/components/address/address-preview.component.ts b/frontend/src/app/components/address/address-preview.component.ts new file mode 100644 index 000000000..c661c29db --- /dev/null +++ b/frontend/src/app/components/address/address-preview.component.ts @@ -0,0 +1,116 @@ +import { Component, OnInit, OnDestroy } 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, Transaction } from '../../interfaces/electrs.interface'; +import { StateService } from 'src/app/services/state.service'; +import { OpenGraphService } from 'src/app/services/opengraph.service'; +import { AudioService } from 'src/app/services/audio.service'; +import { ApiService } from 'src/app/services/api.service'; +import { of, merge, Subscription, Observable } from 'rxjs'; +import { SeoService } from 'src/app/services/seo.service'; +import { AddressInformation } from 'src/app/interfaces/node-api.interface'; + +@Component({ + selector: 'app-address-preview', + templateUrl: './address-preview.component.html', + styleUrls: ['./address-preview.component.scss'] +}) +export class AddressPreviewComponent implements OnInit, OnDestroy { + network = ''; + + address: Address; + addressString: string; + isLoadingAddress = true; + error: any; + mainSubscription: Subscription; + addressLoadingStatus$: Observable; + addressInfo: null | AddressInformation = null; + + totalConfirmedTxCount = 0; + loadedConfirmedTxCount = 0; + txCount = 0; + received = 0; + sent = 0; + totalUnspent = 0; + + constructor( + private route: ActivatedRoute, + private electrsApiService: ElectrsApiService, + private stateService: StateService, + private apiService: ApiService, + private seoService: SeoService, + private openGraphService: OpenGraphService, + ) { } + + ngOnInit() { + this.openGraphService.setPreviewLoading(); + this.stateService.networkChanged$.subscribe((network) => this.network = network); + + this.addressLoadingStatus$ = this.route.paramMap + .pipe( + switchMap(() => this.stateService.loadingIndicators$), + map((indicators) => indicators['address-' + this.addressString] !== undefined ? indicators['address-' + this.addressString] : 0) + ); + + this.mainSubscription = this.route.paramMap + .pipe( + switchMap((params: ParamMap) => { + this.error = undefined; + this.isLoadingAddress = true; + this.loadedConfirmedTxCount = 0; + this.address = null; + this.addressInfo = null; + this.addressString = params.get('id') || ''; + if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}$/.test(this.addressString)) { + this.addressString = this.addressString.toLowerCase(); + } + this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`); + + return this.electrsApiService.getAddress$(this.addressString) + .pipe( + catchError((err) => { + this.isLoadingAddress = false; + this.error = err; + console.log(err); + return of(null); + }) + ); + }) + ) + .pipe( + filter((address) => !!address), + tap((address: Address) => { + if ((this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet') && /^([m-zA-HJ-NP-Z1-9]{26,35}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[a-km-zA-HJ-NP-Z1-9]{80})$/.test(address.address)) { + this.apiService.validateAddress$(address.address) + .subscribe((addressInfo) => { + this.addressInfo = addressInfo; + }); + } + this.address = address; + this.updateChainStats(); + this.isLoadingAddress = false; + this.openGraphService.setPreviewReady(); + }) + ) + .subscribe(() => {}, + (error) => { + console.log(error); + this.error = error; + this.isLoadingAddress = false; + } + ); + } + + updateChainStats() { + this.received = this.address.chain_stats.funded_txo_sum + this.address.mempool_stats.funded_txo_sum; + this.sent = this.address.chain_stats.spent_txo_sum + this.address.mempool_stats.spent_txo_sum; + this.txCount = this.address.chain_stats.tx_count + this.address.mempool_stats.tx_count; + this.totalConfirmedTxCount = this.address.chain_stats.tx_count; + this.totalUnspent = this.address.chain_stats.funded_txo_count - this.address.chain_stats.spent_txo_count; + } + + ngOnDestroy() { + this.mainSubscription.unsubscribe(); + } +} diff --git a/frontend/src/app/components/block/block-preview.component.scss b/frontend/src/app/components/block/block-preview.component.scss index 6099f5d47..f2049a1d3 100644 --- a/frontend/src/app/components/block/block-preview.component.scss +++ b/frontend/src/app/components/block/block-preview.component.scss @@ -1,7 +1,3 @@ -.box { - padding: 2rem 3rem; -} - .block-title { margin-bottom: 0.75em; font-size: 42px; diff --git a/frontend/src/app/services/opengraph.service.ts b/frontend/src/app/services/opengraph.service.ts index 48064fdea..ad62a889c 100644 --- a/frontend/src/app/services/opengraph.service.ts +++ b/frontend/src/app/services/opengraph.service.ts @@ -58,4 +58,14 @@ export class OpenGraphService { this.metaService.updateTag({ property: 'og:image:width', content: '1000' }); this.metaService.updateTag({ property: 'og:image:height', content: '500' }); } + + /// signal that the unfurler should wait for a 'ready' signal before taking a screenshot + setPreviewLoading() { + this.metaService.updateTag({ property: 'og:loading', content: 'loading'}); + } + + // signal to the unfurler that the page is ready for a screenshot + setPreviewReady() { + this.metaService.updateTag({ property: 'og:ready', content: 'ready'}); + } } diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index cd087a3c4..fc7acec54 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -50,6 +50,7 @@ import { BlockAuditComponent } from '../components/block-audit/block-audit.compo import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component'; import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component'; import { AddressComponent } from '../components/address/address.component'; +import { AddressPreviewComponent } from '../components/address/address-preview.component'; import { SearchFormComponent } from '../components/search-form/search-form.component'; import { AddressLabelsComponent } from '../components/address-labels/address-labels.component'; import { FooterComponent } from '../components/footer/footer.component'; @@ -124,6 +125,7 @@ import { TimestampComponent } from './components/timestamp/timestamp.component'; BlockOverviewTooltipComponent, TransactionsListComponent, AddressComponent, + AddressPreviewComponent, SearchFormComponent, TimeSpanComponent, AddressLabelsComponent, @@ -225,6 +227,7 @@ import { TimestampComponent } from './components/timestamp/timestamp.component'; BlockOverviewTooltipComponent, TransactionsListComponent, AddressComponent, + AddressPreviewComponent, SearchFormComponent, TimeSpanComponent, AddressLabelsComponent, diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index d5e8dde28..da4bdcffe 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -87,6 +87,11 @@ body { box-shadow: 0.125rem 0.125rem 0.25rem rgba(0,0,0,0.075); } +.preview-box { + min-height: 512px; + padding: 2rem 3rem; +} + @media (max-width: 767.98px) { .box { padding: 0.75rem; diff --git a/unfurler/src/index.ts b/unfurler/src/index.ts index 998beb1eb..49815fcb1 100644 --- a/unfurler/src/index.ts +++ b/unfurler/src/index.ts @@ -53,7 +53,7 @@ class Server { } async clusterTask({ page, data: { url, action } }) { - await page.goto(url, { waitUntil: "domcontentloaded" }); + await page.goto(url, { waitUntil: "networkidle0" }); switch (action) { case 'screenshot': { await page.evaluate(async () => { @@ -73,11 +73,21 @@ class Server { }), ]); }); + const waitForReady = await page.$('meta[property="og:loading"]'); + const alreadyReady = await page.$('meta[property="og:ready"]'); + if (waitForReady != null && alreadyReady == null) { + try { + await page.waitForSelector('meta[property="og:ready]"', { timeout: 10000 }); + } catch (e) { + // probably timed out + } + } return page.screenshot(); } break; default: { try { - await page.waitForSelector('meta[property="og:title"', { timeout: 5000 }) + await page.waitForSelector('meta[property="og:title"]', { timeout: 10000 }) + const tag = await page.$('meta[property="og:title"]'); } catch (e) { // probably timed out }