From 18d18fa234709efd0c366413b8438c7a8ef31bb1 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 11 Aug 2022 17:19:12 +0000 Subject: [PATCH] Add lightning node link previews --- frontend/src/app/app-routing.module.ts | 12 +++ .../master-page-preview.component.html | 6 +- .../master-page-preview.component.ts | 2 + .../lightning/lightning-previews.module.ts | 26 +++++ .../lightning-previews.routing.module.ts | 20 ++++ .../src/app/lightning/lightning.module.ts | 21 ++++ .../node/node-preview.component.html | 50 +++++++++ .../node/node-preview.component.scss | 27 +++++ .../lightning/node/node-preview.component.ts | 101 ++++++++++++++++++ .../nodes-channels-map.component.html | 4 +- .../nodes-channels-map.component.scss | 12 +++ .../nodes-channels-map.component.ts | 10 +- frontend/src/app/services/state.service.ts | 14 +++ frontend/src/app/shared/shared.module.ts | 3 +- unfurler/src/index.ts | 29 +++-- 15 files changed, 322 insertions(+), 15 deletions(-) create mode 100644 frontend/src/app/lightning/lightning-previews.module.ts create mode 100644 frontend/src/app/lightning/lightning-previews.routing.module.ts create mode 100644 frontend/src/app/lightning/node/node-preview.component.html create mode 100644 frontend/src/app/lightning/node/node-preview.component.scss create mode 100644 frontend/src/app/lightning/node/node-preview.component.ts diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 1d6d58e2b..bd2d8c541 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -366,6 +366,18 @@ let routes: Routes = [ children: [], component: AddressPreviewComponent }, + { + path: 'lightning', + loadChildren: () => import('./lightning/lightning-previews.module').then(m => m.LightningPreviewsModule) + }, + { + path: 'testnet/lightning', + loadChildren: () => import('./lightning/lightning-previews.module').then(m => m.LightningPreviewsModule) + }, + { + path: 'signet/lightning', + loadChildren: () => import('./lightning/lightning-previews.module').then(m => m.LightningPreviewsModule) + }, ], }, { diff --git a/frontend/src/app/components/master-page-preview/master-page-preview.component.html b/frontend/src/app/components/master-page-preview/master-page-preview.component.html index 52a3e7026..7d4f5364a 100644 --- a/frontend/src/app/components/master-page-preview/master-page-preview.component.html +++ b/frontend/src/app/components/master-page-preview/master-page-preview.component.html @@ -7,12 +7,12 @@
- logo Signet - testnet logo Testnet + logo Signet Lightning + testnet logo Testnet Lightning bisq logo Bisq liquid mainnet logo Liquid liquid testnet logo Liquid Testnet - bitcoin logo Mainnet + bitcoin logo Mainnet Lightning
diff --git a/frontend/src/app/components/master-page-preview/master-page-preview.component.ts b/frontend/src/app/components/master-page-preview/master-page-preview.component.ts index 9678aa32d..61a392b5e 100644 --- a/frontend/src/app/components/master-page-preview/master-page-preview.component.ts +++ b/frontend/src/app/components/master-page-preview/master-page-preview.component.ts @@ -10,6 +10,7 @@ import { LanguageService } from 'src/app/services/language.service'; }) export class MasterPagePreviewComponent implements OnInit { network$: Observable; + lightning$: Observable; officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE; urlLanguage: string; @@ -20,6 +21,7 @@ export class MasterPagePreviewComponent implements OnInit { ngOnInit() { this.network$ = merge(of(''), this.stateService.networkChanged$); + this.lightning$ = this.stateService.lightningChanged$; this.urlLanguage = this.languageService.getLanguageForUrl(); } } diff --git a/frontend/src/app/lightning/lightning-previews.module.ts b/frontend/src/app/lightning/lightning-previews.module.ts new file mode 100644 index 000000000..d3a08dff6 --- /dev/null +++ b/frontend/src/app/lightning/lightning-previews.module.ts @@ -0,0 +1,26 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { RouterModule } from '@angular/router'; +import { GraphsModule } from '../graphs/graphs.module'; +import { LightningModule } from './lightning.module'; +import { LightningApiService } from './lightning-api.service'; +import { NodePreviewComponent } from './node/node-preview.component'; +import { LightningPreviewsRoutingModule } from './lightning-previews.routing.module'; +@NgModule({ + declarations: [ + NodePreviewComponent, + ], + imports: [ + CommonModule, + SharedModule, + RouterModule, + GraphsModule, + LightningPreviewsRoutingModule, + LightningModule, + ], + providers: [ + LightningApiService, + ] +}) +export class LightningPreviewsModule { } diff --git a/frontend/src/app/lightning/lightning-previews.routing.module.ts b/frontend/src/app/lightning/lightning-previews.routing.module.ts new file mode 100644 index 000000000..e499cc5c7 --- /dev/null +++ b/frontend/src/app/lightning/lightning-previews.routing.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { NodePreviewComponent } from './node/node-preview.component'; + +const routes: Routes = [ + { + path: 'node/:public_key', + component: NodePreviewComponent, + }, + { + path: '**', + redirectTo: '' + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class LightningPreviewsRoutingModule { } diff --git a/frontend/src/app/lightning/lightning.module.ts b/frontend/src/app/lightning/lightning.module.ts index 781418bfd..c01792815 100644 --- a/frontend/src/app/lightning/lightning.module.ts +++ b/frontend/src/app/lightning/lightning.module.ts @@ -53,6 +53,27 @@ import { NodesChannelsMap } from '../lightning/nodes-channels-map/nodes-channels LightningRoutingModule, GraphsModule, ], + exports: [ + LightningDashboardComponent, + NodesListComponent, + NodeStatisticsComponent, + NodeStatisticsChartComponent, + NodeComponent, + ChannelsListComponent, + ChannelComponent, + LightningWrapperComponent, + ChannelBoxComponent, + ClosingTypeComponent, + LightningStatisticsChartComponent, + NodesNetworksChartComponent, + ChannelsStatisticsComponent, + NodesPerISPChartComponent, + NodesPerCountry, + NodesPerISP, + NodesPerCountryChartComponent, + NodesMap, + NodesChannelsMap, + ], providers: [ LightningApiService, ] diff --git a/frontend/src/app/lightning/node/node-preview.component.html b/frontend/src/app/lightning/node/node-preview.component.html new file mode 100644 index 000000000..941dedaf5 --- /dev/null +++ b/frontend/src/app/lightning/node/node-preview.component.html @@ -0,0 +1,50 @@ +
+
+
+

{{ node.alias }}

+ + + + + + + + + + + + + + + + + + + + + + + +
Active capacity + +
Active channels + {{ node.active_channel_count }} +
Average size + +
Location + {{ node.city.en }} +
Country + {{ node.country.en }} {{ node.flag }} +
+
+
+ +
+
+
+ + +
+ Error loading data. +
+
diff --git a/frontend/src/app/lightning/node/node-preview.component.scss b/frontend/src/app/lightning/node/node-preview.component.scss new file mode 100644 index 000000000..c4712effa --- /dev/null +++ b/frontend/src/app/lightning/node/node-preview.component.scss @@ -0,0 +1,27 @@ +.title { + font-size: 52px; + margin-bottom: 48px; +} + +.table { + font-size: 32px; +} + +.map-col { + flex-grow: 0; + flex-shrink: 0; + width: 470px; + min-width: 470px; + padding: 0; + background: #181b2d; + max-height: 470px; + overflow: hidden; +} + +.row { + margin-right: 0; +} + +::ng-deep .symbol { + font-size: 24px; +} diff --git a/frontend/src/app/lightning/node/node-preview.component.ts b/frontend/src/app/lightning/node/node-preview.component.ts new file mode 100644 index 000000000..08a4ffc1a --- /dev/null +++ b/frontend/src/app/lightning/node/node-preview.component.ts @@ -0,0 +1,101 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { Observable } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import { SeoService } from 'src/app/services/seo.service'; +import { OpenGraphService } from 'src/app/services/opengraph.service'; +import { getFlagEmoji } from 'src/app/shared/graphs.utils'; +import { LightningApiService } from '../lightning-api.service'; +import { isMobile } from '../../shared/common.utils'; + +@Component({ + selector: 'app-node-preview', + templateUrl: './node-preview.component.html', + styleUrls: ['./node-preview.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NodePreviewComponent implements OnInit { + node$: Observable; + statistics$: Observable; + publicKey$: Observable; + selectedSocketIndex = 0; + qrCodeVisible = false; + channelsListStatus: string; + error: Error; + publicKey: string; + + publicKeySize = 99; + + constructor( + private lightningApiService: LightningApiService, + private activatedRoute: ActivatedRoute, + private seoService: SeoService, + private openGraphService: OpenGraphService, + ) { + if (isMobile()) { + this.publicKeySize = 12; + } + } + + ngOnInit(): void { + this.node$ = this.activatedRoute.paramMap + .pipe( + switchMap((params: ParamMap) => { + this.openGraphService.waitFor('node-map'); + this.openGraphService.waitFor('node-data'); + this.publicKey = params.get('public_key'); + return this.lightningApiService.getNode$(params.get('public_key')); + }), + map((node) => { + this.seoService.setTitle(`Node: ${node.alias}`); + + const socketsObject = []; + for (const socket of node.sockets.split(',')) { + if (socket === '') { + continue; + } + let label = ''; + if (socket.match(/(?:[0-9]{1,3}\.){3}[0-9]{1,3}/)) { + label = 'IPv4'; + } else if (socket.indexOf('[') > -1) { + label = 'IPv6'; + } else if (socket.indexOf('onion') > -1) { + label = 'Tor'; + } + node.flag = getFlagEmoji(node.iso_code); + socketsObject.push({ + label: label, + socket: node.public_key + '@' + socket, + }); + } + node.socketsObject = socketsObject; + node.avgCapacity = node.capacity / Math.max(1, node.active_channel_count); + + this.openGraphService.waitOver('node-data'); + + return node; + }), + catchError(err => { + this.error = err; + this.openGraphService.waitOver('node-map'); + this.openGraphService.waitOver('node-data'); + return [{ + alias: this.publicKey, + public_key: this.publicKey, + }]; + }) + ); + } + + changeSocket(index: number) { + this.selectedSocketIndex = index; + } + + onChannelsListStatusChanged(e) { + this.channelsListStatus = e; + } + + onMapReady() { + this.openGraphService.waitOver('node-map'); + } +} diff --git a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.html b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.html index 5ccb9f3bc..87082257e 100644 --- a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.html +++ b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.html @@ -1,4 +1,4 @@ -
+
@@ -8,7 +8,7 @@
+ (chartInit)="onChartInit($event)" (chartFinished)="onChartFinished($event)">
diff --git a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.scss b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.scss index 578bffc3a..ca887ad13 100644 --- a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.scss +++ b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.scss @@ -29,6 +29,18 @@ min-height: 250px; } +.full-container.fit-container { + margin: 0; + padding: 0; + height: 100%; + min-height: 100px; + + .chart { + padding: 0; + min-height: 100px; + } +} + .widget { width: 90vw; margin-left: auto; diff --git a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.ts b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.ts index d8952d632..033f944b1 100644 --- a/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.ts +++ b/frontend/src/app/lightning/nodes-channels-map/nodes-channels-map.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, HostListener, Input, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, HostListener, Input, Output, EventEmitter, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { SeoService } from 'src/app/services/seo.service'; import { ApiService } from 'src/app/services/api.service'; import { Observable, switchMap, tap, zip } from 'rxjs'; @@ -20,9 +20,11 @@ export class NodesChannelsMap implements OnInit, OnDestroy { @Input() style: 'graph' | 'nodepage' | 'widget' | 'channelpage' = 'graph'; @Input() publicKey: string | undefined; @Input() channel: any[] = []; + @Input() fitContainer = false; + @Output() readyEvent = new EventEmitter(); observable$: Observable; - + center: number[] | undefined; zoom: number | undefined; channelWidth = 0.6; @@ -313,4 +315,8 @@ export class NodesChannelsMap implements OnInit, OnDestroy { this.chartInstance.setOption(chartOptions); }); } + + onChartFinished(e) { + this.readyEvent.emit(); + } } diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 0d0b05556..466837f98 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -71,11 +71,13 @@ const defaultEnv: Env = { export class StateService { isBrowser: boolean = isPlatformBrowser(this.platformId); network = ''; + lightning = false; blockVSize: number; env: Env; latestBlockHeight = -1; networkChanged$ = new ReplaySubject(1); + lightningChanged$ = new ReplaySubject(1); blocks$: ReplaySubject<[BlockExtended, boolean]>; transactions$ = new ReplaySubject(6); conversions$ = new ReplaySubject(1); @@ -122,15 +124,18 @@ export class StateService { if (this.isBrowser) { this.setNetworkBasedonUrl(window.location.pathname); + this.setLightningBasedonUrl(window.location.pathname); this.isTabHidden$ = fromEvent(document, 'visibilitychange').pipe(map(() => this.isHidden()), shareReplay()); } else { this.setNetworkBasedonUrl('/'); + this.setLightningBasedonUrl('/'); this.isTabHidden$ = new BehaviorSubject(false); } this.router.events.subscribe((event) => { if (event instanceof NavigationStart) { this.setNetworkBasedonUrl(event.url); + this.setLightningBasedonUrl(event.url); } }); @@ -198,6 +203,15 @@ export class StateService { } } + setLightningBasedonUrl(url: string) { + if (this.env.BASE_MODULE !== 'mempool') { + return; + } + const networkMatches = url.match(/\/lightning\//); + this.lightning = !!networkMatches; + this.lightningChanged$.next(this.lightning); + } + getHiddenProp(){ const prefixes = ['webkit', 'moz', 'ms', 'o']; if ('hidden' in document) { return 'hidden'; } diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index ce131bb02..48e9eb46e 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -4,7 +4,7 @@ import { NgbCollapse, NgbCollapseModule, NgbRadioGroup, NgbTypeaheadModule } fro import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle, faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown, - faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode } from '@fortawesome/free-solid-svg-icons'; + faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft } from '@fortawesome/free-solid-svg-icons'; import { InfiniteScrollModule } from 'ngx-infinite-scroll'; import { MasterPageComponent } from '../components/master-page/master-page.component'; import { MasterPagePreviewComponent } from '../components/master-page-preview/master-page-preview.component'; @@ -297,5 +297,6 @@ export class SharedModule { library.addIcons(faListUl); library.addIcons(faDownload); library.addIcons(faQrcode); + library.addIcons(faArrowRightArrowLeft); } } diff --git a/unfurler/src/index.ts b/unfurler/src/index.ts index ca85ae5cc..440b68290 100644 --- a/unfurler/src/index.ts +++ b/unfurler/src/index.ts @@ -150,16 +150,31 @@ class Server { } // handle supported preview routes - if (parts[0] === 'block') { - ogTitle = `Block: ${parts[1]}`; - } else if (parts[0] === 'address') { - ogTitle = `Address: ${parts[1]}`; - } else { - previewSupported = false; + switch (parts[0]) { + case 'block': + ogTitle = `Block: ${parts[1]}`; + break; + case 'address': + ogTitle = `Address: ${parts[1]}`; + break; + case 'lightning': + switch (parts[1]) { + case 'node': + ogTitle = `Lightning Node: ${parts[2]}`; + break; + case 'channel': + ogTitle = `Lightning Channel: ${parts[2]}`; + break; + default: + previewSupported = false; + } + break; + default: + previewSupported = false; } if (previewSupported) { - ogImageUrl = `${config.SERVER.HOST}/render/${lang || 'en'}/preview${path}`; + ogImageUrl = `${config.SERVER.HOST}${config.SERVER.HTTP_PORT ? ':' + config.SERVER.HTTP_PORT : ''}/render/${lang || 'en'}/preview${path}`; ogTitle = `${this.network ? capitalize(this.network) + ' ' : ''}${mode !== 'mainnet' ? capitalize(mode) + ' ' : ''}${ogTitle}`; } else { ogTitle = 'The Mempool Open Source Projectâ„¢';