Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions services/vault/src/components/shared/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
<>
Expand All @@ -54,6 +54,7 @@ function V3NavLinks() {
<NavLink
key={path}
to={path}
className="group"
end={path === "/"}
data-testid={`nav-${id}`}
>
Expand All @@ -63,8 +64,8 @@ function V3NavLinks() {
label={label}
isActive={isActive}
trailing={
id === "vaults" && pendingCount > 0 ? (
<PendingDepositsBadge count={pendingCount} />
id === "vaults" && pending.count > 0 ? (
<PendingDepositsBadge {...pending} />
) : undefined
}
/>
Expand Down
44 changes: 35 additions & 9 deletions services/vault/src/components/shared/PendingDepositsBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<span
role="img"
aria-label={COPY.nav.pendingDeposits(count)}
aria-label={label}
title={label}
className="relative flex size-[18px] items-center justify-center"
>
<svg
Expand All @@ -34,16 +44,32 @@ export function PendingDepositsBadge({ count }: { count: number }) {
strokeWidth={RING_STROKE_WIDTH}
className="stroke-secondary-strokeLight"
/>
<path
d={PROGRESS_ARC_PATH}
strokeWidth={RING_STROKE_WIDTH}
strokeLinecap="round"
className="stroke-risk-amber"
/>
{progress !== null && progress > 0 && (
<circle
cx={RING_CENTER}
cy={RING_CENTER}
r={RING_RADIUS}
pathLength={1}
strokeDasharray={1}
strokeDashoffset={1 - progress}
transform={`rotate(-90 ${RING_CENTER} ${RING_CENTER})`}
strokeWidth={RING_STROKE_WIDTH}
strokeLinecap="round"
className="stroke-risk-amber"
/>
)}
</svg>
<span className="relative text-[10px] font-bold leading-[1.2] tracking-[0.4px] text-accent-primary">
{displayedCount}
</span>
{progress !== null && (
<span
aria-hidden
className="absolute right-6 hidden text-xs text-accent-primary group-focus-visible:block"
>
{Math.round(progress * 100)}%
</span>
)}
</span>
);
}
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
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(() => ({
isExploreEnabled: true,
}));

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", () => {
Expand Down Expand Up @@ -109,18 +106,48 @@ 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(
<MemoryRouter initialEntries={["/"]}>
<AppSidebar />
</MemoryRouter>,
);

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(
<MemoryRouter>
<AppSidebar />
</MemoryRouter>,
);

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", () => {
Expand All @@ -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(
<MemoryRouter initialEntries={["/"]}>
Expand Down
43 changes: 30 additions & 13 deletions services/vault/src/context/deposit/PeginPollingContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -38,6 +39,7 @@ import { useClaimExpiredWindowGate } from "../../hooks/useClaimExpiredWindowGate
import { useStuckVaultChainConfirm } from "../../hooks/useStuckVaultChainConfirm";
import {
ContractStatus,
getPeginProgressStep,
LocalStorageStatus,
} from "../../models/peginStateMachine";
import {
Expand Down Expand Up @@ -74,7 +76,7 @@ import {
subscribeToOptimisticDepositState,
} from "./optimisticDepositState";
import {
publishPendingDepositCount,
publishPendingDepositSummary,
selectPendingActivities,
} from "./pendingDepositCount";
import {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading