From 4d77b4d4a41262547801c08ae0b9cca10520a7df Mon Sep 17 00:00:00 2001 From: Kirill Date: Tue, 9 Jun 2026 03:06:31 +0400 Subject: [PATCH 1/6] feat(vault): redesign artifact download modal with progress bar and activate handoff --- .../deposit/ArtifactDownloadModal/index.tsx | 165 ++++++++++++----- .../deposit/RecoveryArtifactsCard.tsx | 166 ++++++++++++++---- services/vault/src/copy.ts | 22 ++- .../__tests__/useArtifactDownload.test.tsx | 15 +- .../src/hooks/deposit/useArtifactDownload.ts | 79 +++++---- .../artifacts/artifactDownloadService.ts | 93 +++++++++- .../vault/src/services/artifacts/index.ts | 6 +- 7 files changed, 428 insertions(+), 118 deletions(-) diff --git a/services/vault/src/components/deposit/ArtifactDownloadModal/index.tsx b/services/vault/src/components/deposit/ArtifactDownloadModal/index.tsx index f6bbcfd16..2afae7be3 100644 --- a/services/vault/src/components/deposit/ArtifactDownloadModal/index.tsx +++ b/services/vault/src/components/deposit/ArtifactDownloadModal/index.tsx @@ -36,6 +36,14 @@ interface ArtifactDownloadModalProps extends ArtifactDownloadModalParams { * and they must restart the deposit flow to recover. */ unsignedPrePeginTxHex?: string; + /** + * Optional activation handler invoked by the Activate Vault button in + * the downloaded state. When omitted, that button falls back to + * `onComplete` — the label stays "Activate Vault" because the + * downstream flow (post-deposit continuation, etc.) leads to + * activation, even if this modal isn't the one that fires it. + */ + onActivate?: () => void; } export function ArtifactDownloadModal({ @@ -47,6 +55,7 @@ export function ArtifactDownloadModal({ depositorPk, vaultId, unsignedPrePeginTxHex, + onActivate, }: ArtifactDownloadModalProps) { // Seed from localStorage so a reopened modal for an already-downloaded // vault renders the Continue path immediately (the card itself flips to @@ -55,9 +64,16 @@ export function ArtifactDownloadModal({ const [downloaded, setDownloaded] = useState(() => hasArtifactsDownloaded(vaultId), ); + // Mirrors RecoveryArtifactsCard's internal `loading` flag via onLoadingChange + // so the whole modal (title, body, footer button) reads as a single + // "downloading" state once the user kicks off the request. + const [isDownloading, setIsDownloading] = useState(false); useEffect(() => { - if (open) setDownloaded(hasArtifactsDownloaded(vaultId)); + if (open) { + setDownloaded(hasArtifactsDownloaded(vaultId)); + setIsDownloading(false); + } }, [open, vaultId]); const cardRef = useRef(null); @@ -87,43 +103,85 @@ export function ArtifactDownloadModal({
- + {downloaded ? ( + + ) : ( + + )}

- {COPY.deposit.artifactDownload.title} + {downloaded + ? COPY.deposit.artifactDownload.titleDownloaded + : isDownloading + ? COPY.deposit.artifactDownload.titleDownloading + : COPY.deposit.artifactDownload.title}

- {COPY.deposit.artifactDownload.body} + {downloaded + ? COPY.deposit.artifactDownload.bodyDownloaded + : isDownloading + ? COPY.deposit.artifactDownload.bodyDownloading + : COPY.deposit.artifactDownload.body}

@@ -136,19 +194,40 @@ export function ArtifactDownloadModal({ vaultId={vaultId} unsignedPrePeginTxHex={unsignedPrePeginTxHex} onDownloaded={() => setDownloaded(true)} + onLoadingChange={setIsDownloading} />
- + {downloaded ? ( + <> + + + + ) : ( + + )} ); diff --git a/services/vault/src/components/deposit/RecoveryArtifactsCard.tsx b/services/vault/src/components/deposit/RecoveryArtifactsCard.tsx index 3eaa375c9..9b3abda9a 100644 --- a/services/vault/src/components/deposit/RecoveryArtifactsCard.tsx +++ b/services/vault/src/components/deposit/RecoveryArtifactsCard.tsx @@ -8,17 +8,66 @@ import { useMemo, useRef, } from "react"; -import { - IoCheckmarkCircle, - IoDocumentText, - IoDownloadOutline, -} from "react-icons/io5"; +import { IoDownloadOutline } from "react-icons/io5"; import type { Hex } from "viem"; +import { ProgressBar } from "@/components/simple/DepositProgressView/ProgressBar"; import { COPY } from "@/copy"; import { useArtifactDownload } from "@/hooks/deposit/useArtifactDownload"; import { hasArtifactsDownloaded } from "@/utils/artifactDownloadStorage"; +const BYTES_PER_MB = 1024 * 1024; +const BYTES_PER_GB = 1024 * 1024 * 1024; + +// Brand orange (Tailwind's `secondary-main` token), inlined because +// ProgressBar takes a raw CSS color rather than a class name. +const PROGRESS_BAR_FILL_COLOR = "#CE6533"; + +function RecoveryArtifactsIcon() { + return ( + + ); +} + +function formatBytes(bytes: number): string { + if (bytes >= BYTES_PER_GB) { + return `${(bytes / BYTES_PER_GB).toFixed(2)} GB`; + } + if (bytes >= BYTES_PER_MB) { + return `${Math.round(bytes / BYTES_PER_MB)} MB`; + } + return `${Math.round(bytes / 1024)} KB`; +} + interface RecoveryArtifactsCardProps { providerAddress: string; peginTxid: string; @@ -33,6 +82,12 @@ interface RecoveryArtifactsCardProps { unsignedPrePeginTxHex?: string; /** Fired the first time the artifact download completes within this card. */ onDownloaded?: () => void; + /** + * Fired whenever the in-card download flag flips. Lets a parent modal + * swap its own title/body/footer copy in lockstep with the card so the + * whole dialog reads as a single "downloading" state. + */ + onLoadingChange?: (loading: boolean) => void; } /** @@ -55,6 +110,7 @@ export const RecoveryArtifactsCard = forwardRef< vaultId, unsignedPrePeginTxHex, onDownloaded, + onLoadingChange, }, ref, ) { @@ -68,8 +124,16 @@ export const RecoveryArtifactsCard = forwardRef< return { vaultId, unsignedPrePeginTxHex, btcWallet }; }, [btcWallet, unsignedPrePeginTxHex, vaultId]); - const { loading, progress, error, downloaded, download, cancel } = - useArtifactDownload({ vaultId, primeContext }); + const { + loading, + progress, + error, + downloaded, + receivedBytes, + totalBytes, + download, + cancel, + } = useArtifactDownload({ vaultId, primeContext }); useImperativeHandle(ref, () => ({ cancel }), [cancel]); @@ -84,15 +148,65 @@ export const RecoveryArtifactsCard = forwardRef< } }, [downloaded, onDownloaded]); + useEffect(() => { + onLoadingChange?.(loading); + }, [loading, onLoadingChange]); + const handleDownload = () => { download(providerAddress, peginTxid, depositorPk); }; + // While the download is in flight, the parent modal swaps its own + // title/body/footer (see ArtifactDownloadModal) and we drop the icon + // header + inline Cancel link — the modal's footer button handles + // cancellation. The container styling stays consistent across states + // so the box position in the dialog doesn't jump. + if (loading) { + return ( +
+ {totalBytes > 0 ? ( + <> +
+ + {formatBytes(receivedBytes)} + + {" / "} + {formatBytes(totalBytes)} + + + + {Math.min(100, Math.round((receivedBytes / totalBytes) * 100))}% + +
+ + + {COPY.deposit.recoveryArtifacts.doNotCloseHint} + + + ) : ( +
+ + + {progress || COPY.deposit.recoveryArtifacts.downloadingButton} + +
+ )} +
+ ); + } + return (
-
- +
+
@@ -103,41 +217,15 @@ export const RecoveryArtifactsCard = forwardRef< {COPY.deposit.recoveryArtifacts.cardSubtitle} - {COPY.deposit.recoveryArtifacts.cardSize} + {isDownloaded + ? COPY.deposit.recoveryArtifacts.cardSizeDownloaded + : COPY.deposit.recoveryArtifacts.cardSize}
- {isDownloaded ? ( -
- - - {COPY.deposit.recoveryArtifacts.downloadedLabel} - -
- ) : loading ? ( -
-
- - - {COPY.deposit.recoveryArtifacts.downloadingButton} - -
- {progress && ( - - {progress} - - )} - -
- ) : ( + {!isDownloaded && (
) : ( diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index 0101c090f..54805604e 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -318,11 +318,10 @@ export const COPY = { bodyDownloaded: "Your files are stored locally and never uploaded.", cancelButton: "Cancel", cancelDownloadButton: "Cancel download", - // Right footer button in the downloaded state. The handler is - // contextual: PendingDepositSection hands off to the activation - // flow; other call sites (sign continuation, collateral re-download) - // fall back to their existing onComplete handler. - activateButton: "Activate Vault", + // Right footer button in the downloaded state. The modal only confirms + // the artifacts are on disk; it doesn't perform activation, so the + // label simply dismisses the dialog. + doneButton: "Done", }, recoveryArtifacts: { cardTitle: "Recovery artifacts",