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/channel/channel-preview.component.html b/frontend/src/app/lightning/channel/channel-preview.component.html new file mode 100644 index 000000000..c98929931 --- /dev/null +++ b/frontend/src/app/lightning/channel/channel-preview.component.html @@ -0,0 +1,72 @@ +
+
+

+ Channel: + {{ channel.short_id }} +

+
+ Inactive + Active + Closed + + +
+
+
+ + {{ channel.node_left.alias || '?' }} + + + + {{ channel.node_right.alias || '?' }} + +
+
+
+ + + + + + + + + + + + + + + + + + + + +
Created{{ channel.created | date:'yyyy-MM-dd HH:mm' }}
Capacity
Fee rate +
+ {{ channel.node_left.fee_rate }} ppm + + {{ channel.node_right.fee_rate }} ppm +
+
Base fee +
+ + + +
+
+
+
+ +
+
+
+ + +
+ Error loading data. +

+ {{ error.status }}: {{ error.error }} +
+
diff --git a/frontend/src/app/lightning/channel/channel-preview.component.scss b/frontend/src/app/lightning/channel/channel-preview.component.scss new file mode 100644 index 000000000..e89733ff3 --- /dev/null +++ b/frontend/src/app/lightning/channel/channel-preview.component.scss @@ -0,0 +1,76 @@ +.title { + font-size: 52px; + margin: 0; +} + +.table { + font-size: 32px; + margin-top: 36px; +} + +.badges { + font-size: 28px; + + ::ng-deep .badge { + margin-left: 0.5em; + } +} + +.row { + margin-right: 0; +} + +.full-width-row { + padding-left: 15px; + padding-right: 15px; + + &:nth-child(even) { + background: #181b2d; + margin: 15px 0; + } +} + +.nodes { + font-size: 36px; + align-items: center; +} + +.between-arrow { + font-size: 24px; +} + +.map-col { + flex-grow: 0; + flex-shrink: 0; + width: 470px; + min-width: 470px; + padding: 0; + background: #181b2d; + max-height: 470px; + overflow: hidden; +} + +::ng-deep .symbol { + font-size: 24px; +} + +.dual-cell { + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: baseline; + + & > * { + width: 0; + flex-grow: 1; + + &:nth-child(2) { + text-align: center; + max-width: 1.5em; + } + &:nth-child(3) { + text-align: right; + } + } +} diff --git a/frontend/src/app/lightning/channel/channel-preview.component.ts b/frontend/src/app/lightning/channel/channel-preview.component.ts new file mode 100644 index 000000000..c82adba66 --- /dev/null +++ b/frontend/src/app/lightning/channel/channel-preview.component.ts @@ -0,0 +1,67 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { Observable, of } from 'rxjs'; +import { catchError, switchMap, tap } from 'rxjs/operators'; +import { SeoService } from 'src/app/services/seo.service'; +import { OpenGraphService } from 'src/app/services/opengraph.service'; +import { LightningApiService } from '../lightning-api.service'; + +@Component({ + selector: 'app-channel-preview', + templateUrl: './channel-preview.component.html', + styleUrls: ['./channel-preview.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChannelPreviewComponent implements OnInit { + channel$: Observable; + error: any = null; + channelGeo: number[] = []; + + constructor( + private lightningApiService: LightningApiService, + private activatedRoute: ActivatedRoute, + private seoService: SeoService, + private openGraphService: OpenGraphService, + ) { } + + ngOnInit(): void { + this.channel$ = this.activatedRoute.paramMap + .pipe( + switchMap((params: ParamMap) => { + this.openGraphService.waitFor('channel-map'); + this.openGraphService.waitFor('channel-data'); + this.error = null; + this.seoService.setTitle(`Channel: ${params.get('short_id')}`); + return this.lightningApiService.getChannel$(params.get('short_id')) + .pipe( + tap((data) => { + if (!data.node_left.longitude || !data.node_left.latitude || + !data.node_right.longitude || !data.node_right.latitude) { + this.channelGeo = []; + } else { + this.channelGeo = [ + data.node_left.public_key, + data.node_left.alias, + data.node_left.longitude, data.node_left.latitude, + data.node_right.public_key, + data.node_right.alias, + data.node_right.longitude, data.node_right.latitude, + ]; + } + this.openGraphService.waitOver('channel-data'); + }), + catchError((err) => { + this.error = err; + this.openGraphService.fail('channel-map'); + this.openGraphService.fail('channel-data'); + return of(null); + }) + ); + }) + ); + } + + onMapReady() { + this.openGraphService.waitOver('channel-map'); + } +} 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..4d5d6cee9 --- /dev/null +++ b/frontend/src/app/lightning/lightning-previews.module.ts @@ -0,0 +1,28 @@ +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'; +import { ChannelPreviewComponent } from './channel/channel-preview.component'; +@NgModule({ + declarations: [ + NodePreviewComponent, + ChannelPreviewComponent, + ], + 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..69de2aadf --- /dev/null +++ b/frontend/src/app/lightning/lightning-previews.routing.module.ts @@ -0,0 +1,25 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { NodePreviewComponent } from './node/node-preview.component'; +import { ChannelPreviewComponent } from './channel/channel-preview.component'; + +const routes: Routes = [ + { + path: 'node/:public_key', + component: NodePreviewComponent, + }, + { + path: 'channel/:short_id', + component: ChannelPreviewComponent, + }, + { + 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..0bb7255a6 --- /dev/null +++ b/frontend/src/app/lightning/node/node-preview.component.html @@ -0,0 +1,64 @@ +
+
+

+ Node: + {{ node.alias }} +

+
+ {{ socketType }} +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Active capacity + +
Active channels + {{ node.active_channel_count }} +
Average size + +
Location + {{ node.city.en }} +
Country + {{ node.country.en }} {{ node.flag }} +
Location + unknown +
+
+
+ +
+
+
+ + +
+ 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..c6b2ea9d7 --- /dev/null +++ b/frontend/src/app/lightning/node/node-preview.component.scss @@ -0,0 +1,43 @@ +.title { + font-size: 52px; + margin-bottom: 0; +} + +.table { + margin-top: 48px; + font-size: 32px; +} + +.badges { + font-size: 28px; + + ::ng-deep .badge { + margin-left: 0.5em; + } +} + +.map-col { + flex-grow: 0; + flex-shrink: 0; + width: 470px; + height: 390px; + min-width: 470px; + min-height: 390px; + max-height: 390px; + padding: 0; + background: #181b2d; + overflow: hidden; + margin-top: 18px; +} + +.row { + margin-right: 0; +} + +.full-width-row { + padding-left: 15px; +} + +::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..6344a38b2 --- /dev/null +++ b/frontend/src/app/lightning/node/node-preview.component.ts @@ -0,0 +1,105 @@ +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; + socketTypes: 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 = []; + const socketTypesMap = {}; + 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, + }); + socketTypesMap[label] = true + } + node.socketsObject = socketsObject; + this.socketTypes = Object.keys(socketTypesMap); + 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.fail('node-map'); + this.openGraphService.fail('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..cd6b7762f 100644 --- a/unfurler/src/index.ts +++ b/unfurler/src/index.ts @@ -150,12 +150,27 @@ 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) {