@@ -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.