Skip to content

Commit eb7b8e5

Browse files
committed
feat(LIVE-29447): add desktop swap status modal
1 parent 37455e8 commit eb7b8e5

23 files changed

Lines changed: 1020 additions & 10 deletions

apps/ledger-live-desktop/src/renderer/hooks/useDeeplinking/__tests__/parseDeepLink.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,21 @@ describe("parseDeepLink", () => {
181181
});
182182
});
183183

184+
it("creates transaction-status swap route", () => {
185+
const parsed = parseDeepLink(
186+
"ledgerwallet://connect/swap/transaction-status?swapId=swap-1&provider=lifi&redirectUrl=https%3A%2F%2Fexample.com",
187+
);
188+
const route = createRoute(parsed);
189+
190+
expect(route).toEqual({
191+
type: "transaction-status",
192+
kind: "swap",
193+
swapId: "swap-1",
194+
provider: "lifi",
195+
redirectUrl: "https://example.com",
196+
});
197+
});
198+
184199
it("creates bridge route", () => {
185200
const parsed = parseDeepLink("ledgerwallet://bridge?origin=https://example.com&appName=Test");
186201
const route = createRoute(parsed);

apps/ledger-live-desktop/src/renderer/hooks/useDeeplinking/__tests__/useDeepLinkHandler.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,27 @@ jest.mock("~/renderer/actions/modals", () => ({
1818
closeAllModal: jest.fn(() => ({ type: "CLOSE_ALL_MODAL" })),
1919
}));
2020

21+
jest.mock(
22+
"@ledgerhq/live-common/exchange/transactionStatus/index",
23+
() => ({
24+
parseSwapTransactionStatusParams: jest.fn(params => {
25+
if (!params.swapId) {
26+
return { ok: false, error: { code: "missing_required_field" } };
27+
}
28+
return {
29+
ok: true,
30+
params: {
31+
kind: "swap",
32+
swapId: params.swapId,
33+
provider: params.provider,
34+
redirectUrl: params.redirectUrl,
35+
},
36+
};
37+
}),
38+
}),
39+
{ virtual: true },
40+
);
41+
2142
jest.mock("~/renderer/actions/walletSync");
2243

2344
const mockOpenModal = jest.mocked(openModal);
@@ -176,6 +197,34 @@ describe("useDeepLinkHandler", () => {
176197
});
177198
});
178199

200+
describe("transaction-status flow", () => {
201+
it("opens the SwapTransactionStatus modal with validated swap params", async () => {
202+
await testDeeplink(
203+
"ledgerwallet://connect/swap/transaction-status?swapId=swap-1&provider=lifi&redirectUrl=https%3A%2F%2Fexample.com",
204+
);
205+
206+
await waitFor(() => {
207+
expect(mockOpenModal).toHaveBeenCalledWith("MODAL_SWAP_TRANSACTION_STATUS", {
208+
kind: "swap",
209+
swapId: "swap-1",
210+
provider: "lifi",
211+
redirectUrl: "https://example.com",
212+
});
213+
});
214+
});
215+
216+
it("ignores invalid SwapTransactionStatus deeplinks", async () => {
217+
await testDeeplink("ledgerwallet://connect/swap/transaction-status?provider=lifi");
218+
219+
await waitFor(() => {
220+
expect(mockOpenModal).not.toHaveBeenCalledWith(
221+
"MODAL_SWAP_TRANSACTION_STATUS",
222+
expect.anything(),
223+
);
224+
});
225+
});
226+
});
227+
179228
describe("send/receive/delegate flows", () => {
180229
describe.each([
181230
["send", "MODAL_SEND"],
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { parseSwapTransactionStatusParams } from "@ledgerhq/live-common/exchange/transactionStatus/index";
2+
import { openModal } from "~/renderer/actions/modals";
3+
import { DeeplinkHandler } from "../types";
4+
5+
export const swapTransactionStatusHandler: DeeplinkHandler<"transaction-status"> = (
6+
route,
7+
{ dispatch },
8+
) => {
9+
const result = parseSwapTransactionStatusParams(route);
10+
if (!result.ok) return;
11+
dispatch(openModal("MODAL_SWAP_TRANSACTION_STATUS", result.params));
12+
};

apps/ledger-live-desktop/src/renderer/hooks/useDeeplinking/parseDeepLink.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
BorrowRoute,
1111
ManagerRoute,
1212
SwapRoute,
13+
SwapTransactionStatusRoute,
1314
BridgeRoute,
1415
SendRoute,
1516
ReceiveRoute,
@@ -175,6 +176,31 @@ export function createRoute(parsed: ParsedDeeplink): DeeplinkRoute {
175176
return route;
176177
}
177178

179+
case "connect": {
180+
if (path === "swap/transaction-status") {
181+
const route: SwapTransactionStatusRoute = {
182+
type: "transaction-status",
183+
kind: "swap",
184+
swapId: query.swapId,
185+
provider: query.provider,
186+
redirectUrl: query.redirectUrl,
187+
};
188+
return route;
189+
}
190+
return { type: "default" };
191+
}
192+
193+
case "transaction-status": {
194+
const route: SwapTransactionStatusRoute = {
195+
type: "transaction-status",
196+
kind: path === "swap" || !path ? "swap" : (path as never),
197+
swapId: query.swapId,
198+
provider: query.provider,
199+
redirectUrl: query.redirectUrl,
200+
};
201+
return route;
202+
}
203+
178204
case "bridge": {
179205
const route: BridgeRoute = {
180206
type: "bridge",

apps/ledger-live-desktop/src/renderer/hooks/useDeeplinking/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { earnHandler } from "./handlers/earn.handler";
77
import { borrowHandler } from "./handlers/borrow.handler";
88
import { managerHandler } from "./handlers/manager.handler";
99
import { swapHandler } from "./handlers/swap.handler";
10+
import { swapTransactionStatusHandler } from "./handlers/swapTransactionStatus.handler";
1011
import { bridgeHandler } from "./handlers/bridge.handler";
1112
import { sendHandler, receiveHandler, delegateHandler } from "./handlers/transactionFlow.handler";
1213
import { settingsHandler } from "./handlers/settings.handler";
@@ -30,6 +31,7 @@ export const deeplinkRegistry: DeeplinkHandlerRegistry = {
3031
borrow: borrowHandler,
3132
myledger: managerHandler,
3233
swap: swapHandler,
34+
"transaction-status": swapTransactionStatusHandler,
3335
bridge: bridgeHandler,
3436
send: sendHandler,
3537
receive: receiveHandler,

apps/ledger-live-desktop/src/renderer/hooks/useDeeplinking/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Account, TokenAccount } from "@ledgerhq/types-live";
22
import { CryptoOrTokenCurrency } from "@ledgerhq/types-cryptoassets";
33
import type { AppDispatch } from "~/state-manager/configureStore";
4+
import type { SwapTransactionStatusRawParams } from "@ledgerhq/live-common/exchange/transactionStatus/index";
45

56
export type NavigateFn = (
67
pathname: string,
@@ -135,6 +136,10 @@ export interface SwapRoute {
135136
toAccountId?: string;
136137
}
137138

139+
export interface SwapTransactionStatusRoute extends SwapTransactionStatusRawParams {
140+
type: "transaction-status";
141+
}
142+
138143
export interface BridgeRoute {
139144
type: "bridge";
140145
origin?: string;
@@ -240,6 +245,7 @@ export type DeeplinkRoute =
240245
| BorrowRoute
241246
| ManagerRoute
242247
| SwapRoute
248+
| SwapTransactionStatusRoute
243249
| BridgeRoute
244250
| SendRoute
245251
| ReceiveRoute
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import React, { useEffect, useRef, useState } from "react";
2+
import { useTranslation } from "react-i18next";
3+
import { Copy } from "@ledgerhq/lumen-ui-react/symbols";
4+
import { useCopyToClipboard } from "LLD/hooks/useCopyToClipboard";
5+
6+
type CopyIconButtonProps = {
7+
text: string;
8+
};
9+
10+
export function CopyIconButton({ text }: CopyIconButtonProps) {
11+
const { t } = useTranslation();
12+
const [isCopied, setIsCopied] = useState(false);
13+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
14+
const copyToClipboard = useCopyToClipboard(() => {
15+
setIsCopied(true);
16+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
17+
timeoutRef.current = setTimeout(() => setIsCopied(false), 1_000);
18+
});
19+
20+
useEffect(() => {
21+
return () => {
22+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
23+
};
24+
}, []);
25+
26+
return (
27+
<>
28+
<button
29+
type="button"
30+
className="inline-flex cursor-pointer border-0 bg-transparent p-0 text-base hover:text-interactive"
31+
aria-label={t("swap2.modals.transactionStatus.accessibility.copySwapId")}
32+
onClick={() => copyToClipboard(text)}
33+
>
34+
<Copy size={16} />
35+
</button>
36+
{isCopied ? (
37+
<div className="pointer-events-none fixed inset-0 z-10000 flex items-center justify-center">
38+
<div className="rounded-sm bg-base px-16 py-8 body-2-semi-bold text-inverted">
39+
{t("swap2.modals.transactionStatus.actions.copied")}
40+
</div>
41+
</div>
42+
) : null}
43+
</>
44+
);
45+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import React from "react";
2+
3+
type DetailRowProps = {
4+
label: string;
5+
value: React.ReactNode;
6+
};
7+
8+
export function DetailRow({ label, value }: DetailRowProps) {
9+
return (
10+
<div className="flex justify-between">
11+
<dt className="body-3 text-muted">{label}</dt>
12+
<dd className="text-right body-3-semi-bold text-base">{value}</dd>
13+
</div>
14+
);
15+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import React from "react";
2+
import { useTranslation } from "react-i18next";
3+
import { Skeleton } from "@ledgerhq/lumen-ui-react";
4+
import { getProviderName } from "@ledgerhq/live-common/exchange/swap/utils/index";
5+
import type { AdditionalProviderConfig } from "@ledgerhq/live-common/exchange/providers/swap";
6+
import type { CryptoOrTokenCurrency } from "@ledgerhq/types-cryptoassets";
7+
import CryptoCurrencyIcon from "~/renderer/components/CryptoCurrencyIcon";
8+
import ProviderIcon from "~/renderer/components/ProviderIcon";
9+
import { openURL } from "~/renderer/linking";
10+
import { CopyIconButton } from "./CopyIconButton";
11+
import { DetailRow } from "./DetailRow";
12+
import { truncateMiddle } from "../utils";
13+
14+
interface Props {
15+
feesAmount?: string;
16+
receiveAccountName?: string;
17+
provider?: string;
18+
providerData?: AdditionalProviderConfig;
19+
receiveCurrency?: CryptoOrTokenCurrency;
20+
swapId: string;
21+
}
22+
23+
export function DetailsSection({
24+
feesAmount,
25+
receiveAccountName,
26+
provider,
27+
providerData,
28+
receiveCurrency,
29+
swapId,
30+
}: Props) {
31+
const { t } = useTranslation();
32+
const providerName = provider ? getProviderName(provider) : undefined;
33+
34+
return (
35+
<section>
36+
<dl className="m-0 flex flex-col gap-12">
37+
<DetailRow
38+
label={t("swap2.modals.transactionStatus.sections.details.networkFees")}
39+
value={feesAmount ?? <Skeleton className="h-16 w-96 rounded-sm" />}
40+
/>
41+
<DetailRow
42+
label={t("swap2.modals.transactionStatus.sections.details.receiveAccount")}
43+
value={
44+
receiveAccountName ? (
45+
<span className="inline-flex items-center gap-6">
46+
<span>{receiveAccountName}</span>
47+
{receiveCurrency ? (
48+
<CryptoCurrencyIcon currency={receiveCurrency} size={16} />
49+
) : null}
50+
</span>
51+
) : (
52+
<Skeleton className="h-16 w-112 rounded-sm" />
53+
)
54+
}
55+
/>
56+
{provider && providerName ? (
57+
<DetailRow
58+
label={t("swap2.modals.transactionStatus.sections.details.provider")}
59+
value={
60+
providerData?.mainUrl ? (
61+
<button
62+
type="button"
63+
className="inline-flex cursor-pointer items-center gap-6 border-0 bg-transparent p-0 body-3 text-base"
64+
onClick={() => openURL(providerData.mainUrl!, "SwapTransactionStatus_Provider")}
65+
>
66+
<span>{providerName}</span>
67+
<ProviderIcon name={provider} size="XXS" boxed={false} />
68+
</button>
69+
) : (
70+
<span className="inline-flex items-center gap-6">
71+
<span className="body-3-semi-bold">{providerName}</span>
72+
<ProviderIcon name={provider} size="XXS" boxed={false} />
73+
</span>
74+
)
75+
}
76+
/>
77+
) : null}
78+
<DetailRow
79+
label={t("swap2.modals.transactionStatus.sections.details.swapId")}
80+
value={
81+
<span className="inline-flex items-center gap-6">
82+
<p className="body-3-semi-bold">{truncateMiddle(swapId)}</p>
83+
<CopyIconButton text={swapId} />
84+
</span>
85+
}
86+
/>
87+
</dl>
88+
</section>
89+
);
90+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import React from "react";
2+
import { useTranslation } from "react-i18next";
3+
import { IconButton } from "@ledgerhq/lumen-ui-react";
4+
import { Close } from "@ledgerhq/lumen-ui-react/symbols";
5+
6+
interface Props {
7+
children: React.ReactNode;
8+
onClose: () => void;
9+
}
10+
11+
export function ModalLayout({ children, onClose }: Props) {
12+
const { t } = useTranslation();
13+
14+
return (
15+
<div className="flex flex-col bg-base">
16+
<div className="flex justify-end p-16">
17+
<IconButton
18+
aria-label={t("swap2.modals.transactionStatus.accessibility.close")}
19+
icon={Close}
20+
onClick={onClose}
21+
size="sm"
22+
appearance="transparent"
23+
/>
24+
</div>
25+
<div className="flex flex-col px-24 pb-24 pt-12 gap-24">{children}</div>
26+
</div>
27+
);
28+
}

0 commit comments

Comments
 (0)