diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html index ebf79cb66..a707cd2e9 100644 --- a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.html @@ -1,75 +1,77 @@
-
-
-
- -
- -
-
- +
+
+
+
+ +
+ +
+
+ +
-
- - -
-
- - - - - - -
-
-
-
- - -
-
- Compare these two transactions -
-
+
+
+ + + + + + +
+
+
+
+
+ + +
+
+ Compare these two transactions +
+
+
+
@@ -97,13 +99,15 @@
- -
- Failed to load transaction diff. + +
+ This comparison could not be loaded. Select it again to retry.
-
+ +
@@ -177,8 +181,9 @@ Fee + + [class.positive]="diff.feeIncreased" [class.negative]="!diff.feeIncreased"> {{ diff.feePercent > 0 ? '+' : '' }}{{ diff.feePercent | number: '1.0-1' }}% @@ -200,7 +205,7 @@ + [class.positive]="diff.weightIncreased" [class.negative]="!diff.weightIncreased"> {{ diff.weightPercent > 0 ? '+' : '' }}{{ diff.weightPercent | number: '1.0-1' }}% @@ -246,29 +251,36 @@ bump is not flagged as a change at all. --> + Output address - Output (address changed) - Output (fee adjusted) - Output - - - - - - + + + + + + + + {{ out.scriptpubkey_type | scriptpubkeyType }} + + ⚠️ - - {{ out.scriptpubkey_type | scriptpubkeyType }} - - - - + + + + + +   + diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss index 0da93fd09..e0ed281bc 100644 --- a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.scss @@ -3,19 +3,15 @@ width: 100%; padding: 1em 0; - .timeline-wrapper { + // Non-scrolling frame around the scroller. The edge fades live here because an + // absolutely positioned child of an overflow container scrolls away with its + // contents, which left the fade sitting mid-timeline covering the track. + // It wraps only the timeline, so the fades never reach the diff tables below. + .timeline-viewport { position: relative; width: calc(100% - 2em); margin: auto; - overflow-x: auto; - -ms-overflow-style: none; - scrollbar-width: none; - &::-webkit-scrollbar { - display: none; - } - - // scoped to the timeline so the gradients don't overlay the diff tables below &::after, &::before { content: ''; display: block; @@ -37,7 +33,7 @@ background: linear-gradient(to left, var(--box-bg), var(--box-bg), transparent); } - // On a phone the wrapper is barely wider than a couple of nodes, so a 2em + // On a phone the viewport is barely wider than a couple of nodes, so a 2em // band of solid background reads as the line being cut rather than fading. @media (max-width: 575.98px) { &::before, &::after { @@ -54,6 +50,18 @@ } } + // owns horizontal scrolling and nothing else + .timeline-wrapper { + width: 100%; + overflow-x: auto; + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + .fade-out { position: relative; @@ -118,6 +126,12 @@ &:hover .track { background: var(--info); + + // a full RBF track is already --info, so the usual highlight would be + // invisible on exactly the lines that need to look clickable + &.fullrbf { + background: var(--fg); + } } } @@ -295,6 +309,15 @@ width: 100%; padding: 1rem 0; } + + .diff-notice { + width: calc(100% - 2em); + margin: 0.5em auto 0; + padding: 0 0.5em; + text-align: center; + font-size: 0.85rem; + color: var(--transparent-fg); + } } .rbf-diff-section { @@ -402,20 +425,43 @@ color: var(--red); } - .output-address { - display: block; - max-width: 100%; - overflow: hidden; + // the warning sits next to the address, so the address has to be the part that + // shrinks when the column is narrow + .output-address-row { + display: flex; + align-items: baseline; + gap: 0.35rem; + // set here rather than on .output-address so the placeholder for an added or + // removed output measures exactly the same as a real address font-size: 0.9rem; } + .output-address { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + } + + .output-warning { + flex: 0 0 auto; + font-size: 0.8rem; + cursor: help; + } + .output-script-type { font-size: 0.9rem; color: var(--transparent-fg); } + // explicit line heights so a populated cell and a placeholder cell measure the + // same, which is what keeps the two tables aligned row by row + .output-address-row { + line-height: 1.6; + } + .output-value { display: block; + line-height: 1.6; font-size: 0.85rem; color: var(--transparent-fg); } diff --git a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts index 0ecfff863..ccea39017 100644 --- a/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts +++ b/frontend/src/app/components/rbf-timeline/rbf-timeline.component.ts @@ -3,8 +3,8 @@ import { Router } from '@angular/router'; import { RbfTree, RbfTransaction } from '@interfaces/node-api.interface'; import { StateService } from '@app/services/state.service'; import { ApiService } from '@app/services/api.service'; -import { forkJoin, of, Subject } from 'rxjs'; -import { catchError, switchMap, takeUntil } from 'rxjs/operators'; +import { forkJoin, of, Observable, Subject } from 'rxjs'; +import { catchError, map, retry, switchMap, takeUntil } from 'rxjs/operators'; import { Transaction, Vout } from '@interfaces/electrs.interface'; import { calculateRbfDiff } from '@app/shared/rbf-diff.utils'; @@ -89,6 +89,9 @@ export class RbfTimelineComponent implements OnInit, OnChanges, OnDestroy { // First half of a two-click selection, waiting for the user to pick the other end pendingAnchorTxid: string | null = null; + // the pair the tables currently show, which can lag behind the selection while + // a new comparison is loading or after one failed to load + private renderedPair: { oldTxid: string, newTxid: string } | null = null; private nodeIndex = new Map(); private destroy$ = new Subject(); // Comparisons go through one stream so a slower earlier request can never land @@ -108,8 +111,8 @@ export class RbfTimelineComponent implements OnInit, OnChanges, OnDestroy { // can already have queued a comparison this.diffRequest$.pipe( switchMap((request) => request ? forkJoin({ - oldTx: this.apiService.getRbfCachedTx$(request.oldTxid).pipe(catchError(() => of(null))), - newTx: this.apiService.getRbfCachedTx$(request.newTxid).pipe(catchError(() => of(null))), + oldTx: this.fetchCachedTx$(request.oldTxid), + newTx: this.fetchCachedTx$(request.newTxid), }) : of(null)), takeUntil(this.destroy$), ).subscribe((result) => { @@ -119,11 +122,18 @@ export class RbfTimelineComponent implements OnInit, OnChanges, OnDestroy { this.diffLoading = false; if (!result.oldTx || !result.newTx) { this.diffError = true; + // stay on the comparison that is still on screen, so the highlighted + // pair keeps matching the table below it + if (this.renderedPair) { + this.diffOldTxid = this.renderedPair.oldTxid; + this.diffNewTxid = this.renderedPair.newTxid; + } return; } this.selectedOldTx = result.oldTx; this.selectedNewTx = result.newTx; this.diffView = this.buildDiffView(result.oldTx, result.newTx); + this.renderedPair = { oldTxid: result.oldTx.txid, newTxid: result.newTx.txid }; }); } @@ -481,6 +491,7 @@ export class RbfTimelineComponent implements OnInit, OnChanges, OnDestroy { this.selectedOldTx = null; this.selectedNewTx = null; this.diffView = null; + this.renderedPair = null; } private loadDiff(): void { @@ -489,10 +500,31 @@ export class RbfTimelineComponent implements OnInit, OnChanges, OnDestroy { } this.diffError = false; this.diffLoading = true; - this.diffView = null; + // diffView is deliberately left alone: a slow or failed comparison shouldn't + // wipe the table the user is already reading this.diffRequest$.next({ oldTxid: this.diffOldTxid, newTxid: this.diffNewTxid }); } + /** + * The RBF cache lives in the memory of each backend instance, so the same txid + * can come back empty from one instance and populated from another. An empty + * body arrives as HTTP 204, which is a *success* with a null body rather than + * an error, so it has to be turned into one for the retry to see it. Retrying + * usually lands on an instance that has the transaction. + */ + private fetchCachedTx$(txid: string): Observable { + return this.apiService.getRbfCachedTx$(txid).pipe( + map((tx) => { + if (!tx) { + throw new Error(`no cached transaction for ${txid}`); + } + return tx; + }), + retry({ count: 2, delay: 400 }), + catchError(() => of(null)), + ); + } + /** * Reduces the structural diff to just what the tables render. Rows for * unchanged fields are left out entirely rather than rendered as noise.