diff --git a/frontend/src/app/components/app/app.component.ts b/frontend/src/app/components/app/app.component.ts index 633e466a4..d9d6f77d6 100644 --- a/frontend/src/app/components/app/app.component.ts +++ b/frontend/src/app/components/app/app.component.ts @@ -25,6 +25,8 @@ export class AppComponent implements OnInit { if (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')) { this.dir = 'rtl'; this.class = 'rtl-layout'; + } else { + this.class = 'ltr-layout'; } tooltipConfig.animation = false; diff --git a/frontend/src/app/components/blockchain/blockchain.component.scss b/frontend/src/app/components/blockchain/blockchain.component.scss index 2f13374fe..df609ff40 100644 --- a/frontend/src/app/components/blockchain/blockchain.component.scss +++ b/frontend/src/app/components/blockchain/blockchain.component.scss @@ -27,7 +27,6 @@ left: 0; top: 75px; transform: translateX(50vw); - transition: transform 1s; } .position-container.liquid, .position-container.liquidtestnet { @@ -84,9 +83,9 @@ .time-toggle { color: white; - font-size: 1rem; + font-size: 0.8rem; position: absolute; - bottom: -1.5em; + bottom: -1.8em; left: 1px; transform: translateX(-50%); background: none; @@ -97,14 +96,31 @@ } .blockchain-wrapper.ltr-transition .blocks-wrapper, +.blockchain-wrapper.ltr-transition .position-container, .blockchain-wrapper.ltr-transition .time-toggle { transition: transform 1s; } -.blockchain-wrapper.time-ltr .blocks-wrapper { - transform: scaleX(-1); +.blockchain-wrapper.time-ltr { + .blocks-wrapper { + transform: scaleX(-1); + } + + .time-toggle { + transform: translateX(-50%) scaleX(-1); + } } -.blockchain-wrapper.time-ltr .time-toggle { - transform: translateX(-50%) scaleX(-1); +:host-context(.ltr-layout) { + .blockchain-wrapper.time-ltr .blocks-wrapper, + .blockchain-wrapper .blocks-wrapper { + direction: ltr; + } +} + +:host-context(.rtl-layout) { + .blockchain-wrapper.time-ltr .blocks-wrapper, + .blockchain-wrapper .blocks-wrapper { + direction: rtl; + } } \ No newline at end of file diff --git a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.scss b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.scss index 8032be92f..565d4b302 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.scss +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.scss @@ -146,4 +146,10 @@ .block-body { transform: scaleX(-1); } +} + +:host-context(.rtl-layout) { + #arrow-up { + transform: translateX(70px); + } } \ No newline at end of file diff --git a/frontend/src/app/components/start/start.component.html b/frontend/src/app/components/start/start.component.html index 9d7f39ba2..89b6efdc3 100644 --- a/frontend/src/app/components/start/start.component.html +++ b/frontend/src/app/components/start/start.component.html @@ -8,7 +8,7 @@
{{ eventName }} in {{ countdown | number }} block{{ countdown === 1 ? '' : 's' }}!
-
diff --git a/frontend/src/app/components/start/start.component.ts b/frontend/src/app/components/start/start.component.ts index 78e004985..37c94baa3 100644 --- a/frontend/src/app/components/start/start.component.ts +++ b/frontend/src/app/components/start/start.component.ts @@ -1,4 +1,5 @@ -import { Component, ElementRef, HostListener, OnInit, ViewChild } from '@angular/core'; +import { Component, ElementRef, HostListener, OnInit, OnDestroy, ViewChild } from '@angular/core'; +import { Subscription } from 'rxjs'; import { StateService } from '../../services/state.service'; import { specialBlocks } from '../../app.constants'; @@ -7,7 +8,7 @@ import { specialBlocks } from '../../app.constants'; templateUrl: './start.component.html', styleUrls: ['./start.component.scss'], }) -export class StartComponent implements OnInit { +export class StartComponent implements OnInit, OnDestroy { interval = 60; colors = ['#5E35B1', '#ffffff']; @@ -16,6 +17,8 @@ export class StartComponent implements OnInit { eventName = ''; mouseDragStartX: number; blockchainScrollLeftInit: number; + timeLtrSubscription: Subscription; + timeLtr: boolean = this.stateService.timeLtr.value; @ViewChild('blockchainContainer') blockchainContainer: ElementRef; constructor( @@ -23,6 +26,9 @@ export class StartComponent implements OnInit { ) { } ngOnInit() { + this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { + this.timeLtr = !!ltr; + }); this.stateService.blocks$ .subscribe((blocks: any) => { if (this.stateService.network !== '') { @@ -72,4 +78,8 @@ export class StartComponent implements OnInit { this.mouseDragStartX = null; this.stateService.setBlockScrollingInProgress(false); } + + ngOnDestroy() { + this.timeLtrSubscription.unsubscribe(); + } } diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index 5b35fc7b1..360d3e34f 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -195,7 +195,7 @@

Flow

- +
@@ -208,7 +208,11 @@ [lineLimit]="inOutLimit" [maxStrands]="graphExpanded ? maxInOut : 24" [network]="network" - [tooltip]="true"> + [tooltip]="true" + [inputIndex]="inputIndex" [outputIndex]="outputIndex" + (selectInput)="selectInput($event)" + (selectOutput)="selectOutput($event)" + >
@@ -234,13 +238,13 @@
- +
- +

Details

diff --git a/frontend/src/app/components/transaction/transaction.component.scss b/frontend/src/app/components/transaction/transaction.component.scss index e0e7d79fe..df8d37ebc 100644 --- a/frontend/src/app/components/transaction/transaction.component.scss +++ b/frontend/src/app/components/transaction/transaction.component.scss @@ -3,38 +3,38 @@ } .container-buttons { - align-self: center; + align-self: flex-start; } .title-block { - flex-wrap: wrap; + flex-wrap: wrap; + align-items: baseline; @media (min-width: 650px) { flex-direction: row; } h1 { margin: 0rem; + margin-right: 15px; line-height: 1; } } .tx-link { - display: flex; - flex-grow: 1; margin-bottom: 0px; margin-top: 8px; - @media (min-width: 650px) { - align-self: end; - margin-left: 15px; - margin-top: 0px; - margin-bottom: -3px; - } - @media (min-width: 768px) { + display: inline-block; + width: 100%; + flex-shrink: 0; + @media (min-width: 651px) { + display: flex; + width: auto; + flex-grow: 1; margin-bottom: 0px; top: 1px; position: relative; - } - @media (max-width: 768px) { - order: 3; - } + } + @media (max-width: 650px) { + order: 3; + } } .td-width { diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 1db6e8f09..c64c112b1 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -47,6 +47,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { now = new Date().getTime(); timeAvg$: Observable; liquidUnblinding = new LiquidUnblinding(); + inputIndex: number; outputIndex: number; showFlow: boolean = true; graphExpanded: boolean = false; @@ -121,8 +122,15 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { .pipe( switchMap((params: ParamMap) => { const urlMatch = (params.get('id') || '').split(':'); - this.txId = urlMatch[0]; - this.outputIndex = urlMatch[1] === undefined ? null : parseInt(urlMatch[1], 10); + if (urlMatch.length === 2 && urlMatch[1].length === 64) { + this.inputIndex = parseInt(urlMatch[0], 10); + this.outputIndex = null; + this.txId = urlMatch[1]; + } else { + this.txId = urlMatch[0]; + this.outputIndex = urlMatch[1] === undefined ? null : parseInt(urlMatch[1], 10); + this.inputIndex = null; + } this.seoService.setTitle( $localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:` ); @@ -334,6 +342,16 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.graphExpanded = false; } + selectInput(input) { + this.inputIndex = input; + this.outputIndex = null; + } + + selectOutput(output) { + this.outputIndex = output; + this.inputIndex = null; + } + @HostListener('window:resize', ['$event']) setGraphSize(): void { if (this.graphContainer) { diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.html b/frontend/src/app/components/transactions-list/transactions-list.component.html index 81c3dce5c..e53c54a7a 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -20,9 +20,9 @@
- + - + @@ -158,7 +158,7 @@
@@ -146,7 +146,7 @@
- + - + @@ -257,7 +257,7 @@ - + diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index 8fd81af51..4f3f1cec3 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -24,6 +24,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { @Input() transactionPage = false; @Input() errorUnblinded = false; @Input() paginated = false; + @Input() inputIndex: number; @Input() outputIndex: number; @Input() address: string = ''; @Input() rowLimit = 12; @@ -37,6 +38,8 @@ export class TransactionsListComponent implements OnInit, OnChanges { showDetails$ = new BehaviorSubject(false); assetsMinimal: any; transactionsLength: number = 0; + inputRowLimit: number = 12; + outputRowLimit: number = 12; constructor( public stateService: StateService, @@ -97,50 +100,57 @@ export class TransactionsListComponent implements OnInit, OnChanges { ).subscribe(() => this.ref.markForCheck()); } - ngOnChanges(): void { - if (!this.transactions || !this.transactions.length) { - return; + ngOnChanges(changes): void { + if (changes.inputIndex || changes.outputIndex || changes.rowLimit) { + this.inputRowLimit = Math.max(this.rowLimit, (this.inputIndex || 0) + 3); + this.outputRowLimit = Math.max(this.rowLimit, (this.outputIndex || 0) + 3); + if ((this.inputIndex || this.outputIndex) && !changes.transactions) { + setTimeout(() => { + const assetBoxElements = document.getElementsByClassName('assetBox'); + if (assetBoxElements && assetBoxElements[0]) { + assetBoxElements[0].scrollIntoView({block: "center"}); + } + }, 10); + } } - - this.transactionsLength = this.transactions.length; - if (this.outputIndex) { - setTimeout(() => { - const assetBoxElements = document.getElementsByClassName('assetBox'); - if (assetBoxElements && assetBoxElements[0]) { - assetBoxElements[0].scrollIntoView(); - } - }, 10); - } - - this.transactions.forEach((tx) => { - tx['@voutLimit'] = true; - tx['@vinLimit'] = true; - if (tx['addressValue'] !== undefined) { + if (changes.transactions || changes.address) { + if (!this.transactions || !this.transactions.length) { return; } - if (this.address) { - const addressIn = tx.vout - .filter((v: Vout) => v.scriptpubkey_address === this.address) - .map((v: Vout) => v.value || 0) - .reduce((a: number, b: number) => a + b, 0); + this.transactionsLength = this.transactions.length; - const addressOut = tx.vin - .filter((v: Vin) => v.prevout && v.prevout.scriptpubkey_address === this.address) - .map((v: Vin) => v.prevout.value || 0) - .reduce((a: number, b: number) => a + b, 0); - tx['addressValue'] = addressIn - addressOut; - } - }); - const txIds = this.transactions.filter((tx) => !tx._outspends).map((tx) => tx.txid); - if (txIds.length) { - this.refreshOutspends$.next(txIds); - } - if (this.stateService.env.LIGHTNING) { - const txIds = this.transactions.filter((tx) => !tx._channels).map((tx) => tx.txid); + this.transactions.forEach((tx) => { + tx['@voutLimit'] = true; + tx['@vinLimit'] = true; + if (tx['addressValue'] !== undefined) { + return; + } + + if (this.address) { + const addressIn = tx.vout + .filter((v: Vout) => v.scriptpubkey_address === this.address) + .map((v: Vout) => v.value || 0) + .reduce((a: number, b: number) => a + b, 0); + + const addressOut = tx.vin + .filter((v: Vin) => v.prevout && v.prevout.scriptpubkey_address === this.address) + .map((v: Vin) => v.prevout.value || 0) + .reduce((a: number, b: number) => a + b, 0); + + tx['addressValue'] = addressIn - addressOut; + } + }); + const txIds = this.transactions.filter((tx) => !tx._outspends).map((tx) => tx.txid); if (txIds.length) { - this.refreshChannels$.next(txIds); + this.refreshOutspends$.next(txIds); + } + if (this.stateService.env.LIGHTNING) { + const txIds = this.transactions.filter((tx) => !tx._channels).map((tx) => tx.txid); + if (txIds.length) { + this.refreshChannels$.next(txIds); + } } } } diff --git a/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.html b/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.html index 563e6ed00..6872438a0 100644 --- a/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.html +++ b/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.html @@ -44,7 +44,7 @@ Output Fee - #{{ line.index }} + #{{ line.index + 1 }}

Confidential

diff --git a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.html b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.html index 03056cd53..ced3b5f57 100644 --- a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.html +++ b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.html @@ -41,6 +41,18 @@ + + + + + + + + + + + + @@ -56,20 +68,24 @@ diff --git a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.scss b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.scss index 9cacb7d4b..5a71ee421 100644 --- a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.scss +++ b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.scss @@ -12,6 +12,17 @@ stroke: url(#fee-gradient); } + &.highlight { + z-index: 8; + cursor: pointer; + &.input { + stroke: url(#input-highlight-gradient); + } + &.output { + stroke: url(#output-highlight-gradient); + } + } + &:hover { z-index: 10; cursor: pointer; diff --git a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts index 16e2736f7..9d29500f0 100644 --- a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts +++ b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts @@ -1,5 +1,11 @@ -import { Component, OnInit, Input, OnChanges, HostListener } from '@angular/core'; -import { Transaction } from '../../interfaces/electrs.interface'; +import { Component, OnInit, Input, Output, EventEmitter, OnChanges, HostListener } from '@angular/core'; +import { StateService } from '../../services/state.service'; +import { Outspend, Transaction } from '../../interfaces/electrs.interface'; +import { Router } from '@angular/router'; +import { ReplaySubject, merge, Subscription } from 'rxjs'; +import { tap, switchMap } from 'rxjs/operators'; +import { ApiService } from '../../services/api.service'; +import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; interface SvgLine { path: string; @@ -34,6 +40,11 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { @Input() minWeight = 2; // @Input() maxStrands = 24; // number of inputs/outputs to keep fully on-screen. @Input() tooltip = false; + @Input() inputIndex: number; + @Input() outputIndex: number; + + @Output() selectInput = new EventEmitter(); + @Output() selectOutput = new EventEmitter(); inputData: Xput[]; outputData: Xput[]; @@ -45,6 +56,10 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { isLiquid: boolean = false; hoverLine: Xput | void = null; tooltipPosition = { x: 0, y: 0 }; + outspends: Outspend[] = []; + + outspendsSubscription: Subscription; + refreshOutspends$: ReplaySubject = new ReplaySubject(); gradientColors = { '': ['#9339f4', '#105fb0'], @@ -61,12 +76,45 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { gradient: string[] = ['#105fb0', '#105fb0']; + constructor( + private router: Router, + private relativeUrlPipe: RelativeUrlPipe, + private stateService: StateService, + private apiService: ApiService, + ) { } + ngOnInit(): void { this.initGraph(); + + this.outspendsSubscription = merge( + this.refreshOutspends$ + .pipe( + switchMap((txid) => this.apiService.getOutspendsBatched$([txid])), + tap((outspends: Outspend[][]) => { + if (!this.tx || !outspends || !outspends.length) { + return; + } + this.outspends = outspends[0]; + }), + ), + this.stateService.utxoSpent$ + .pipe( + tap((utxoSpent) => { + for (const i in utxoSpent) { + this.outspends[i] = { + spent: true, + txid: utxoSpent[i].txid, + vin: utxoSpent[i].vin, + }; + } + }), + ), + ).subscribe(() => {}); } ngOnChanges(): void { this.initGraph(); + this.refreshOutspends$.next(this.tx.txid); } initGraph(): void { @@ -76,11 +124,12 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { this.combinedWeight = Math.min(this.maxCombinedWeight, Math.floor((this.width - (2 * this.midWidth)) / 6)); const totalValue = this.calcTotalValue(this.tx); - let voutWithFee = this.tx.vout.map(v => { + let voutWithFee = this.tx.vout.map((v, i) => { return { type: v.scriptpubkey_type === 'fee' ? 'fee' : 'output', value: v?.value, address: v?.scriptpubkey_address || v?.scriptpubkey_type?.toUpperCase(), + index: i, pegout: v?.pegout?.scriptpubkey_address, confidential: (this.isLiquid && v?.value === undefined), } as Xput; @@ -91,11 +140,12 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { } const outputCount = voutWithFee.length; - let truncatedInputs = this.tx.vin.map(v => { + let truncatedInputs = this.tx.vin.map((v, i) => { return { type: 'input', value: v?.prevout?.value, address: v?.prevout?.scriptpubkey_address || v?.prevout?.scriptpubkey_type?.toUpperCase(), + index: i, coinbase: v?.is_coinbase, pegin: v?.is_pegin, confidential: (this.isLiquid && v?.prevout?.value === undefined), @@ -306,8 +356,7 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { }; } else { this.hoverLine = { - ...this.outputData[index], - index + ...this.outputData[index] }; } } @@ -315,4 +364,29 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { onBlur(event, side, index): void { this.hoverLine = null; } + + onClick(event, side, index): void { + if (side === 'input') { + const input = this.tx.vin[index]; + if (input && input.txid && input.vout != null) { + this.router.navigate([this.relativeUrlPipe.transform('/tx'), input.txid + ':' + input.vout], { + queryParamsHandling: 'merge', + fragment: 'flow' + }); + } else { + this.selectInput.emit(index); + } + } else { + const output = this.tx.vout[index]; + const outspend = this.outspends[index]; + if (output && outspend && outspend.spent && outspend.txid) { + this.router.navigate([this.relativeUrlPipe.transform('/tx'), outspend.vin + ':' + outspend.txid], { + queryParamsHandling: 'merge', + fragment: 'flow' + }); + } else { + this.selectOutput.emit(index); + } + } + } } diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 920f32dd9..9f7cc58a3 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core'; import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs'; import { Transaction } from '../interfaces/electrs.interface'; import { IBackendInfo, MempoolBlock, MempoolBlockWithTransactions, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface'; @@ -113,6 +113,7 @@ export class StateService { constructor( @Inject(PLATFORM_ID) private platformId: any, + @Inject(LOCALE_ID) private locale: string, private router: Router, private storageService: StorageService, ) { @@ -151,7 +152,10 @@ export class StateService { this.blockVSize = this.env.BLOCK_WEIGHT_UNITS / 4; - this.timeLtr = new BehaviorSubject(this.storageService.getValue('time-preference-ltr') === 'true'); + const savedTimePreference = this.storageService.getValue('time-preference-ltr'); + const rtlLanguage = (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')); + // default time direction is right-to-left, unless locale is a RTL language + this.timeLtr = new BehaviorSubject(savedTimePreference === 'true' || (savedTimePreference == null && rtlLanguage)); this.timeLtr.subscribe((ltr) => { this.storageService.setValue('time-preference-ltr', ltr ? 'true' : 'false'); });