diff --git a/services/vault/src/components/shared/AppSidebar.tsx b/services/vault/src/components/shared/AppSidebar.tsx index 648be054c..a4c975faf 100644 --- a/services/vault/src/components/shared/AppSidebar.tsx +++ b/services/vault/src/components/shared/AppSidebar.tsx @@ -13,7 +13,7 @@ import type { ComponentType } from "react"; import { NavLink } from "react-router"; import { getVisibleV3NavGroups, type V3NavItemId } from "@/config/v3Navigation"; -import { usePendingDepositCount } from "@/context/deposit/pendingDepositCount"; +import { usePendingDepositSummary } from "@/context/deposit/pendingDepositCount"; import { PendingDepositsBadge } from "./PendingDepositsBadge"; import { SidebarFooter } from "./SidebarFooter"; @@ -39,7 +39,7 @@ function V3NavLinks() { // section is never advertised as a dead end. The sidebar itself only renders // under v3 — see RootLayout. const groups = getVisibleV3NavGroups(); - const pendingCount = usePendingDepositCount(); + const pending = usePendingDepositSummary(); return ( <> @@ -54,6 +54,7 @@ function V3NavLinks() { @@ -63,8 +64,8 @@ function V3NavLinks() { label={label} isActive={isActive} trailing={ - id === "vaults" && pendingCount > 0 ? ( - + id === "vaults" && pending.count > 0 ? ( + ) : undefined } /> diff --git a/services/vault/src/components/shared/PendingDepositsBadge.tsx b/services/vault/src/components/shared/PendingDepositsBadge.tsx index 786f80316..54d0a12d0 100644 --- a/services/vault/src/components/shared/PendingDepositsBadge.tsx +++ b/services/vault/src/components/shared/PendingDepositsBadge.tsx @@ -4,19 +4,29 @@ const BADGE_SIZE = 18; const RING_STROKE_WIDTH = 2; const RING_RADIUS = 8; const RING_CENTER = BADGE_SIZE / 2; -const PROGRESS_ARC_PATH = "M17 9C17 4.58172 13.4183 1 9 1"; const MAX_DISPLAYED_COUNT = 9; -export function PendingDepositsBadge({ count }: { count: number }) { +export function PendingDepositsBadge({ + count, + progress, +}: { + count: number; + progress: number | null; +}) { const displayedCount = count > MAX_DISPLAYED_COUNT ? COPY.nav.pendingDepositsOverflow(MAX_DISPLAYED_COUNT) : count; + const label = + progress === null + ? COPY.nav.pendingDeposits(count) + : `${COPY.nav.pendingDeposits(count)}, ${COPY.nav.pendingDepositsProgress(Math.round(progress * 100))}`; return ( - + {progress !== null && progress > 0 && ( + + )} {displayedCount} + {progress !== null && ( + + {Math.round(progress * 100)}% + + )} ); } diff --git a/services/vault/src/components/shared/__tests__/AppSidebar.test.tsx b/services/vault/src/components/shared/__tests__/AppSidebar.test.tsx index 3a2df6a9a..2a1124929 100644 --- a/services/vault/src/components/shared/__tests__/AppSidebar.test.tsx +++ b/services/vault/src/components/shared/__tests__/AppSidebar.test.tsx @@ -1,7 +1,9 @@ -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { publishPendingDepositSummary } from "@/context/deposit/pendingDepositCount"; + import { AppSidebar } from "../AppSidebar"; const featureFlagsMock = vi.hoisted(() => ({ @@ -9,16 +11,11 @@ const featureFlagsMock = vi.hoisted(() => ({ })); vi.mock("@/config/featureFlags", () => ({ default: featureFlagsMock })); -const pendingCountMock = vi.hoisted(() => ({ value: 0 })); - -vi.mock("@/context/deposit/pendingDepositCount", () => ({ - usePendingDepositCount: () => pendingCountMock.value, -})); describe("AppSidebar", () => { beforeEach(() => { featureFlagsMock.isExploreEnabled = true; - pendingCountMock.value = 0; + publishPendingDepositSummary(0, null); }); it("renders all 6 nav items", () => { @@ -109,8 +106,8 @@ describe("AppSidebar", () => { ); }); - it("shows the pending deposit count on the Vaults row", () => { - pendingCountMock.value = 3; + it("updates the progress arc without a count change", () => { + publishPendingDepositSummary(3, 5 / 6); render( @@ -118,9 +115,39 @@ describe("AppSidebar", () => { , ); - const badge = screen.getByRole("img", { name: "3 pending deposits" }); + const badge = screen.getByRole("img", { name: /^3 pending deposits/ }); expect(badge).toHaveTextContent("3"); expect(screen.getByTestId("nav-vaults")).toContainElement(badge); + expect(screen.getByTestId("nav-vaults")).toHaveAccessibleName( + "Vaults 3 pending deposits, 83% average deposit progress", + ); + expect(badge.querySelector("circle[stroke-dasharray]")).toHaveAttribute( + "stroke-dashoffset", + String(1 - 5 / 6), + ); + + act(() => publishPendingDepositSummary(3, 13 / 15)); + + expect(badge).toHaveTextContent("3"); + expect(screen.getByTestId("nav-vaults")).toHaveAccessibleName( + "Vaults 3 pending deposits, 87% average deposit progress", + ); + expect(badge.querySelector("circle[stroke-dasharray]")).toHaveAttribute( + "stroke-dashoffset", + String(1 - 13 / 15), + ); + }); + + it.each([null, 0])("shows no progress arc for %s progress", (progress) => { + publishPendingDepositSummary(1, progress); + render( + + + , + ); + + const badge = screen.getByRole("img", { name: /^1 pending deposit/ }); + expect(badge.querySelector("circle[stroke-dasharray]")).toBeNull(); }); it("shows no badge when there are no pending deposits", () => { @@ -136,7 +163,7 @@ describe("AppSidebar", () => { }); it("caps the visible count at 9+ but keeps the full count in the accessible name", () => { - pendingCountMock.value = 10; + publishPendingDepositSummary(10, null); render( diff --git a/services/vault/src/context/deposit/PeginPollingContext.tsx b/services/vault/src/context/deposit/PeginPollingContext.tsx index c507f1e0f..010de1270 100644 --- a/services/vault/src/context/deposit/PeginPollingContext.tsx +++ b/services/vault/src/context/deposit/PeginPollingContext.tsx @@ -22,6 +22,7 @@ import { } from "react"; import type { Hex } from "viem"; +import { getStepFillPercent } from "@/components/simple/DepositProgressView/steps"; import { useActivationFloorGate } from "@/hooks/useActivationFloorGate"; import { logger } from "@/infrastructure"; import { shortId, TELEMETRY_EVENT } from "@/infrastructure/telemetryEvents"; @@ -38,6 +39,7 @@ import { useClaimExpiredWindowGate } from "../../hooks/useClaimExpiredWindowGate import { useStuckVaultChainConfirm } from "../../hooks/useStuckVaultChainConfirm"; import { ContractStatus, + getPeginProgressStep, LocalStorageStatus, } from "../../models/peginStateMachine"; import { @@ -74,7 +76,7 @@ import { subscribeToOptimisticDepositState, } from "./optimisticDepositState"; import { - publishPendingDepositCount, + publishPendingDepositSummary, selectPendingActivities, } from "./pendingDepositCount"; import { @@ -206,18 +208,6 @@ export function PeginPollingProvider({ // controlled results below instead of the live polling decision tree. const demo = useDepositOverride(); - const pendingDepositCount = useMemo( - () => - isConnected || demo - ? selectPendingActivities(activities, demo).length - : 0, - [isConnected, activities, demo], - ); - useEffect(() => { - publishPendingDepositCount(pendingDepositCount); - }, [pendingDepositCount]); - useEffect(() => () => publishPendingDepositCount(0), []); - // Optimistic step completions (for immediate UI feedback after an action). // App-scoped, not provider-scoped: the writers run outside the context // surface, and the provider itself still unmounts (geo-block branch, wallet @@ -717,6 +707,33 @@ export function PeginPollingProvider({ ], ); + useEffect(() => { + // The demo gallery also keeps completed deposits for preview. + const pending = ( + isConnected || demo ? selectPendingActivities(activities, demo) : [] + ).filter( + (activity) => + activity.contractStatus === ContractStatus.PENDING || + activity.contractStatus === ContractStatus.VERIFIED, + ); + const fills = pending.map((activity) => { + const result = getPollingResult(activity.id); + const step = + result && !result.loading + ? (result.displayStepOverride ?? + getPeginProgressStep(result.peginState)) + : null; + return step !== null ? getStepFillPercent(step) : null; + }); + publishPendingDepositSummary( + pending.length, + fills.length > 0 && fills.every((fill) => fill !== null) + ? fills.reduce((sum, fill) => sum + fill, 0) / fills.length + : null, + ); + }, [activities, demo, getPollingResult, isConnected]); + useEffect(() => () => publishPendingDepositSummary(0, null), []); + // Surface a browser notification when any polled deposit enters a // signing/action-required state while the user is on another tab. useSigningRequiredNotifications(activities, getPollingResult, btcPublicKey); diff --git a/services/vault/src/context/deposit/__tests__/PeginPollingContext.test.tsx b/services/vault/src/context/deposit/__tests__/PeginPollingContext.test.tsx index 6a310c170..08e784513 100644 --- a/services/vault/src/context/deposit/__tests__/PeginPollingContext.test.tsx +++ b/services/vault/src/context/deposit/__tests__/PeginPollingContext.test.tsx @@ -3,8 +3,16 @@ import type { PropsWithChildren } from "react"; import type { Hex } from "viem"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setDepositOverride } from "@/overrides/deposits"; + +import featureFlags from "../../../config/featureFlags"; import { STORAGE_KEY_PREFIX } from "../../../constants"; import { COPY } from "../../../copy"; +import { + ACTIVATED_SCENARIO_INDEX, + buildDepositsDemo, + DEPOSIT_SCENARIOS, +} from "../../../dev/demoDeposit"; import { ContractStatus, isVaultActivated, @@ -26,6 +34,7 @@ import { markWotsSubmitted, resetOptimisticDepositState, } from "../optimisticDepositState"; +import { usePendingDepositSummary } from "../pendingDepositCount"; const mockQueryResult = { polledIds: undefined as string[] | undefined, @@ -161,12 +170,13 @@ const ACTIVITY: VaultActivity = { depositorWotsPkHash: "0xwotsh", }; -function renderProvider() { +function renderProvider(isConnected = false, activities = [ACTIVITY]) { const wrapper = ({ children }: PropsWithChildren) => ( {children} @@ -221,12 +231,14 @@ describe("PeginPollingContext", () => { // and re-exposes SIGN_PAYOUT_TRANSACTIONS until the next poll. mockQueryResult.pendingDepositorSignatures = new Set([ACTIVITY_ID]); - const { result } = renderProvider(); + const { result, unmount } = renderProvider(true); + const summary = renderHook(usePendingDepositSummary); const before = result.current.getPollingResult(ACTIVITY_ID); expect(before?.peginState.availableActions).toContain( PeginAction.SIGN_PAYOUT_TRANSACTIONS, ); + expect(summary.result.current).toEqual({ count: 1, progress: 8 / 15 }); act(() => { result.current.setOptimisticStatus( @@ -243,6 +255,95 @@ describe("PeginPollingContext", () => { expect(after?.peginState.displayLabel).toBe( PEGIN_DISPLAY_LABELS.PROCESSING, ); + expect(summary.result.current).toEqual({ count: 1, progress: 11 / 15 }); + + unmount(); + expect(summary.result.current).toEqual({ count: 0, progress: null }); + }); + + it("averages pending deposit progress without rounding each deposit", () => { + mockQueryResult.pendingDepositorSignatures = new Set([ACTIVITY_ID]); + renderProvider(true, [ + ACTIVITY, + { + ...ACTIVITY, + id: "0xpeginOther", + contractStatus: ContractStatus.VERIFIED, + }, + ]); + const { result } = renderHook(usePendingDepositSummary); + + expect(result.current.count).toBe(2); + expect(result.current.progress).toBeCloseTo(2 / 3, 12); + }); + + it("excludes completed demo deposits from the pending summary", () => { + const flag = vi + .spyOn(featureFlags, "isGodModePanelEnabled", "get") + .mockReturnValue(true); + try { + const demo = buildDepositsDemo( + [ + DEPOSIT_SCENARIOS.findIndex((s) => s.key === "pending-step-11"), + ACTIVATED_SCENARIO_INDEX, + ].map((stateIndex, key) => ({ + key, + type: "deposit", + stateIndex, + amount: ACTIVITY.collateral.amount, + batched: false, + })), + true, + ); + setDepositOverride(demo); + renderProvider(); + const { result } = renderHook(usePendingDepositSummary); + + expect(result.current).toEqual({ count: 1, progress: 2 / 3 }); + + act(() => + setDepositOverride({ + ...demo, + pendingActivities: demo.pendingActivities.filter( + (a) => a.contractStatus === ContractStatus.ACTIVE, + ), + }), + ); + + expect(result.current).toEqual({ count: 0, progress: null }); + } finally { + act(() => setDepositOverride(null)); + flag.mockRestore(); + } + }); + + it.each(["loading", "unknown"])( + "keeps the count without progress when a deposit's progress is %s", + (state) => { + const { rerender } = renderProvider(true, [ + ACTIVITY, + { ...ACTIVITY, id: "0xpeginOther" }, + ]); + const { result } = renderHook(usePendingDepositSummary); + expect(result.current.progress).not.toBeNull(); + + if (state === "loading") mockQueryResult.isLoading = true; + else { + mockQueryResult.errors = new Map([ + [ACTIVITY_ID, new Error("Unauthorized depositor")], + ]); + } + rerender(); + + expect(result.current).toEqual({ count: 2, progress: null }); + }, + ); + + it("publishes no pending deposits while disconnected", () => { + renderProvider(); + const { result } = renderHook(usePendingDepositSummary); + + expect(result.current).toEqual({ count: 0, progress: null }); }); it("does not suppress transactionsReady from a localStorage-only PAYOUT_SIGNED — keeps the existing stale-localStorage cross-check intact", () => { diff --git a/services/vault/src/context/deposit/pendingDepositCount.ts b/services/vault/src/context/deposit/pendingDepositCount.ts index c27c914c0..19001bdff 100644 --- a/services/vault/src/context/deposit/pendingDepositCount.ts +++ b/services/vault/src/context/deposit/pendingDepositCount.ts @@ -17,7 +17,7 @@ export function selectPendingActivities( return [...demo.pendingActivities, ...(demo.hideReal ? [] : real)]; } -let pendingDepositCount = 0; +let pendingDepositSummary = { count: 0, progress: null as number | null }; const listeners = new Set<() => void>(); function subscribe(listener: () => void): () => void { @@ -27,16 +27,23 @@ function subscribe(listener: () => void): () => void { }; } -function getSnapshot(): number { - return pendingDepositCount; +function getSnapshot() { + return pendingDepositSummary; } -export function publishPendingDepositCount(count: number): void { - if (count === pendingDepositCount) return; - pendingDepositCount = count; +export function publishPendingDepositSummary( + count: number, + progress: number | null, +): void { + if ( + count === pendingDepositSummary.count && + progress === pendingDepositSummary.progress + ) + return; + pendingDepositSummary = { count, progress }; for (const listener of listeners) listener(); } -export function usePendingDepositCount(): number { +export function usePendingDepositSummary() { return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } diff --git a/services/vault/src/copy.ts b/services/vault/src/copy.ts index ad9224e25..1475bed83 100644 --- a/services/vault/src/copy.ts +++ b/services/vault/src/copy.ts @@ -2413,6 +2413,8 @@ export const COPY = { pendingDeposits: (count: number) => `${count} pending ${count === 1 ? "deposit" : "deposits"}`, pendingDepositsOverflow: (max: number) => `${max}+`, + pendingDepositsProgress: (percent: number) => + `${percent}% average deposit progress`, termsOfUse: "Terms of Use", privacyPolicy: "Privacy Policy", },