diff --git a/.changeset/unlucky-pianos-smash.md b/.changeset/unlucky-pianos-smash.md new file mode 100644 index 000000000000..3c2682cd8e33 --- /dev/null +++ b/.changeset/unlucky-pianos-smash.md @@ -0,0 +1,5 @@ +--- +"ledger-live-desktop": minor +--- + +refactor canton offers desktop diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/DeviceAppModal.test.tsx b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/DeviceAppModal.test.tsx deleted file mode 100644 index 09481ddffe2c..000000000000 --- a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/DeviceAppModal.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import React from "react"; -import { render, screen, fireEvent, waitFor } from "tests/testSetup"; -import { setEnv } from "@ledgerhq/live-env"; -import DeviceAppModal from "./DeviceAppModal"; - -describe("DeviceAppModal", () => { - const mockOnConfirm = jest.fn(); - const mockOnClose = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - setEnv("MOCK", "1"); - // Create modals container for Modal component - if (!document.getElementById("modals")) { - const modalsContainer = document.createElement("div"); - modalsContainer.id = "modals"; - document.body.appendChild(modalsContainer); - } - }); - - afterEach(() => { - setEnv("MOCK", ""); - const modalsContainer = document.getElementById("modals"); - if (modalsContainer) { - document.body.removeChild(modalsContainer); - } - }); - - describe("when modal is closed", () => { - it("should not render when isOpen is false", () => { - render( - , - ); - - expect(screen.queryByTestId("canton-offer-action-modal")).not.toBeInTheDocument(); - }); - }); - - describe("when modal is open", () => { - it("should render modal with correct title for accept action", async () => { - render( - , - ); - - await waitFor(() => { - expect(screen.getByTestId("modal-container")).toBeInTheDocument(); - }); - }); - - it("should render modal with correct title for reject action", async () => { - render( - , - ); - - await waitFor(() => { - expect(screen.getByTestId("modal-container")).toBeInTheDocument(); - }); - }); - - it("should render modal with correct title for withdraw action", async () => { - render( - , - ); - - await waitFor(() => { - expect(screen.getByTestId("modal-container")).toBeInTheDocument(); - }); - }); - - it("should call onClose when close button is clicked", async () => { - render( - , - ); - - await waitFor(() => { - expect(screen.getByTestId("modal-close-button")).toBeInTheDocument(); - }); - - const closeButton = screen.getByTestId("modal-close-button"); - fireEvent.click(closeButton); - - expect(mockOnClose).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/DeviceAppModal.tsx b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/DeviceAppModal.tsx index 0d5eaf84c84a..6fd5c24bdddc 100644 --- a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/DeviceAppModal.tsx +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/DeviceAppModal.tsx @@ -1,15 +1,17 @@ -import React, { useCallback, useMemo, useState, useEffect, FC } from "react"; +import React, { FC } from "react"; import { useTranslation } from "react-i18next"; -import Modal, { ModalBody } from "~/renderer/components/Modal"; +import BigSpinner from "~/renderer/components/BigSpinner"; import Box from "~/renderer/components/Box"; import DeviceAction from "~/renderer/components/DeviceAction"; -import SuccessDisplay from "~/renderer/components/SuccessDisplay"; import ErrorDisplay from "~/renderer/components/ErrorDisplay"; -import BigSpinner from "~/renderer/components/BigSpinner"; +import Modal, { ModalBody } from "~/renderer/components/Modal"; +import SuccessDisplay from "~/renderer/components/SuccessDisplay"; import Text from "~/renderer/components/Text"; -import useConnectAppAction from "~/renderer/hooks/useConnectAppAction"; -import { CONNECTION_TYPES } from "~/renderer/analytics/hooks/variables"; import type { TransferProposalAction } from "./types"; +import { + useDeviceAppModalViewModel, + type DeviceAppModalViewModel, +} from "./useDeviceAppModalViewModel"; type Props = { isOpen: boolean; @@ -19,63 +21,37 @@ type Props = { onClose?: () => void; }; -type ConfirmationState = "pending" | "confirming" | "completed" | "error"; - const translations = { - title: { + action: { + accept: "families.canton.pendingTransactions.accept", + reject: "families.canton.pendingTransactions.reject", + withdraw: "families.canton.pendingTransactions.withdraw", + }, + successTitle: { accept: "families.canton.pendingTransactions.deviceAppModal.success.accept.title", reject: "families.canton.pendingTransactions.deviceAppModal.success.reject.title", withdraw: "families.canton.pendingTransactions.deviceAppModal.success.withdraw.title", }, - description: { + successDescription: { accept: "families.canton.pendingTransactions.deviceAppModal.success.accept.description", reject: "families.canton.pendingTransactions.deviceAppModal.success.reject.description", withdraw: "families.canton.pendingTransactions.deviceAppModal.success.withdraw.description", }, }; -const DeviceAppModal: FC = ({ isOpen, onConfirm, action, onClose, appName }) => { +export function View({ + confirmationState, + error, + request, + actionConnect, + handleDeviceResult, + handleRetry, + isOpen, + action, + onClose, +}: DeviceAppModalViewModel) { const { t } = useTranslation(); - const [confirmationState, setConfirmationState] = useState("pending"); - const [error, setError] = useState(null); - - const actionConnect = useConnectAppAction(); - const request = useMemo( - () => ({ - appName, - }), - [appName], - ); - - useEffect(() => { - if (isOpen) { - setConfirmationState("pending"); - setError(null); - } - }, [isOpen]); - - const handleConfirm = useCallback( - async (deviceId: string) => { - try { - setConfirmationState("confirming"); - await onConfirm(deviceId); - setConfirmationState("completed"); - } catch (err) { - setConfirmationState("error"); - setError(err instanceof Error ? err : new Error(String(err))); - } - }, - [onConfirm], - ); - - const handleRetry = useCallback(() => { - setConfirmationState("pending"); - setError(null); - }, []); - - const actionTitle = useMemo(() => { - return action.toUpperCase().slice(0, 1) + action.slice(1); - }, [action]); + const actionTitle = t(translations.action[action]); return ( = ({ isOpen, onConfirm, action, onClose, appName {confirmationState === "completed" ? ( ) : confirmationState === "error" && error ? ( @@ -121,7 +97,7 @@ const DeviceAppModal: FC = ({ isOpen, onConfirm, action, onClose, appName style={{ whiteSpace: "pre-wrap" }} > {t("families.canton.pendingTransactions.deviceAppModal.processing", { - action, + action: actionTitle, })} @@ -129,17 +105,7 @@ const DeviceAppModal: FC = ({ isOpen, onConfirm, action, onClose, appName { - if (result) { - let deviceId = result?.device?.deviceId; - - if (!deviceId || deviceId === "") { - deviceId = result.device?.wired ? CONNECTION_TYPES.USB : CONNECTION_TYPES.BLE; - } - - await handleConfirm(deviceId); - } - }} + onResult={handleDeviceResult} analyticsPropertyFlow="canton-pending" /> )} @@ -148,6 +114,10 @@ const DeviceAppModal: FC = ({ isOpen, onConfirm, action, onClose, appName /> ); +} + +const DeviceAppModal: FC = ({ isOpen, onConfirm, action, onClose, appName }) => { + return ; }; export default DeviceAppModal; diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/PendingTransferProposalsDetails.test.tsx b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/PendingTransferProposalsDetails.test.tsx deleted file mode 100644 index 7c0d4535ad21..000000000000 --- a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/PendingTransferProposalsDetails.test.tsx +++ /dev/null @@ -1,464 +0,0 @@ -/* eslint-disable @typescript-eslint/consistent-type-assertions */ -import { CantonAccount } from "@ledgerhq/live-common/families/canton/types"; -import React from "react"; -import { act, fireEvent, render, screen, waitFor } from "tests/testSetup"; -import PendingTransactionDetails from "./PendingTransferProposalsDetails"; - -jest.mock("react-i18next", () => ({ - ...jest.requireActual("react-i18next"), - useTranslation: () => ({ - t: (key: string) => key, - }), - Trans: ({ i18nKey }: { i18nKey: string }) => {i18nKey}, -})); - -jest.mock("~/renderer/hooks/useAccountUnit", () => ({ - useAccountUnit: jest.fn(() => ({ - code: "AMU", - magnitude: 18, - name: "Amulet", - })), -})); - -jest.mock("~/renderer/components/CopyWithFeedback", () => ({ - __esModule: true, - default: ({ text }: { text: string }) => ( - - ), -})); - -jest.mock("~/renderer/components/OperationsList/AddressCell", () => ({ - __esModule: true, - SplitAddress: ({ value }: { value: string }) => ( - {value} - ), -})); - -const createMockAccount = (xpub: string): CantonAccount => - ({ - id: "test-account-id", - name: "Test Account", - xpub, - currency: { - id: "canton_network", - name: "Canton", - }, - balance: { - toNumber: () => 1000, - }, - cantonResources: { - pendingTransferProposals: [ - { - contract_id: "contract-123", - sender: "sender-address", - receiver: "receiver-address", - amount: "1000000", - memo: "Test memo", - expires_at_micros: Date.now() * 1000 + 3600000, // 1 hour from now - }, - { - contract_id: "contract-456", - sender: "other-sender", - receiver: xpub, - amount: "2000000", - memo: "", - expires_at_micros: Date.now() * 1000 - 3600000, // 1 hour ago (expired) - }, - { - contract_id: "contract-789", - sender: xpub, // Account is the sender (outgoing) - receiver: "other-receiver", - amount: "3000000", - memo: "Outgoing memo", - expires_at_micros: Date.now() * 1000 + 3600000, // 1 hour from now - }, - ], - }, - }) as unknown as CantonAccount; - -const createMockParentAccount = (xpub: string): CantonAccount => - ({ - id: "test-parent-account-id", - name: "Test Parent Account", - xpub, - currency: { - id: "canton_network", - name: "Canton", - }, - balance: { - toNumber: () => 5000, - }, - }) as unknown as CantonAccount; - -describe("PendingTransactionDetails", () => { - const mockOnClose = jest.fn(); - const mockOnOpenModal = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); - - describe("when proposal exists", () => { - it("should render proposal details for incoming transaction", () => { - const account = createMockAccount("receiver-address"); - const parentAccount = createMockParentAccount("receiver-address"); - - render( - , - ); - - expect(screen.getByText("families.canton.pendingTransactions.amount")).toBeInTheDocument(); - expect(screen.getByText("families.canton.pendingTransactions.from")).toBeInTheDocument(); - expect(screen.getByText("families.canton.pendingTransactions.to")).toBeInTheDocument(); - expect(screen.getByTestId("address-sender-address")).toBeInTheDocument(); - expect(screen.getByTestId("address-receiver-address")).toBeInTheDocument(); - }); - - it("should render proposal details for outgoing transaction", () => { - const account = createMockAccount("receiver-address"); - const parentAccount = createMockParentAccount("receiver-address"); - - render( - , - ); - - expect(screen.getByText("families.canton.pendingTransactions.amount")).toBeInTheDocument(); - // For outgoing: sender is xpub (receiver-address), receiver is other-receiver - expect(screen.getByTestId("address-receiver-address")).toBeInTheDocument(); - expect(screen.getByTestId("address-other-receiver")).toBeInTheDocument(); - }); - - it("should display memo when present", () => { - const account = createMockAccount("receiver-address"); - const parentAccount = createMockParentAccount("receiver-address"); - - render( - , - ); - - // Check for memo title and value separately - expect(screen.getByText("families.canton.pendingTransactions.memo")).toBeInTheDocument(); - expect(screen.getByText("Test memo")).toBeInTheDocument(); - }); - - it("should not display memo section when memo is empty", () => { - const account = createMockAccount("receiver-address"); - const parentAccount = createMockParentAccount("receiver-address"); - - render( - , - ); - - expect( - screen.queryByText("families.canton.pendingTransactions.memo"), - ).not.toBeInTheDocument(); - }); - - it("should display contract ID", () => { - const account = createMockAccount("receiver-address"); - const parentAccount = createMockParentAccount("receiver-address"); - - render( - , - ); - - expect(screen.getByTestId("address-contract-123")).toBeInTheDocument(); - expect(screen.getByTestId("copy-contract-123")).toBeInTheDocument(); - }); - - it("should display expired status for expired proposals", () => { - const account = createMockAccount("receiver-address"); - const parentAccount = createMockParentAccount("receiver-address"); - - render( - , - ); - - expect(screen.getByText("families.canton.pendingTransactions.expired")).toBeInTheDocument(); - }); - - it("should update time remaining every second", async () => { - const account = createMockAccount("receiver-address"); - const parentAccount = createMockParentAccount("receiver-address"); - const futureTime = Date.now() + 10000; // 10 seconds from now to ensure it doesn't expire - - const accountWithFutureExpiry = { - ...account, - cantonResources: { - pendingTransferProposals: [ - { - ...account.cantonResources!.pendingTransferProposals[0], - expires_at_micros: futureTime * 1000, - }, - ], - }, - }; - - render( - , - ); - - // Run timers to trigger initial useEffect - await act(async () => { - jest.runOnlyPendingTimers(); - }); - - // Wait for initial time remaining to be calculated and displayed - await waitFor(() => { - // Check that time remaining value is displayed - const timeRemaining = screen.getByText(/\d+[hms]/); - expect(timeRemaining).toBeInTheDocument(); - }); - - // Verify time remaining is displayed initially - const initialTimeRemaining = screen.getByText(/\d+[hms]/); - expect(initialTimeRemaining).toBeInTheDocument(); - - // Advance time by 1 second and wait for re-render - await act(async () => { - jest.advanceTimersByTime(1000); - }); - - // Run pending timers to trigger the interval callback - await act(async () => { - jest.runOnlyPendingTimers(); - }); - - // The time remaining should still be visible after advancing time - // The value should have updated (decreased by 1 second) - await waitFor(() => { - const updatedTimeRemaining = screen.queryByText(/\d+[hms]/); - expect(updatedTimeRemaining).toBeInTheDocument(); - // The value should be different (or at least the element should exist) - expect(updatedTimeRemaining?.textContent).toBeTruthy(); - }); - }); - }); - - describe("action buttons for incoming transactions", () => { - it("should show accept and reject buttons for incoming transaction", () => { - const account = createMockAccount("receiver-address"); - const parentAccount = createMockParentAccount("receiver-address"); - - render( - , - ); - - expect(screen.getByText("families.canton.pendingTransactions.accept")).toBeInTheDocument(); - expect(screen.getByText("families.canton.pendingTransactions.reject")).toBeInTheDocument(); - }); - - it("should disable accept button for expired incoming transaction", () => { - const account = createMockAccount("receiver-address"); - const parentAccount = createMockParentAccount("receiver-address"); - - render( - , - ); - - const acceptButton = screen - .getByText("families.canton.pendingTransactions.accept") - .closest("button"); - expect(acceptButton).toBeDisabled(); - }); - - it("should call onOpenModal with accept action when accept button is clicked", async () => { - const account = createMockAccount("receiver-address"); - const parentAccount = createMockParentAccount("receiver-address"); - - render( - , - ); - - const acceptButton = screen - .getByText("families.canton.pendingTransactions.accept") - .closest("button"); - fireEvent.click(acceptButton!); - - await waitFor(() => { - expect(mockOnOpenModal).toHaveBeenCalledWith("contract-123", "accept"); - }); - }); - - it("should call onOpenModal with reject action when reject button is clicked", async () => { - const account = createMockAccount("receiver-address"); - const parentAccount = createMockParentAccount("receiver-address"); - - render( - , - ); - - const rejectButton = screen - .getByText("families.canton.pendingTransactions.reject") - .closest("button"); - fireEvent.click(rejectButton!); - - await waitFor(() => { - expect(mockOnOpenModal).toHaveBeenCalledWith("contract-123", "reject"); - }); - }); - - it("should call onClose after opening modal", async () => { - const account = createMockAccount("receiver-address"); - const parentAccount = createMockParentAccount("receiver-address"); - - render( - , - ); - - const acceptButton = screen - .getByText("families.canton.pendingTransactions.accept") - .closest("button"); - fireEvent.click(acceptButton!); - - await waitFor(() => { - expect(mockOnClose).toHaveBeenCalled(); - }); - }); - }); - - describe("action buttons for outgoing transactions", () => { - it("should show withdraw button for outgoing transaction", () => { - const account = createMockAccount("receiver-address"); - const parentAccount = createMockParentAccount("receiver-address"); - - render( - , - ); - - // Outgoing action uses the common cancel label - expect(screen.getByText("common.cancel")).toBeInTheDocument(); - expect( - screen.queryByText("families.canton.pendingTransactions.accept"), - ).not.toBeInTheDocument(); - expect( - screen.queryByText("families.canton.pendingTransactions.reject"), - ).not.toBeInTheDocument(); - }); - - it("should call onOpenModal with withdraw action when withdraw button is clicked", async () => { - const account = createMockAccount("receiver-address"); - const parentAccount = createMockParentAccount("receiver-address"); - - render( - , - ); - - const withdrawButton = screen.getByText("common.cancel").closest("button"); - fireEvent.click(withdrawButton!); - - await waitFor(() => { - expect(mockOnOpenModal).toHaveBeenCalledWith("contract-789", "withdraw"); - }); - }); - }); - - describe("when proposal does not exist", () => { - it("should display not found message", () => { - const account = createMockAccount("receiver-address"); - const parentAccount = createMockParentAccount("receiver-address"); - - render( - , - ); - - expect(screen.getByText("common.notFound")).toBeInTheDocument(); - }); - }); -}); diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/PendingTransferProposalsDetails.tsx b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/PendingTransferProposalsDetails.tsx index 4aa8201528b9..50cc6e3d07fe 100644 --- a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/PendingTransferProposalsDetails.tsx +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/PendingTransferProposalsDetails.tsx @@ -1,110 +1,43 @@ -import React, { useCallback, useMemo } from "react"; -import { useTranslation } from "react-i18next"; +import { Divider } from "@ledgerhq/react-ui/index"; import { Account } from "@ledgerhq/types-live"; -import { BigNumber } from "bignumber.js"; +import React from "react"; +import { useTranslation } from "react-i18next"; import Box from "~/renderer/components/Box"; -import Text from "~/renderer/components/Text"; import Button from "~/renderer/components/Button"; -import { useAccountUnit } from "~/renderer/hooks/useAccountUnit"; +import CopyWithFeedback from "~/renderer/components/CopyWithFeedback"; import FormattedVal from "~/renderer/components/FormattedVal"; -import IconCheck from "~/renderer/icons/Check"; -import { CantonAccount } from "@ledgerhq/live-common/families/canton/types"; +import { SplitAddress } from "~/renderer/components/OperationsList/AddressCell"; +import Text from "~/renderer/components/Text"; import { + GradientHover, + HashContainer, + OpDetailsData, OpDetailsSection, OpDetailsTitle, - OpDetailsData, - HashContainer, - GradientHover, } from "~/renderer/drawers/OperationDetails/styledComponents"; -import { SplitAddress } from "~/renderer/components/OperationsList/AddressCell"; -import CopyWithFeedback from "~/renderer/components/CopyWithFeedback"; -import { useTimeRemaining } from "@ledgerhq/live-common/families/canton/react"; -import { dayFormat, useDateFormatter } from "~/renderer/hooks/useDateFormatter"; -import type { TransferProposalAction } from "./types"; -import { Divider } from "@ledgerhq/react-ui/index"; - -type PendingProposal = { - contract_id: string; - sender: string; - receiver: string; - amount: BigNumber; - memo: string; - expires_at_micros: number; - isExpired: boolean; -}; +import IconCheck from "~/renderer/icons/Check"; +import IconCross from "~/renderer/icons/Cross"; +import type { ProcessedProposal, TransferProposalAction } from "./types"; +import { + usePendingTransferProposalsDetailsViewModel, + type PendingTransferProposalsDetailsViewModel, +} from "./usePendingTransferProposalsDetailsViewModel"; export type PendingTransferProposalsDetailsProps = { onClose?: () => void; account: Account; - parentAccount: Account; - contractId: string; + proposal: ProcessedProposal | null; onOpenModal: (contractId: string, action: TransferProposalAction) => void; }; -const PendingTransferProposalsDetails: React.FC = ({ - account, - contractId, - onClose, - parentAccount, - onOpenModal, -}) => { +export function View({ + proposal, + unit, + dateFormatted, + timeRemaining, + handleAction, +}: PendingTransferProposalsDetailsViewModel) { const { t } = useTranslation(); - const unit = useAccountUnit(account); - - const cantonAccount = account as CantonAccount; - const proposal = useMemo(() => { - const pendingTransferProposals = cantonAccount?.cantonResources?.pendingTransferProposals || []; - const found = pendingTransferProposals.find(p => p.contract_id === contractId); - if (!found) return null; - - const now = Date.now(); - const isExpired = now > found.expires_at_micros / 1000; - return { - ...found, - isExpired, - amount: new BigNumber(found.amount), - }; - }, [cantonAccount, contractId]); - - const handleAcceptOffer = useCallback( - (contractId: string) => { - onOpenModal(contractId, "accept"); - // Close drawer after modal state is set - setTimeout(() => { - onClose?.(); - }, 0); - }, - [onClose, onOpenModal], - ); - - const handleRejectOffer = useCallback( - (contractId: string) => { - onOpenModal(contractId, "reject"); - // Close drawer after modal state is set - setTimeout(() => { - onClose?.(); - }, 0); - }, - [onClose, onOpenModal], - ); - - const handleWithdrawOffer = useCallback( - (contractId: string) => { - onOpenModal(contractId, "withdraw"); - // Close drawer after modal state is set - setTimeout(() => { - onClose?.(); - }, 0); - }, - [onClose, onOpenModal], - ); - - const formatDate = useDateFormatter(dayFormat); - const dateFormatted = useMemo( - () => formatDate(new Date((proposal?.expires_at_micros ?? 0) / 1000)), - [proposal?.expires_at_micros, formatDate], - ); - const timeRemaining = useTimeRemaining(proposal?.expires_at_micros, proposal?.isExpired); if (!proposal) { return ( @@ -114,7 +47,7 @@ const PendingTransferProposalsDetails: React.FC @@ -201,10 +134,10 @@ const PendingTransferProposalsDetails: React.FC - + - + @@ -218,11 +151,7 @@ const PendingTransferProposalsDetails: React.FC { - if (!proposal.isExpired) { - handleAcceptOffer(proposal.contract_id); - } - }} + onClick={() => handleAction("accept")} > {t("families.canton.pendingTransactions.accept")} @@ -238,7 +167,7 @@ const PendingTransferProposalsDetails: React.FC handleRejectOffer(proposal.contract_id)} + onClick={() => handleAction("reject")} > {t("families.canton.pendingTransactions.reject")} @@ -248,10 +177,10 @@ const PendingTransferProposalsDetails: React.FC ) : ( - @@ -259,6 +188,19 @@ const PendingTransferProposalsDetails: React.FC ); +} + +const PendingTransferProposalsDetails: React.FC = ({ + account, + proposal, + onClose, + onOpenModal, +}) => { + return ( + + ); }; export default PendingTransferProposalsDetails; diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/DeviceAppModal.test.tsx b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/DeviceAppModal.test.tsx new file mode 100644 index 000000000000..1d978191015e --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/DeviceAppModal.test.tsx @@ -0,0 +1,96 @@ +import React from "react"; +import { fireEvent, render, screen } from "tests/testSetup"; +import { View } from "../DeviceAppModal"; +import type { DeviceAppModalViewModel } from "../useDeviceAppModalViewModel"; + +const buildViewModel = (overrides?: Partial): DeviceAppModalViewModel => ({ + isOpen: true, + action: "accept", + onClose: jest.fn(), + confirmationState: "confirming", + error: null, + request: { appName: "Canton" }, + actionConnect: {} as DeviceAppModalViewModel["actionConnect"], + handleDeviceResult: jest.fn(), + handleRetry: jest.fn(), + ...overrides, +}); + +describe("DeviceAppModal View", () => { + beforeEach(() => { + if (!document.getElementById("modals")) { + const modalsContainer = document.createElement("div"); + modalsContainer.id = "modals"; + document.body.appendChild(modalsContainer); + } + }); + + afterEach(() => { + const modalsContainer = document.getElementById("modals"); + if (modalsContainer) { + document.body.removeChild(modalsContainer); + } + }); + + describe("when modal is closed", () => { + it("should not render when isOpen is false", () => { + render(); + + expect(screen.queryByTestId("canton-offer-action-modal")).not.toBeInTheDocument(); + }); + }); + + describe("when modal is open", () => { + it("should render modal container", () => { + render(); + + expect(screen.getByTestId("modal-container")).toBeInTheDocument(); + }); + + it("should call onClose when close button is clicked", () => { + const onClose = jest.fn(); + render(); + + fireEvent.click(screen.getByTestId("modal-close-button")); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); + + describe("confirmation states", () => { + it("should render success title and description when completed", () => { + render(); + + expect(screen.getByTestId("success-message-label")).toHaveTextContent("Offer Accepted"); + expect( + screen.getByText("The transfer offer has been successfully accepted."), + ).toBeInTheDocument(); + }); + + it("should render error message and retry option when in error state", () => { + const error = new Error("Test failure"); + const handleRetry = jest.fn(); + render(); + + expect(screen.getByText(/test failure/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + + it("should render processing text when confirming", () => { + render(); + + expect(screen.getByText(/processing accept/i)).toBeInTheDocument(); + }); + }); + + describe("action titles", () => { + it.each(["accept", "reject", "withdraw"] as const)( + "should render modal for %s action", + action => { + render(); + + expect(screen.getByTestId("modal-container")).toBeInTheDocument(); + }, + ); + }); +}); diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/PendingTransferProposalsDetails.test.tsx b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/PendingTransferProposalsDetails.test.tsx new file mode 100644 index 000000000000..26054b0cacec --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/PendingTransferProposalsDetails.test.tsx @@ -0,0 +1,242 @@ +import type { Unit } from "@ledgerhq/types-cryptoassets"; +import React from "react"; +import { fireEvent, render, screen } from "tests/testSetup"; +import { View } from "../PendingTransferProposalsDetails"; +import type { PendingTransferProposalsDetailsViewModel } from "../usePendingTransferProposalsDetailsViewModel"; +import { createProcessedProposal } from "./test-utils"; + +const unit: Unit = { code: "CANTON", magnitude: 38, name: "Canton" }; + +const buildViewModel = ( + overrides?: Partial, +): PendingTransferProposalsDetailsViewModel => ({ + proposal: createProcessedProposal(), + unit, + dateFormatted: "2025-06-15", + timeRemaining: "1h 00m 00s", + handleAction: jest.fn(), + ...overrides, +}); + +describe("PendingTransferProposalsDetails View", () => { + describe("when proposal exists", () => { + it("should render proposal details for incoming transaction", () => { + const proposal = createProcessedProposal({ + contractId: "contract-123", + sender: "sender-address", + receiver: "receiver-address", + isIncoming: true, + memo: "Test memo", + }); + + const { container } = render(); + + expect(screen.getByText("Amount")).toBeInTheDocument(); + expect(screen.getByText("From")).toBeInTheDocument(); + expect(screen.getByText("To")).toBeInTheDocument(); + expect(container.textContent).toContain("sender-address"); + expect(container.textContent).toContain("receiver-address"); + }); + + it("should render proposal details for outgoing transaction", () => { + const proposal = createProcessedProposal({ + contractId: "contract-789", + sender: "receiver-address", + receiver: "other-receiver", + isIncoming: false, + memo: "Outgoing memo", + }); + + const { container } = render(); + + expect(screen.getByText("Amount")).toBeInTheDocument(); + expect(container.textContent).toContain("receiver-address"); + expect(container.textContent).toContain("other-receiver"); + }); + + it("should display memo when present", () => { + const proposal = createProcessedProposal({ + contractId: "contract-123", + memo: "Test memo", + }); + + render(); + + expect(screen.getByText("Memo")).toBeInTheDocument(); + expect(screen.getByText("Test memo")).toBeInTheDocument(); + }); + + it("should not display memo section when memo is empty", () => { + const proposal = createProcessedProposal({ + contractId: "contract-456", + memo: "", + isIncoming: true, + isExpired: true, + }); + + render(); + + expect(screen.queryByText("Memo")).not.toBeInTheDocument(); + }); + + it("should display contract ID", () => { + const proposal = createProcessedProposal({ contractId: "contract-123" }); + + const { container } = render(); + + expect(container.textContent).toContain("contract-123"); + expect(screen.getAllByText("Copy").length).toBeGreaterThan(0); + }); + + it("should display expired status for expired proposals", () => { + const proposal = createProcessedProposal({ + contractId: "contract-456", + isExpired: true, + isIncoming: true, + }); + + render(); + + expect(screen.getByText("Expired")).toBeInTheDocument(); + }); + + it("should display time remaining when not expired", () => { + const proposal = createProcessedProposal({ + contractId: "contract-123", + isExpired: false, + }); + + render(); + + expect(screen.getByText("2h 30m 15s")).toBeInTheDocument(); + }); + + it("should not display time remaining section when expired", () => { + const proposal = createProcessedProposal({ + contractId: "contract-123", + isExpired: true, + }); + + render(); + + expect(screen.queryByText("Expires in")).not.toBeInTheDocument(); + }); + + it("should display formatted expiry date", () => { + render(); + + expect(screen.getByText("2025-12-25")).toBeInTheDocument(); + }); + }); + + describe("action buttons for incoming transactions", () => { + it("should show accept and reject buttons for incoming transaction", () => { + const proposal = createProcessedProposal({ + contractId: "contract-123", + isIncoming: true, + }); + + render(); + + expect(screen.getByText("Accept")).toBeInTheDocument(); + expect(screen.getByText("Reject")).toBeInTheDocument(); + }); + + it("should disable accept button for expired incoming transaction", () => { + const proposal = createProcessedProposal({ + contractId: "contract-456", + isIncoming: true, + isExpired: true, + }); + + render(); + + const acceptButton = screen.getByText("Accept").closest("button"); + expect(acceptButton).toBeDisabled(); + }); + + it("should call handleAction with accept when accept button is clicked", () => { + const handleAction = jest.fn(); + const proposal = createProcessedProposal({ + contractId: "contract-123", + isIncoming: true, + }); + + render(); + + const acceptButton = screen.getByText("Accept").closest("button"); + fireEvent.click(acceptButton!); + + expect(handleAction).toHaveBeenCalledWith("accept"); + }); + + it("should call handleAction with reject when reject button is clicked", () => { + const handleAction = jest.fn(); + const proposal = createProcessedProposal({ + contractId: "contract-123", + isIncoming: true, + }); + + render(); + + const rejectButton = screen.getByText("Reject").closest("button"); + fireEvent.click(rejectButton!); + + expect(handleAction).toHaveBeenCalledWith("reject"); + }); + + it("should not call handleAction when clicking disabled accept button on expired proposal", () => { + const handleAction = jest.fn(); + const proposal = createProcessedProposal({ + contractId: "contract-456", + isIncoming: true, + isExpired: true, + }); + + render(); + + const acceptButton = screen.getByText("Accept").closest("button"); + fireEvent.click(acceptButton!); + + expect(handleAction).not.toHaveBeenCalledWith("accept"); + }); + }); + + describe("action buttons for outgoing transactions", () => { + it("should show withdraw button for outgoing transaction", () => { + const proposal = createProcessedProposal({ + contractId: "contract-789", + isIncoming: false, + }); + + render(); + + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.queryByText("Accept")).not.toBeInTheDocument(); + expect(screen.queryByText("Reject")).not.toBeInTheDocument(); + }); + + it("should call handleAction with withdraw when cancel button is clicked", () => { + const handleAction = jest.fn(); + const proposal = createProcessedProposal({ + contractId: "contract-789", + isIncoming: false, + }); + + render(); + + const withdrawButton = screen.getByText("Cancel").closest("button"); + fireEvent.click(withdrawButton!); + + expect(handleAction).toHaveBeenCalledWith("withdraw"); + }); + }); + + describe("when proposal does not exist", () => { + it("should display not found message", () => { + render(); + + expect(screen.getByText("common.notFound")).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/index.test.tsx b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/index.test.tsx new file mode 100644 index 000000000000..f4bab74fb6f5 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/index.test.tsx @@ -0,0 +1,131 @@ +import type { Unit } from "@ledgerhq/types-cryptoassets"; +import React from "react"; +import { fireEvent, render, screen } from "tests/testSetup"; +import { View } from "../index"; +import type { GroupedProposals } from "../types"; +import type { PendingTransferProposalsViewModel } from "../usePendingTransferProposalsViewModel"; +import { createProcessedProposal } from "./test-utils"; + +const unit: Unit = { code: "CANTON", magnitude: 38, name: "Canton" }; + +const buildIncoming = ( + contractId = "contract-123", +): { grouped: GroupedProposals; count: number } => { + const proposal = createProcessedProposal({ contractId, isIncoming: true }); + return { grouped: [{ day: proposal.day, proposals: [proposal] }], count: 1 }; +}; + +const buildOutgoing = ( + contractId = "contract-456", +): { grouped: GroupedProposals; count: number } => { + const proposal = createProcessedProposal({ contractId, isIncoming: false }); + return { grouped: [{ day: proposal.day, proposals: [proposal] }], count: 1 }; +}; + +const buildViewModel = ( + overrides?: Partial, +): PendingTransferProposalsViewModel => ({ + groupedIncoming: [], + groupedOutgoing: [], + incomingCount: 0, + outgoingCount: 0, + modal: { isOpen: false, action: "accept", contractId: "" }, + unit, + appName: "Canton", + onRowClick: jest.fn(), + onOpenModal: jest.fn(), + onDeviceConfirm: jest.fn(), + onModalClose: jest.fn(), + ...overrides, +}); + +describe("PendingTransferProposals", () => { + it("renders nothing when there are no proposals", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + describe("incoming proposals", () => { + it("shows accept and reject buttons", () => { + const { grouped, count } = buildIncoming(); + + render(); + + expect(screen.getByRole("button", { name: /accept/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /reject/i })).toBeInTheDocument(); + }); + + it("calls onOpenModal with accept when accept button is clicked", () => { + const onOpenModal = jest.fn(); + const { grouped, count } = buildIncoming("contract-123"); + + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /accept/i })); + + expect(onOpenModal).toHaveBeenCalledWith("contract-123", "accept"); + }); + + it("calls onOpenModal with reject when reject button is clicked", () => { + const onOpenModal = jest.fn(); + const { grouped, count } = buildIncoming("contract-123"); + + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /reject/i })); + + expect(onOpenModal).toHaveBeenCalledWith("contract-123", "reject"); + }); + }); + + describe("outgoing proposals", () => { + it("shows cancel button", () => { + const { grouped, count } = buildOutgoing(); + + render(); + + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); + }); + + it("calls onOpenModal with withdraw when cancel button is clicked", () => { + const onOpenModal = jest.fn(); + const { grouped, count } = buildOutgoing("contract-456"); + + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + + expect(onOpenModal).toHaveBeenCalledWith("contract-456", "withdraw"); + }); + }); + + describe("both tables", () => { + it("renders incoming and outgoing tables simultaneously", () => { + const incoming = buildIncoming(); + const outgoing = buildOutgoing(); + + render( + , + ); + + expect(screen.getByRole("button", { name: /accept/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/test-utils.ts b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/test-utils.ts new file mode 100644 index 000000000000..81963fde2c30 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/test-utils.ts @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/consistent-type-assertions */ +import { BigNumber } from "bignumber.js"; +import type { ProcessedProposal, RawTransferProposal } from "../types"; +export { createMockAccount } from "../../__tests__/testUtils"; + +export const ACCOUNT_XPUB = "test-xpub"; + +export const createRawProposal = ( + contractId: string, + sender: string, + receiver: string, + overrides: Partial = {}, +): RawTransferProposal => ({ + contract_id: contractId, + sender, + receiver, + amount: "1000000", + instrument_id: "instrument-1", + instrument_admin: "", + update_id: "", + expires_at_micros: Date.now() * 1000 + 3600000000, + memo: "", + ...overrides, +}); + +export const createProcessedProposal = ( + overrides: Partial = {}, +): ProcessedProposal => { + const futureMicros = (Date.now() + 3600000) * 1000; + const expiresAt = new Date(futureMicros / 1000); + const day = new Date(expiresAt); + day.setUTCHours(0, 0, 0, 0); + return { + contractId: "contract-1", + sender: "sender-xpub", + receiver: ACCOUNT_XPUB, + amount: new BigNumber("1000000"), + instrumentId: "instrument-1", + memo: "", + expiresAtMicros: futureMicros, + isExpired: false, + isIncoming: true, + expiresAt, + day, + ...overrides, + }; +}; diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/transferProposals.test.ts b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/transferProposals.test.ts new file mode 100644 index 000000000000..9e720efa289a --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/transferProposals.test.ts @@ -0,0 +1,173 @@ +/* eslint-disable @typescript-eslint/consistent-type-assertions */ +import { BigNumber } from "bignumber.js"; +import type { ProcessedProposal, RawTransferProposal } from "../types"; +import { + groupByDay, + INSTRUCTION_TYPE_MAP, + processTransferProposals, +} from "../utils/transferProposals"; +import { ACCOUNT_XPUB, createRawProposal } from "./test-utils"; + +const OTHER_XPUB = "other-xpub"; + +describe("processTransferProposals", () => { + it("should return empty incoming and outgoing arrays when given no proposals", () => { + const result = processTransferProposals([], ACCOUNT_XPUB); + expect(result.incoming).toEqual([]); + expect(result.outgoing).toEqual([]); + }); + + it("should classify a proposal as incoming when sender differs from accountXpub", () => { + const raw = createRawProposal("contract-1", OTHER_XPUB, ACCOUNT_XPUB); + + const { incoming, outgoing } = processTransferProposals([raw], ACCOUNT_XPUB); + + expect(incoming).toHaveLength(1); + expect(outgoing).toHaveLength(0); + expect(incoming[0].isIncoming).toBe(true); + }); + + it("should classify a proposal as outgoing when sender matches accountXpub", () => { + const raw = createRawProposal("contract-2", ACCOUNT_XPUB, OTHER_XPUB); + + const { incoming, outgoing } = processTransferProposals([raw], ACCOUNT_XPUB); + + expect(outgoing).toHaveLength(1); + expect(incoming).toHaveLength(0); + expect(outgoing[0].isIncoming).toBe(false); + }); + + it("should correctly split a mixed list into incoming and outgoing", () => { + const proposals = [ + createRawProposal("in-1", OTHER_XPUB, ACCOUNT_XPUB), + createRawProposal("out-1", ACCOUNT_XPUB, OTHER_XPUB), + createRawProposal("in-2", OTHER_XPUB, ACCOUNT_XPUB), + ]; + + const { incoming, outgoing } = processTransferProposals(proposals, ACCOUNT_XPUB); + + expect(incoming).toHaveLength(2); + expect(outgoing).toHaveLength(1); + }); + + it("should mark a proposal as expired when its expiry timestamp is in the past", () => { + const pastMicros = (Date.now() - 10000) * 1000; + const raw = createRawProposal("expired-1", OTHER_XPUB, ACCOUNT_XPUB, { + expires_at_micros: pastMicros, + }); + + const { incoming } = processTransferProposals([raw], ACCOUNT_XPUB); + + expect(incoming[0].isExpired).toBe(true); + }); + + it("should mark a proposal as not expired when its expiry timestamp is in the future", () => { + const futureMicros = (Date.now() + 3600000) * 1000; + const raw = createRawProposal("active-1", OTHER_XPUB, ACCOUNT_XPUB, { + expires_at_micros: futureMicros, + }); + + const { incoming } = processTransferProposals([raw], ACCOUNT_XPUB); + + expect(incoming[0].isExpired).toBe(false); + }); + + it("should correctly map raw field names to the ProcessedProposal shape", () => { + const futureMicros = (Date.now() + 3600000) * 1000; + const raw = createRawProposal("contract-map", OTHER_XPUB, ACCOUNT_XPUB, { + expires_at_micros: futureMicros, + memo: "test memo", + }); + + const { incoming } = processTransferProposals([raw], ACCOUNT_XPUB); + const proposal = incoming[0]; + + expect(proposal.contractId).toBe("contract-map"); + expect(proposal.sender).toBe(OTHER_XPUB); + expect(proposal.receiver).toBe(ACCOUNT_XPUB); + expect(proposal.amount).toEqual(new BigNumber("1000000")); + expect(proposal.memo).toBe("test memo"); + expect(proposal.expiresAtMicros).toBe(futureMicros); + expect(proposal.expiresAt).toBeInstanceOf(Date); + expect(proposal.day).toBeInstanceOf(Date); + }); + + it("should default memo to an empty string when it is undefined", () => { + const raw = { + ...createRawProposal("no-memo", OTHER_XPUB, ACCOUNT_XPUB), + memo: undefined, + }; + + const { incoming } = processTransferProposals( + [raw as unknown as RawTransferProposal], + ACCOUNT_XPUB, + ); + + expect(incoming[0].memo).toBe(""); + }); +}); + +describe("groupByDay", () => { + const makeProposal = (contractId: string, expiresAt: Date): ProcessedProposal => { + const day = new Date(expiresAt); + day.setUTCHours(0, 0, 0, 0); + return { + contractId, + sender: OTHER_XPUB, + receiver: ACCOUNT_XPUB, + amount: new BigNumber("1000000"), + instrumentId: "instrument-1", + memo: "", + expiresAtMicros: expiresAt.getTime() * 1000, + isExpired: false, + isIncoming: true, + expiresAt, + day, + }; + }; + + it("should return an empty array when given no proposals", () => { + expect(groupByDay([])).toEqual([]); + }); + + it("should return a single group for proposals that all expire on the same day", () => { + const date = new Date("2025-06-15T10:00:00Z"); + const proposals = [ + makeProposal("contract-1", date), + makeProposal("contract-2", new Date("2025-06-15T14:00:00Z")), + ]; + + const result = groupByDay(proposals); + + expect(result).toHaveLength(1); + expect(result[0].proposals).toHaveLength(2); + }); + + it("should return multiple groups for proposals that expire on different days", () => { + const day1 = new Date("2025-06-15T10:00:00Z"); + const day2 = new Date("2025-06-16T10:00:00Z"); + const proposals = [makeProposal("contract-1", day1), makeProposal("contract-2", day2)]; + + const result = groupByDay(proposals); + + expect(result).toHaveLength(2); + }); + + it("should sort day groups in descending order so the latest day appears first", () => { + const earlier = new Date("2025-06-14T10:00:00Z"); + const later = new Date("2025-06-16T10:00:00Z"); + const proposals = [makeProposal("contract-old", earlier), makeProposal("contract-new", later)]; + + const result = groupByDay(proposals); + + expect(result[0].day.getTime()).toBeGreaterThan(result[1].day.getTime()); + }); +}); + +describe("INSTRUCTION_TYPE_MAP", () => { + it("should map every TransferProposalAction to the correct instruction type", () => { + expect(INSTRUCTION_TYPE_MAP.accept).toBe("accept-transfer-instruction"); + expect(INSTRUCTION_TYPE_MAP.reject).toBe("reject-transfer-instruction"); + expect(INSTRUCTION_TYPE_MAP.withdraw).toBe("withdraw-transfer-instruction"); + }); +}); diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/useDeviceAppModalViewModel.test.ts b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/useDeviceAppModalViewModel.test.ts new file mode 100644 index 000000000000..13b01321624b --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/useDeviceAppModalViewModel.test.ts @@ -0,0 +1,136 @@ +import { act, renderHook } from "tests/testSetup"; +import { useDeviceAppModalViewModel } from "../useDeviceAppModalViewModel"; + +describe("useDeviceAppModalViewModel", () => { + const mockOnConfirm = jest.fn(); + const mockOnClose = jest.fn(); + + const defaultInput = { + isOpen: false, + onConfirm: mockOnConfirm, + action: "accept" as const, + appName: "Canton", + onClose: mockOnClose, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should initialise with pending state and pass through input fields", () => { + const { result } = renderHook(() => useDeviceAppModalViewModel(defaultInput)); + + expect(result.current.confirmationState).toBe("pending"); + expect(result.current.error).toBeNull(); + expect(result.current.request).toEqual({ appName: "Canton" }); + expect(result.current.isOpen).toBe(false); + expect(result.current.action).toBe("accept"); + expect(result.current.onClose).toBe(mockOnClose); + }); + + it("should reset to pending when isOpen changes to true", () => { + const { result, rerender } = renderHook( + ({ isOpen }: { isOpen: boolean }) => useDeviceAppModalViewModel({ ...defaultInput, isOpen }), + { initialProps: { isOpen: false } }, + ); + + rerender({ isOpen: true }); + + expect(result.current.confirmationState).toBe("pending"); + expect(result.current.error).toBeNull(); + }); + + it("should transition to confirming then completed on successful confirm", async () => { + mockOnConfirm.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useDeviceAppModalViewModel({ ...defaultInput, isOpen: true }), + ); + + await act(async () => { + result.current.handleDeviceResult({ device: { deviceId: "device-1" } }); + }); + + expect(result.current.confirmationState).toBe("completed"); + expect(mockOnConfirm).toHaveBeenCalledWith("device-1"); + }); + + it("should transition to error on failed confirm", async () => { + const testError = new Error("Test failure"); + mockOnConfirm.mockRejectedValue(testError); + + const { result } = renderHook(() => + useDeviceAppModalViewModel({ ...defaultInput, isOpen: true }), + ); + + await act(async () => { + result.current.handleDeviceResult({ device: { deviceId: "device-1" } }); + }); + + expect(result.current.confirmationState).toBe("error"); + expect(result.current.error).toBe(testError); + }); + + it("should reset state on retry", async () => { + const testError = new Error("Test failure"); + mockOnConfirm.mockRejectedValue(testError); + + const { result } = renderHook(() => + useDeviceAppModalViewModel({ ...defaultInput, isOpen: true }), + ); + + await act(async () => { + result.current.handleDeviceResult({ device: { deviceId: "device-1" } }); + }); + + expect(result.current.confirmationState).toBe("error"); + + act(() => { + result.current.handleRetry(); + }); + + expect(result.current.confirmationState).toBe("pending"); + expect(result.current.error).toBeNull(); + }); + + it("should fall back to wired/ble connection type when deviceId is empty", async () => { + mockOnConfirm.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useDeviceAppModalViewModel({ ...defaultInput, isOpen: true }), + ); + + await act(async () => { + result.current.handleDeviceResult({ device: { deviceId: "", wired: true } }); + }); + + expect(mockOnConfirm).toHaveBeenCalledWith("USB"); + }); + + it("should fall back to BLE when deviceId is empty and not wired", async () => { + mockOnConfirm.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useDeviceAppModalViewModel({ ...defaultInput, isOpen: true }), + ); + + await act(async () => { + result.current.handleDeviceResult({ device: { deviceId: "", wired: false } }); + }); + + expect(mockOnConfirm).toHaveBeenCalledWith("BLE"); + }); + + it("should not call onConfirm when device is undefined in result", async () => { + const { result } = renderHook(() => + useDeviceAppModalViewModel({ ...defaultInput, isOpen: true }), + ); + + await act(async () => { + result.current.handleDeviceResult({}); + }); + + expect(mockOnConfirm).not.toHaveBeenCalled(); + expect(result.current.confirmationState).toBe("pending"); + }); +}); diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/usePendingTransferProposalsDetailsViewModel.test.ts b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/usePendingTransferProposalsDetailsViewModel.test.ts new file mode 100644 index 000000000000..c8963c795831 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/usePendingTransferProposalsDetailsViewModel.test.ts @@ -0,0 +1,121 @@ +import { act, renderHook } from "tests/testSetup"; +import { createMockAccount, createProcessedProposal } from "./test-utils"; +import { usePendingTransferProposalsDetailsViewModel } from "../usePendingTransferProposalsDetailsViewModel"; + +describe("usePendingTransferProposalsDetailsViewModel", () => { + const account = createMockAccount(); + const mockOnOpenModal = jest.fn(); + const mockOnClose = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const renderViewModel = (proposal = createProcessedProposal()) => + renderHook(() => + usePendingTransferProposalsDetailsViewModel({ + account, + proposal, + onOpenModal: mockOnOpenModal, + onClose: mockOnClose, + }), + ); + + it("should return the unit derived from the account", () => { + const { result } = renderViewModel(); + + expect(result.current.unit).toBeDefined(); + expect(result.current.unit.code).toBe("CANTON"); + }); + + it("should return the proposal as a pass-through", () => { + const proposal = createProcessedProposal({ contractId: "contract-pass" }); + const { result } = renderViewModel(proposal); + + expect(result.current.proposal).toBe(proposal); + }); + + it("should return null proposal when input is null", () => { + const { result } = renderHook(() => + usePendingTransferProposalsDetailsViewModel({ + account, + proposal: null, + onOpenModal: mockOnOpenModal, + onClose: mockOnClose, + }), + ); + + expect(result.current.proposal).toBeNull(); + }); + + it("should return an empty string for timeRemaining when proposal is expired", () => { + const expired = createProcessedProposal({ isExpired: true }); + const { result } = renderViewModel(expired); + + expect(result.current.timeRemaining).toBe(""); + }); + + it("should return an empty string for timeRemaining when proposal is null", () => { + const { result } = renderHook(() => + usePendingTransferProposalsDetailsViewModel({ + account, + proposal: null, + onOpenModal: mockOnOpenModal, + onClose: mockOnClose, + }), + ); + + expect(result.current.timeRemaining).toBe(""); + }); + + it("should call onOpenModal with the proposal contractId and the given action", () => { + const proposal = createProcessedProposal({ contractId: "contract-xyz" }); + const { result } = renderViewModel(proposal); + + act(() => { + result.current.handleAction("accept"); + }); + + expect(mockOnOpenModal).toHaveBeenCalledWith("contract-xyz", "accept"); + }); + + it("should call onOpenModal with the reject action", () => { + const proposal = createProcessedProposal({ contractId: "contract-xyz" }); + const { result } = renderViewModel(proposal); + + act(() => { + result.current.handleAction("reject"); + }); + + expect(mockOnOpenModal).toHaveBeenCalledWith("contract-xyz", "reject"); + }); + + it("should call onClose after opening modal", () => { + const proposal = createProcessedProposal({ contractId: "contract-123" }); + const { result } = renderViewModel(proposal); + + act(() => { + result.current.handleAction("accept"); + }); + + expect(mockOnOpenModal).toHaveBeenCalledWith("contract-123", "accept"); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it("should not call onOpenModal when proposal is null", () => { + const { result } = renderHook(() => + usePendingTransferProposalsDetailsViewModel({ + account, + proposal: null, + onOpenModal: mockOnOpenModal, + onClose: mockOnClose, + }), + ); + + act(() => { + result.current.handleAction("withdraw"); + }); + + expect(mockOnOpenModal).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/usePendingTransferProposalsViewModel.test.ts b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/usePendingTransferProposalsViewModel.test.ts new file mode 100644 index 000000000000..79abc5e688db --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/usePendingTransferProposalsViewModel.test.ts @@ -0,0 +1,280 @@ +/* eslint-disable @typescript-eslint/consistent-type-assertions */ +import { TopologyChangeError } from "@ledgerhq/coin-canton"; +import { useBridgeSync } from "@ledgerhq/live-common/bridge/react/index"; +import { useCantonAcceptOrRejectOffer } from "@ledgerhq/live-common/families/canton/react"; +import { CantonAccount } from "@ledgerhq/live-common/families/canton/types"; +import { act, renderHook, waitFor } from "tests/testSetup"; +import { State } from "~/renderer/reducers"; +import { INITIAL_STATE as SETTINGS_INITIAL_STATE } from "~/renderer/reducers/settings"; +import { createMockAccount } from "../../__tests__/testUtils"; +import { handleTopologyChangeError } from "../../hooks/topologyChangeError"; +import { createMockDevice } from "../../OnboardModal/__tests__/testUtils"; +import { usePendingTransferProposalsViewModel } from "../usePendingTransferProposalsViewModel"; + +jest.mock("@ledgerhq/live-common/families/canton/react"); +jest.mock("@ledgerhq/live-common/bridge/react/index"); +jest.mock("../../hooks/topologyChangeError", () => ({ + __esModule: true, + handleTopologyChangeError: jest.fn(), + TopologyChangeError: jest.requireActual("@ledgerhq/coin-canton").TopologyChangeError, +})); + +const mockUseCantonAcceptOrRejectOffer = jest.mocked(useCantonAcceptOrRejectOffer); +const mockUseBridgeSync = jest.mocked(useBridgeSync); +const mockHandleTopologyChangeError = jest.mocked(handleTopologyChangeError); + +const mockSync = jest.fn(); +const mockPerformTransferInstruction = jest.fn(); + +const createAccountWithProposal = ( + contractId: string, + sender: string, + receiver: string, + overrides?: Partial, +) => + createMockAccount({ + xpub: "test-xpub", + cantonResources: { + isOnboarded: true, + instrumentUtxoCounts: {}, + pendingTransferProposals: [ + { + contract_id: contractId, + sender, + receiver, + amount: "1000000", + memo: "Test memo", + expires_at_micros: Date.now() * 1000 + 3600000000, + ...overrides, + }, + ], + }, + } as Partial); + +describe("usePendingTransferProposalsViewModel", () => { + const mockAccount = createAccountWithProposal("contract-123", "sender-address", "test-xpub"); + const mockDevice = createMockDevice({ deviceId: "device-1" }); + + const buildInitialState: (overrides?: Partial) => Partial = (overrides = {}) => ({ + settings: { ...SETTINGS_INITIAL_STATE }, + devices: { + currentDevice: mockDevice, + devices: [mockDevice], + }, + ...overrides, + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseCantonAcceptOrRejectOffer.mockReturnValue(mockPerformTransferInstruction); + mockUseBridgeSync.mockReturnValue(mockSync); + mockHandleTopologyChangeError.mockReturnValue(true); + }); + + it("should sync account after successful action", async () => { + mockPerformTransferInstruction.mockResolvedValue(undefined); + + const { result } = renderHook( + () => usePendingTransferProposalsViewModel(mockAccount, mockAccount), + { initialState: buildInitialState() }, + ); + + act(() => { + result.current.onOpenModal("contract-123", "accept"); + }); + + await act(async () => { + await result.current.onDeviceConfirm("device-id"); + }); + + await waitFor(() => { + expect(mockSync).toHaveBeenCalledWith({ + type: "SYNC_ONE_ACCOUNT", + accountId: mockAccount.id, + priority: 10, + reason: "canton-pending-transaction-action", + }); + }); + expect(mockHandleTopologyChangeError).not.toHaveBeenCalled(); + }); + + describe("TopologyChangeError", () => { + it.each([ + ["accept", "contract-123", mockAccount], + ["reject", "contract-123", mockAccount], + [ + "withdraw", + "contract-456", + createAccountWithProposal("contract-456", "test-xpub", "receiver-address", { + amount: "2000000", + memo: "", + }), + ], + ])("should handle during %s action", async (action, contractId, account) => { + mockPerformTransferInstruction.mockRejectedValue( + new TopologyChangeError("Topology change detected"), + ); + + const { result } = renderHook( + () => usePendingTransferProposalsViewModel(account, mockAccount), + { initialState: buildInitialState() }, + ); + + act(() => { + result.current.onOpenModal(contractId, action as "accept" | "reject" | "withdraw"); + }); + + await act(async () => { + await result.current.onDeviceConfirm("device-id"); + }); + + await waitFor(() => { + expect(mockHandleTopologyChangeError).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + currency: mockAccount.currency, + device: mockDevice, + accounts: [], + mainAccount: mockAccount, + navigationSnapshot: expect.objectContaining({ + type: "transfer-proposal", + handler: expect.any(Function), + props: expect.objectContaining({ + action, + contractId, + }), + }), + }), + ); + }); + expect(mockSync).not.toHaveBeenCalled(); + }); + + it("should not call handleTopologyChangeError when device is missing", async () => { + mockPerformTransferInstruction.mockRejectedValue( + new TopologyChangeError("Topology change detected"), + ); + + const { result } = renderHook( + () => usePendingTransferProposalsViewModel(mockAccount, mockAccount), + { + initialState: buildInitialState({ + devices: { + currentDevice: null, + devices: [], + }, + }), + }, + ); + + act(() => { + result.current.onOpenModal("contract-123", "accept"); + }); + + await act(async () => { + await result.current.onDeviceConfirm("device-id"); + }); + + await waitFor(() => { + expect(mockHandleTopologyChangeError).not.toHaveBeenCalled(); + }); + }); + }); + + describe("proposal processing", () => { + it("should categorise incoming and outgoing proposals correctly", () => { + const accountWithBoth = createMockAccount({ + xpub: "test-xpub", + cantonResources: { + isOnboarded: true, + instrumentUtxoCounts: {}, + pendingTransferProposals: [ + { + contract_id: "incoming-1", + sender: "other-party", + receiver: "test-xpub", + amount: "500000", + memo: "", + expires_at_micros: Date.now() * 1000 + 3600000000, + }, + { + contract_id: "outgoing-1", + sender: "test-xpub", + receiver: "other-party", + amount: "750000", + memo: "", + expires_at_micros: Date.now() * 1000 + 7200000000, + }, + ], + }, + } as Partial); + + const { result } = renderHook( + () => usePendingTransferProposalsViewModel(accountWithBoth, accountWithBoth), + { initialState: buildInitialState() }, + ); + + expect(result.current.incomingCount).toBe(1); + expect(result.current.outgoingCount).toBe(1); + }); + + it("should return zero counts for empty proposals", () => { + const emptyAccount = createMockAccount({ + xpub: "test-xpub", + cantonResources: { + isOnboarded: false, + instrumentUtxoCounts: {}, + pendingTransferProposals: [], + }, + } as Partial); + + const { result } = renderHook( + () => usePendingTransferProposalsViewModel(emptyAccount, emptyAccount), + { initialState: buildInitialState() }, + ); + + expect(result.current.incomingCount).toBe(0); + expect(result.current.outgoingCount).toBe(0); + }); + }); + + describe("modal state", () => { + it("should open modal with correct action and contractId", () => { + const { result } = renderHook( + () => usePendingTransferProposalsViewModel(mockAccount, mockAccount), + { initialState: buildInitialState() }, + ); + + expect(result.current.modal.isOpen).toBe(false); + + act(() => { + result.current.onOpenModal("contract-123", "reject"); + }); + + expect(result.current.modal).toEqual({ + isOpen: true, + action: "reject", + contractId: "contract-123", + }); + }); + + it("should close modal when onModalClose is called", () => { + const { result } = renderHook( + () => usePendingTransferProposalsViewModel(mockAccount, mockAccount), + { initialState: buildInitialState() }, + ); + + act(() => { + result.current.onOpenModal("contract-123", "accept"); + }); + + expect(result.current.modal.isOpen).toBe(true); + + act(() => { + result.current.onModalClose(); + }); + + expect(result.current.modal.isOpen).toBe(false); + }); + }); +}); diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/useProposalRowViewModel.test.ts b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/useProposalRowViewModel.test.ts new file mode 100644 index 000000000000..f1d07c23c8b9 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/__tests__/useProposalRowViewModel.test.ts @@ -0,0 +1,142 @@ +import type { Unit } from "@ledgerhq/types-cryptoassets"; +import { renderHook, act } from "tests/testSetup"; +import { BigNumber } from "bignumber.js"; +import { useProposalRowViewModel } from "../components/useProposalRowViewModel"; +import { createProcessedProposal } from "./test-utils"; + +const unit: Unit = { code: "CANTON", magnitude: 38, name: "Canton" }; + +describe("useProposalRowViewModel", () => { + const mockOnRowClick = jest.fn(); + const mockOnOpenModal = jest.fn(); + + const renderVM = (proposal = createProcessedProposal()) => + renderHook(() => + useProposalRowViewModel({ + proposal, + unit, + onRowClick: mockOnRowClick, + onOpenModal: mockOnOpenModal, + }), + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should pass through proposal and unit", () => { + const proposal = createProcessedProposal({ contractId: "contract-pass" }); + const { result } = renderVM(proposal); + + expect(result.current.proposal).toBe(proposal); + expect(result.current.unit).toBe(unit); + }); + + it("should resolve addressToShow to sender for incoming proposals", () => { + const proposal = createProcessedProposal({ + isIncoming: true, + sender: "sender-xpub", + receiver: "receiver-xpub", + }); + + const { result } = renderVM(proposal); + + expect(result.current.addressToShow).toBe("sender-xpub"); + }); + + it("should resolve addressToShow to receiver for outgoing proposals", () => { + const proposal = createProcessedProposal({ + isIncoming: false, + sender: "sender-xpub", + receiver: "receiver-xpub", + }); + + const { result } = renderVM(proposal); + + expect(result.current.addressToShow).toBe("receiver-xpub"); + }); + + it("should return positive amountValue for incoming proposals", () => { + const proposal = createProcessedProposal({ + isIncoming: true, + amount: new BigNumber("1000000"), + }); + + const { result } = renderVM(proposal); + + expect(result.current.amountValue.isPositive()).toBe(true); + }); + + it("should return negated amountValue for outgoing proposals", () => { + const proposal = createProcessedProposal({ + isIncoming: false, + amount: new BigNumber("1000000"), + }); + + const { result } = renderVM(proposal); + + expect(result.current.amountValue.isNegative()).toBe(true); + }); + + it("should call onRowClick with contractId on handleRowClick", () => { + const proposal = createProcessedProposal({ contractId: "contract-abc" }); + + const { result } = renderVM(proposal); + + act(() => { + result.current.handleRowClick(); + }); + + expect(mockOnRowClick).toHaveBeenCalledWith("contract-abc"); + }); + + it("should call onOpenModal with accept on handleAcceptClick when not expired", () => { + const proposal = createProcessedProposal({ + contractId: "contract-abc", + isExpired: false, + }); + const mockEvent = { stopPropagation: jest.fn() } as unknown as React.MouseEvent; + + const { result } = renderVM(proposal); + + act(() => { + result.current.handleAcceptClick(mockEvent); + }); + + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + expect(mockOnOpenModal).toHaveBeenCalledWith("contract-abc", "accept"); + }); + + it("should not call onOpenModal on handleAcceptClick when expired", () => { + const proposal = createProcessedProposal({ + contractId: "contract-abc", + isExpired: true, + }); + const mockEvent = { stopPropagation: jest.fn() } as unknown as React.MouseEvent; + + const { result } = renderVM(proposal); + + act(() => { + result.current.handleAcceptClick(mockEvent); + }); + + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + expect(mockOnOpenModal).not.toHaveBeenCalled(); + }); + + it("should call onOpenModal with withdraw on handleWithdrawClick", () => { + const proposal = createProcessedProposal({ + contractId: "contract-abc", + isIncoming: false, + }); + const mockEvent = { stopPropagation: jest.fn() } as unknown as React.MouseEvent; + + const { result } = renderVM(proposal); + + act(() => { + result.current.handleWithdrawClick(mockEvent); + }); + + expect(mockOnOpenModal).toHaveBeenCalledWith("contract-abc", "withdraw"); + }); +}); diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/components/ProposalRow.tsx b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/components/ProposalRow.tsx new file mode 100644 index 000000000000..cf4f424ba248 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/components/ProposalRow.tsx @@ -0,0 +1,184 @@ +import React, { memo } from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import Box from "~/renderer/components/Box"; +import Button from "~/renderer/components/Button"; +import FormattedVal from "~/renderer/components/FormattedVal"; +import { Address } from "~/renderer/components/OperationsList/AddressCell"; +import OperationDate from "~/renderer/components/OperationsList/OperationDate"; +import { TableRow as BaseTableRow } from "~/renderer/components/TableContainer"; +import Text from "~/renderer/components/Text"; +import Tooltip from "~/renderer/components/Tooltip"; +import IconCross from "~/renderer/icons/Cross"; +import IconReceive from "~/renderer/icons/Receive"; +import IconSend from "~/renderer/icons/Send"; +import { + useProposalRowViewModel, + type ProposalRowProps, + type ProposalRowViewModel, +} from "./useProposalRowViewModel"; + +const TableRow = styled(BaseTableRow)` + border-bottom: 1px solid ${p => p.theme.colors.neutral.c40}; +`; + +const MonospaceText = styled(Text)` + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; + letter-spacing: 0; +`; + +type CountdownProps = { + expiresAt: Date; +}; + +const CountdownDisplay: React.FC = ({ expiresAt }) => ( + + + +); + +type ExpiresInDisplayProps = { + timeRemaining: string; + isExpired: boolean; +}; + +const ExpiresInDisplay: React.FC = ({ timeRemaining, isExpired }) => { + const { t } = useTranslation(); + + if (isExpired) { + return ( + + {t("families.canton.pendingTransactions.expired")} + + ); + } + + return ( + + {timeRemaining || "-"} + + ); +}; + +export function View({ + proposal, + unit, + timeRemaining, + addressToShow, + amountValue, + handleAcceptClick, + handleRejectClick, + handleWithdrawClick, + handleRowClick, +}: ProposalRowViewModel) { + const { t } = useTranslation(); + const { isIncoming, isExpired, expiresAt } = proposal; + + return ( + + + + + {isExpired ? ( + + + + + + ) : isIncoming ? ( + + + + ) : ( + + + + )} + + + + + + +
+ + + + + + + + + + + + + {isIncoming ? ( + <> + {!isExpired && ( + + )} + + + ) : ( + + )} + + + + ); +} + +function ProposalRow({ proposal, unit, onRowClick, onOpenModal }: ProposalRowProps) { + return ; +} + +export default memo(ProposalRow); diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/components/ProposalsTable.tsx b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/components/ProposalsTable.tsx new file mode 100644 index 000000000000..ee7f4fc76ece --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/components/ProposalsTable.tsx @@ -0,0 +1,127 @@ +import { Unit } from "@ledgerhq/types-cryptoassets"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import Box from "~/renderer/components/Box"; +import SectionTitle from "~/renderer/components/OperationsList/SectionTitle"; +import TableContainer, { TableHeader } from "~/renderer/components/TableContainer"; +import Text from "~/renderer/components/Text"; +import type { GroupedProposals, TransferProposalAction } from "../types"; +import ProposalRow from "./ProposalRow"; + +const TableHeaderRow = styled(Box)` + border-bottom: 1px solid ${p => p.theme.colors.neutral.c40}; + background-color: ${p => p.theme.colors.background.main}; + padding: 12px 0; +`; + +type ProposalsTableProps = { + proposals: GroupedProposals; + count: number; + titleKey: string; + isIncomingTable: boolean; + unit: Unit; + onRowClick: (contractId: string) => void; + onOpenModal: (contractId: string, action: TransferProposalAction) => void; +}; + +function ProposalsTable({ + proposals, + count, + titleKey, + isIncomingTable, + unit, + onRowClick, + onOpenModal, +}: ProposalsTableProps) { + const { t } = useTranslation(); + + if (count === 0) return null; + + return ( + + + + + + + + {t("families.canton.pendingTransactions.date")} + + + + + + {isIncomingTable + ? t("families.canton.pendingTransactions.from") + : t("families.canton.pendingTransactions.to")} + + + + + {t("families.canton.pendingTransactions.expiresIn")} + + + + + {t("families.canton.pendingTransactions.amount")} + + + + + {t("families.canton.pendingTransactions.action")} + + + + + {proposals.map(group => ( + + + + {group.proposals.map(proposal => ( + + ))} + + + ))} + + ); +} + +export default ProposalsTable; diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/components/useProposalRowViewModel.ts b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/components/useProposalRowViewModel.ts new file mode 100644 index 000000000000..bd0bfcaf327c --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/components/useProposalRowViewModel.ts @@ -0,0 +1,80 @@ +import type { Unit } from "@ledgerhq/types-cryptoassets"; +import { useTimeRemaining } from "@ledgerhq/live-common/families/canton/react"; +import { BigNumber } from "bignumber.js"; +import { useCallback } from "react"; +import type { ProcessedProposal, TransferProposalAction } from "../types"; + +export type ProposalRowProps = { + proposal: ProcessedProposal; + unit: Unit; + onRowClick: (contractId: string) => void; + onOpenModal: (contractId: string, action: TransferProposalAction) => void; +}; + +export type ProposalRowViewModel = { + proposal: ProcessedProposal; + unit: Unit; + timeRemaining: string; + addressToShow: string; + amountValue: BigNumber; + handleAcceptClick: (e: React.MouseEvent) => void; + handleRejectClick: (e: React.MouseEvent) => void; + handleWithdrawClick: (e: React.MouseEvent) => void; + handleRowClick: () => void; +}; + +export function useProposalRowViewModel({ + proposal, + unit, + onRowClick, + onOpenModal, +}: ProposalRowProps): ProposalRowViewModel { + const { isIncoming, isExpired, contractId, sender, receiver, amount } = proposal; + + const timeRemaining = useTimeRemaining(proposal.expiresAtMicros, isExpired); + + const addressToShow = isIncoming ? sender : receiver; + const amountValue = isIncoming ? amount : amount.negated(); + + const handleRowClick = useCallback(() => { + onRowClick(contractId); + }, [onRowClick, contractId]); + + const handleAcceptClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (!isExpired) { + onOpenModal(contractId, "accept"); + } + }, + [contractId, isExpired, onOpenModal], + ); + + const handleRejectClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onOpenModal(contractId, "reject"); + }, + [contractId, onOpenModal], + ); + + const handleWithdrawClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onOpenModal(contractId, "withdraw"); + }, + [contractId, onOpenModal], + ); + + return { + proposal, + unit, + timeRemaining, + addressToShow, + amountValue, + handleAcceptClick, + handleRejectClick, + handleWithdrawClick, + handleRowClick, + }; +} diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/index.test.tsx b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/index.test.tsx deleted file mode 100644 index 60dd2fcb0038..000000000000 --- a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/index.test.tsx +++ /dev/null @@ -1,231 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions */ -import { TopologyChangeError } from "@ledgerhq/coin-canton"; -import { useBridgeSync } from "@ledgerhq/live-common/bridge/react/index"; -import { useCantonAcceptOrRejectOffer } from "@ledgerhq/live-common/families/canton/react"; -import { CantonAccount } from "@ledgerhq/live-common/families/canton/types"; -import React from "react"; -import { fireEvent, render, screen, waitFor } from "tests/testSetup"; -import { State } from "~/renderer/reducers"; -import { getCurrentDevice } from "~/renderer/reducers/devices"; -import { INITIAL_STATE as SETTINGS_INITIAL_STATE } from "~/renderer/reducers/settings"; -import { createMockAccount } from "../__tests__/testUtils"; -import { handleTopologyChangeError } from "../hooks/topologyChangeError"; -import { createMockDevice } from "../OnboardModal/__tests__/testUtils"; -import PendingTransferProposals from "./index"; - -const mockDispatch = jest.fn(); -jest.mock("LLD/hooks/redux", () => { - const actual = jest.requireActual("LLD/hooks/redux"); - return { - ...actual, - useDispatch: () => mockDispatch, - }; -}); -jest.mock("react-i18next", () => ({ - ...jest.requireActual("react-i18next"), - useTranslation: () => ({ - t: (key: string) => key, - }), - Trans: ({ i18nKey }: any) => {i18nKey}, -})); -jest.mock("@ledgerhq/live-common/families/canton/react"); -jest.mock("@ledgerhq/live-common/bridge/react/index"); -jest.mock("~/renderer/reducers/devices", () => { - const actual = jest.requireActual("~/renderer/reducers/devices"); - return { - __esModule: true, - ...actual, - getCurrentDevice: jest.fn(), - }; -}); -jest.mock("../hooks/topologyChangeError", () => ({ - __esModule: true, - handleTopologyChangeError: jest.fn(), - TopologyChangeError: jest.requireActual("@ledgerhq/coin-canton").TopologyChangeError, -})); -jest.mock("./DeviceAppModal", () => ({ - __esModule: true, - default: ({ isOpen, onConfirm, onClose }: any) => - isOpen ? ( -
- - -
- ) : null, -})); - -jest.mock("~/renderer/hooks/useAccountUnit", () => ({ - useAccountUnit: jest.fn(() => ({ code: "AMU", magnitude: 18, name: "Amulet" })), -})); -jest.mock("~/renderer/components/OperationsList/AddressCell", () => ({ - __esModule: true, - default: () =>
, - splitAddress: (value: string) => ({ left: value.slice(0, 5), right: value.slice(5) }), - SplitAddress: ({ value }: { value: string }) => ( - {value} - ), - Address: ({ value }: { value: string }) => {value}, - Cell: ({ children }: { children: React.ReactNode }) =>
{children}
, -})); -jest.mock("./PendingTransferProposalsDetails", () => ({ - __esModule: true, - default: ({ account, contractId, onOpenModal }: any) => ( -
-
Account: {account.id}
-
Contract: {contractId}
- - - -
- ), -})); - -const mockUseCantonAcceptOrRejectOffer = useCantonAcceptOrRejectOffer as jest.MockedFunction< - typeof useCantonAcceptOrRejectOffer ->; -const mockUseBridgeSync = useBridgeSync as jest.MockedFunction; -const mockGetCurrentDevice = getCurrentDevice as jest.MockedFunction; -const mockHandleTopologyChangeError = handleTopologyChangeError as jest.MockedFunction< - typeof handleTopologyChangeError ->; - -const mockSync = jest.fn(); -const mockPerformTransferInstruction = jest.fn(); - -const createAccountWithProposal = ( - contractId: string, - sender: string, - receiver: string, - overrides?: Partial, -) => - createMockAccount({ - xpub: "test-xpub", - cantonResources: { - pendingTransferProposals: [ - { - contract_id: contractId, - sender, - receiver, - amount: "1000000", - memo: "Test memo", - expires_at_micros: Date.now() * 1000 + 3600000000, - ...overrides, - }, - ], - }, - } as Partial); - -describe("PendingTransferProposals", () => { - const mockAccount = createAccountWithProposal("contract-123", "sender-address", "test-xpub"); - const mockDevice = createMockDevice({ deviceId: "device-1" }); - const buildInitialState: (overrides?: Partial) => Partial = (overrides = {}) => ({ - settings: { ...SETTINGS_INITIAL_STATE }, - devices: { - currentDevice: mockDevice, - devices: [mockDevice], - }, - ...overrides, - }); - - beforeEach(() => { - jest.clearAllMocks(); - mockUseCantonAcceptOrRejectOffer.mockReturnValue(mockPerformTransferInstruction); - mockUseBridgeSync.mockReturnValue(mockSync); - mockGetCurrentDevice.mockReturnValue(mockDevice); - mockHandleTopologyChangeError.mockReturnValue(true); - }); - - it("should sync account after successful action", async () => { - mockPerformTransferInstruction.mockResolvedValue(undefined); - - render(, { - initialState: buildInitialState(), - }); - fireEvent.click(screen.getByText("families.canton.pendingTransactions.accept")); - fireEvent.click(await screen.findByTestId("modal-confirm")); - - await waitFor(() => { - expect(mockSync).toHaveBeenCalledWith({ - type: "SYNC_ONE_ACCOUNT", - accountId: mockAccount.id, - priority: 10, - reason: "canton-pending-transaction-action", - }); - }); - expect(mockHandleTopologyChangeError).not.toHaveBeenCalled(); - }); - - describe("TopologyChangeError", () => { - it.each([ - ["accept", "contract-123", mockAccount], - ["reject", "contract-123", mockAccount], - [ - "withdraw", - "contract-456", - createAccountWithProposal("contract-456", "test-xpub", "receiver-address", { - amount: "2000000", - memo: "", - }), - ], - ])("should handle during %s action", async (action, contractId, account) => { - mockPerformTransferInstruction.mockRejectedValue( - new TopologyChangeError("Topology change detected"), - ); - - render(, { - initialState: buildInitialState(), - }); - const buttonText = - action === "withdraw" ? "common.cancel" : `families.canton.pendingTransactions.${action}`; - fireEvent.click(screen.getByText(buttonText)); - fireEvent.click(await screen.findByTestId("modal-confirm")); - - await waitFor(() => { - expect(mockHandleTopologyChangeError).toHaveBeenCalledWith( - mockDispatch, - expect.objectContaining({ - currency: mockAccount.currency, - device: mockDevice, - accounts: [], - mainAccount: mockAccount, - navigationSnapshot: expect.objectContaining({ - type: "transfer-proposal", - handler: expect.any(Function), - props: expect.objectContaining({ - action, - contractId, - }), - }), - }), - ); - }); - expect(mockSync).not.toHaveBeenCalled(); - }); - - it("should not call handleTopologyChangeError when device is missing", async () => { - mockPerformTransferInstruction.mockRejectedValue( - new TopologyChangeError("Topology change detected"), - ); - mockGetCurrentDevice.mockReturnValue(null); - - render(, { - initialState: buildInitialState({ - devices: { - currentDevice: null, - devices: [], - }, - }), - }); - fireEvent.click(screen.getByText("families.canton.pendingTransactions.accept")); - fireEvent.click(await screen.findByTestId("modal-confirm")); - - await waitFor(() => { - expect(mockHandleTopologyChangeError).not.toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/index.tsx b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/index.tsx index 691455c185ad..de10397d99eb 100644 --- a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/index.tsx +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/index.tsx @@ -1,153 +1,30 @@ -import React, { memo, useCallback, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useDispatch, useSelector } from "LLD/hooks/redux"; - -import styled from "styled-components"; -import { isCantonAccount } from "@ledgerhq/coin-canton"; +import React from "react"; import { Account } from "@ledgerhq/types-live"; -import { BigNumber } from "bignumber.js"; -import Box from "~/renderer/components/Box"; -import Text from "~/renderer/components/Text"; -import Button from "~/renderer/components/Button"; -import TableContainer, { - TableRow as BaseTableRow, - TableHeader, -} from "~/renderer/components/TableContainer"; -import { useAccountUnit } from "~/renderer/hooks/useAccountUnit"; -import FormattedVal from "~/renderer/components/FormattedVal"; -import PendingTransferProposalsDetails from "./PendingTransferProposalsDetails"; -import { setDrawer } from "~/renderer/drawers/Provider"; -import SectionTitle from "~/renderer/components/OperationsList/SectionTitle"; -import OperationDate from "~/renderer/components/OperationsList/OperationDate"; -import { Address } from "~/renderer/components/OperationsList/AddressCell"; -import IconReceive from "~/renderer/icons/Receive"; -import IconSend from "~/renderer/icons/Send"; -import IconCross from "~/renderer/icons/Cross"; import DeviceAppModal from "./DeviceAppModal"; -import Tooltip from "~/renderer/components/Tooltip"; +import ProposalsTable from "./components/ProposalsTable"; import { - useCantonAcceptOrRejectOffer, - useTimeRemaining, - type TransferInstructionType, -} from "@ledgerhq/live-common/families/canton/react"; -import { useBridgeSync } from "@ledgerhq/live-common/bridge/react/index"; -import { getCurrentDevice } from "~/renderer/reducers/devices"; -import { handleTopologyChangeError, TopologyChangeError } from "../hooks/topologyChangeError"; -import type { TransferProposalAction } from "./types"; + usePendingTransferProposalsViewModel, + type PendingTransferProposalsViewModel, +} from "./usePendingTransferProposalsViewModel"; type Props = { account: Account; parentAccount: Account; }; -type Modal = { - isOpen: boolean; - action: TransferProposalAction; - contractId: string; -}; - -const INSTRUCTION_TYPE_MAP: Record = { - accept: "accept-transfer-instruction", - reject: "reject-transfer-instruction", - withdraw: "withdraw-transfer-instruction", -}; - -const initialValues = { - groupedIncoming: [], - groupedOutgoing: [], - incomingCount: 0, - outgoingCount: 0, -}; - -const PendingTransferProposals: React.FC = ({ account, parentAccount }) => { - const dispatch = useDispatch(); - const device = useSelector(getCurrentDevice); - const unit = useAccountUnit(account); - const sync = useBridgeSync(); - const [modal, setModal] = useState({ isOpen: false, action: "accept", contractId: "" }); - - const cantonAccount = isCantonAccount(parentAccount) ? parentAccount : null; - const accountXpub = parentAccount.xpub ?? ""; - - const performTransferInstruction = useCantonAcceptOrRejectOffer({ - currency: parentAccount.currency, - account: parentAccount, - partyId: accountXpub, - }); - - const { groupedIncoming, groupedOutgoing, incomingCount, outgoingCount } = useMemo(() => { - if (!isCantonAccount(account)) return initialValues; - - const pendingTransferProposals = account.cantonResources?.pendingTransferProposals ?? []; - - const { incoming, outgoing } = processTransferProposals(pendingTransferProposals, accountXpub); - - return { - groupedIncoming: groupByDay(incoming), - groupedOutgoing: groupByDay(outgoing), - incomingCount: incoming.length, - outgoingCount: outgoing.length, - }; - }, [account, accountXpub]); - - const handleOpenModal = useCallback((contractId: string, action: TransferProposalAction) => { - setModal({ isOpen: true, action, contractId }); - }, []); - - const handleModalConfirm = useCallback( - async (contractId: string, action: TransferProposalAction, deviceId: string) => { - try { - const instructionType = INSTRUCTION_TYPE_MAP[action]; - await performTransferInstruction({ contractId, deviceId, reason: "" }, instructionType); - - sync({ - type: "SYNC_ONE_ACCOUNT", - accountId: parentAccount.id, - priority: 10, - reason: "canton-pending-transaction-action", - }); - } catch (error) { - if (error instanceof TopologyChangeError) { - // Topology changed - need to reonboard before continuing - setModal(prev => ({ ...prev, isOpen: false })); - handleTopologyChangeError(dispatch, { - currency: parentAccount.currency, - device, - accounts: [], - mainAccount: parentAccount, - navigationSnapshot: { - type: "transfer-proposal", - handler: handleOpenModal, - props: { action, contractId }, - }, - }); - return; - } - throw error; - } - }, - [performTransferInstruction, sync, parentAccount, dispatch, device, handleOpenModal], - ); - - const handleDeviceConfirm = useCallback( - async (deviceId: string) => { - await handleModalConfirm(modal.contractId, modal.action, deviceId); - }, - [handleModalConfirm, modal.contractId, modal.action], - ); - - const handleRowClick = useCallback( - (contractId: string) => { - setDrawer(PendingTransferProposalsDetails, { - account, - parentAccount, - contractId, - onOpenModal: handleOpenModal, - }); - }, - [account, parentAccount, handleOpenModal], - ); - +export function View({ + groupedIncoming, + groupedOutgoing, + incomingCount, + outgoingCount, + modal, + unit, + appName, + onRowClick, + onOpenModal, + onDeviceConfirm, + onModalClose, +}: PendingTransferProposalsViewModel) { if (incomingCount === 0 && outgoingCount === 0) { return null; } @@ -156,10 +33,10 @@ const PendingTransferProposals: React.FC = ({ account, parentAccount }) = <> setModal(prev => ({ ...prev, isOpen: false }))} - appName={cantonAccount?.currency.managerAppName ?? parentAccount.currency.managerAppName} + onClose={onModalClose} + appName={appName} /> = ({ account, parentAccount }) = titleKey="families.canton.pendingTransactions.incoming.title" isIncomingTable={true} unit={unit} - onRowClick={handleRowClick} - onOpenModal={handleOpenModal} + onRowClick={onRowClick} + onOpenModal={onOpenModal} /> = ({ account, parentAccount }) = titleKey="families.canton.pendingTransactions.outgoing.title" isIncomingTable={false} unit={unit} - onRowClick={handleRowClick} - onOpenModal={handleOpenModal} + onRowClick={onRowClick} + onOpenModal={onOpenModal} /> ); -}; - -export default PendingTransferProposals; - -type RawTransferProposal = { - contract_id: string; - sender: string; - receiver: string; - amount: string; - instrument_id: string; - memo: string; - expires_at_micros: number; -}; - -type ProcessedProposal = { - contract_id: string; - sender: string; - receiver: string; - amount: BigNumber; - instrument_id: string; - memo: string; - expires_at_micros: number; - expiresAtMicros: number; - isExpired: boolean; - isIncoming: boolean; - expiresAt: Date; - day: Date; -}; - -type GroupedProposals = Array<{ - day: Date; - proposals: ProcessedProposal[]; -}>; - -const startOfDay = (date: Date): Date => { - const d = new Date(date); - d.setHours(0, 0, 0, 0); - return d; -}; - -const processTransferProposals = ( - proposals: RawTransferProposal[], - accountXpub: string, -): { incoming: ProcessedProposal[]; outgoing: ProcessedProposal[] } => { - const currentTime = Date.now(); - const incoming: ProcessedProposal[] = []; - const outgoing: ProcessedProposal[] = []; - - for (let i = proposals.length - 1; i >= 0; i--) { - const proposal = proposals[i]; - const expiresAtTimestamp = proposal.expires_at_micros / 1000; - const expiresAt = new Date(expiresAtTimestamp); - const isExpired = currentTime > expiresAtTimestamp; - const isIncoming = proposal.sender !== accountXpub; - - const processed: ProcessedProposal = { - contract_id: proposal.contract_id, - sender: proposal.sender, - receiver: proposal.receiver, - amount: new BigNumber(proposal.amount), - instrument_id: proposal.instrument_id, - memo: proposal.memo, - expires_at_micros: proposal.expires_at_micros, - expiresAtMicros: proposal.expires_at_micros, - isExpired, - isIncoming, - expiresAt, - day: startOfDay(expiresAt), - }; - - if (isIncoming) { - incoming.push(processed); - } else { - outgoing.push(processed); - } - } - - return { incoming, outgoing }; -}; - -const groupByDay = (proposals: ProcessedProposal[]): GroupedProposals => { - if (proposals.length === 0) { - return []; - } - - const grouped = new Map(); - - for (const proposal of proposals) { - const dayTimestamp = proposal.day.getTime(); - const existing = grouped.get(dayTimestamp); - if (existing) { - existing.push(proposal); - } else { - grouped.set(dayTimestamp, [proposal]); - } - } - - return Array.from(grouped.entries()) - .sort(([timestampA], [timestampB]) => timestampB - timestampA) - .map(([dayTimestamp, proposals]) => ({ - day: new Date(dayTimestamp), - proposals, - })); -}; - -// Child components -const TableRow = styled(BaseTableRow)` - border-bottom: 1px solid ${p => p.theme.colors.neutral.c40}; -`; - -const TableHeaderRow = styled(Box)` - border-bottom: 1px solid ${p => p.theme.colors.neutral.c40}; - background-color: ${p => p.theme.colors.background.main}; - padding: 12px 0; -`; +} -type CountdownProps = { - expiresAt: Date; -}; - -const CountdownDisplay: React.FC = ({ expiresAt }) => { - return ( - - - - ); -}; - -type ExpiresInDisplayProps = { - expiresAtMicros: number; - isExpired: boolean; - t: (key: string) => string; -}; - -const MonospaceText = styled(Text)` - font-variant-numeric: tabular-nums; - font-feature-settings: "tnum"; - letter-spacing: 0; -`; - -const ExpiresInDisplay: React.FC = ({ expiresAtMicros, isExpired, t }) => { - const timeRemaining = useTimeRemaining(expiresAtMicros, isExpired); - - if (isExpired) { - return ( - - {t("families.canton.pendingTransactions.expired")} - - ); - } - - return ( - - {timeRemaining || "-"} - - ); -}; - -type ProposalRowProps = { - proposal: ProcessedProposal; - unit: ReturnType; - onRowClick: (contractId: string) => void; - onOpenModal: (contractId: string, action: TransferProposalAction) => void; - t: (key: string) => string; -}; - -const ProposalRow: React.FC = ({ - proposal, - unit, - onRowClick, - onOpenModal, - t, -}) => { - const { - isIncoming, - isExpired, - contract_id, - sender, - receiver, - amount, - expiresAt, - expiresAtMicros, - } = proposal; - - const addressToShow = isIncoming ? sender : receiver; - const amountValue = isIncoming ? amount : amount.negated(); - - const handleAcceptClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - if (!isExpired) { - onOpenModal(contract_id, "accept"); - } - }, - [contract_id, isExpired, onOpenModal], - ); - - const handleRejectClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - onOpenModal(contract_id, "reject"); - }, - [contract_id, onOpenModal], - ); - - const handleWithdrawClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - onOpenModal(contract_id, "withdraw"); - }, - [contract_id, onOpenModal], - ); - - return ( - onRowClick(contract_id)}> - - {/* Date Cell */} - - - {isExpired ? ( - - - - - - ) : isIncoming ? ( - - - - ) : ( - - - - )} - - - - - - - - -
- - - - - - - - - - - - - {isIncoming ? ( - <> - {!isExpired && ( - - )} - - - ) : ( - - )} - - - - ); -}; - -const ProposalRowMemo = memo(ProposalRow); - -type ProposalsTableProps = { - proposals: GroupedProposals; - count: number; - titleKey: string; - isIncomingTable: boolean; - unit: ReturnType; - onRowClick: (contractId: string) => void; - onOpenModal: (contractId: string, action: TransferProposalAction) => void; -}; - -const ProposalsTable: React.FC = ({ - proposals, - count, - titleKey, - isIncomingTable, - unit, - onRowClick, - onOpenModal, -}) => { - const { t } = useTranslation(); - - if (count === 0) return null; - - return ( - - - - - - - - {t("families.canton.pendingTransactions.date")} - - - - - - {isIncomingTable - ? t("families.canton.pendingTransactions.from") - : t("families.canton.pendingTransactions.to")} - - - - - {t("families.canton.pendingTransactions.expiresIn")} - - - - - {t("families.canton.pendingTransactions.amount")} - - - - - {t("families.canton.pendingTransactions.action")} - - - - - {proposals.map(group => ( - - - - {group.proposals.map(proposal => ( - - ))} - - - ))} - - ); -}; +export default function PendingTransferProposals({ account, parentAccount }: Props) { + return ; +} diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/types.ts b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/types.ts index fb16f18755fe..39684f0ed4da 100644 --- a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/types.ts +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/types.ts @@ -1 +1,40 @@ +import { BigNumber } from "bignumber.js"; + export type TransferProposalAction = "accept" | "reject" | "withdraw"; + +export type Modal = { + isOpen: boolean; + action: TransferProposalAction; + contractId: string; +}; + +export type RawTransferProposal = { + contract_id: string; + sender: string; + receiver: string; + amount: string; + instrument_id: string; + instrument_admin: string; + memo?: string; + expires_at_micros: number; + update_id?: string; +}; + +export type ProcessedProposal = { + contractId: string; + sender: string; + receiver: string; + amount: BigNumber; + instrumentId: string; + memo: string; + expiresAtMicros: number; + isExpired: boolean; + isIncoming: boolean; + expiresAt: Date; + day: Date; +}; + +export type GroupedProposals = Array<{ + day: Date; + proposals: ProcessedProposal[]; +}>; diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/useDeviceAppModalViewModel.ts b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/useDeviceAppModalViewModel.ts new file mode 100644 index 000000000000..0c4b82bc02a4 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/useDeviceAppModalViewModel.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import useConnectAppAction from "~/renderer/hooks/useConnectAppAction"; +import { CONNECTION_TYPES } from "~/renderer/analytics/hooks/variables"; +import type { TransferProposalAction } from "./types"; + +type Input = { + isOpen: boolean; + onConfirm: (deviceId: string) => Promise; + action: TransferProposalAction; + appName: string; + onClose?: () => void; +}; + +export type ConfirmationState = "pending" | "confirming" | "completed" | "error"; + +export type DeviceAppModalViewModel = { + isOpen: boolean; + action: TransferProposalAction; + onClose?: () => void; + confirmationState: ConfirmationState; + error: Error | null; + request: { appName: string }; + actionConnect: ReturnType; + handleDeviceResult: (result: { device?: { deviceId?: string; wired?: boolean } }) => void; + handleRetry: () => void; +}; + +export function useDeviceAppModalViewModel({ + isOpen, + onConfirm, + action, + appName, + onClose, +}: Input): DeviceAppModalViewModel { + const [confirmationState, setConfirmationState] = useState("pending"); + const [error, setError] = useState(null); + + const actionConnect = useConnectAppAction(); + + const request = useMemo(() => ({ appName }), [appName]); + + useEffect(() => { + if (isOpen) { + setConfirmationState("pending"); + setError(null); + } + }, [isOpen]); + + const handleConfirm = useCallback( + async (deviceId: string) => { + try { + setConfirmationState("confirming"); + await onConfirm(deviceId); + setConfirmationState("completed"); + } catch (err) { + setConfirmationState("error"); + setError(err instanceof Error ? err : new Error(String(err))); + } + }, + [onConfirm], + ); + + const handleRetry = useCallback(() => { + setConfirmationState("pending"); + setError(null); + }, []); + + const handleDeviceResult = useCallback( + (result: { device?: { deviceId?: string; wired?: boolean } }) => { + if (result?.device) { + const deviceId = + result.device.deviceId || + (result.device.wired ? CONNECTION_TYPES.USB : CONNECTION_TYPES.BLE); + handleConfirm(deviceId); + } + }, + [handleConfirm], + ); + + return { + isOpen, + action, + onClose, + confirmationState, + error, + request, + actionConnect, + handleDeviceResult, + handleRetry, + }; +} diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/usePendingTransferProposalsDetailsViewModel.ts b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/usePendingTransferProposalsDetailsViewModel.ts new file mode 100644 index 000000000000..db1e11385a8c --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/usePendingTransferProposalsDetailsViewModel.ts @@ -0,0 +1,55 @@ +import { useTimeRemaining } from "@ledgerhq/live-common/families/canton/react"; +import { Account } from "@ledgerhq/types-live"; +import { useCallback, useMemo } from "react"; +import { useAccountUnit } from "~/renderer/hooks/useAccountUnit"; +import { dayFormat, useDateFormatter } from "~/renderer/hooks/useDateFormatter"; +import type { ProcessedProposal, TransferProposalAction } from "./types"; + +type Input = { + account: Account; + proposal: ProcessedProposal | null; + onOpenModal: (contractId: string, action: TransferProposalAction) => void; + onClose?: () => void; +}; + +export type PendingTransferProposalsDetailsViewModel = { + proposal: ProcessedProposal | null; + unit: ReturnType; + dateFormatted: string; + timeRemaining: string; + handleAction: (action: TransferProposalAction) => void; +}; + +export function usePendingTransferProposalsDetailsViewModel({ + account, + proposal, + onOpenModal, + onClose, +}: Input): PendingTransferProposalsDetailsViewModel { + const unit = useAccountUnit(account); + + const formatDate = useDateFormatter(dayFormat); + const dateFormatted = useMemo( + () => formatDate(new Date((proposal?.expiresAtMicros ?? 0) / 1000)), + [proposal?.expiresAtMicros, formatDate], + ); + const timeRemaining = useTimeRemaining(proposal?.expiresAtMicros, proposal?.isExpired); + + const handleAction = useCallback( + (action: TransferProposalAction) => { + if (proposal) { + onOpenModal(proposal.contractId, action); + onClose?.(); + } + }, + [onClose, onOpenModal, proposal], + ); + + return { + proposal, + unit, + dateFormatted, + timeRemaining, + handleAction, + }; +} diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/usePendingTransferProposalsViewModel.ts b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/usePendingTransferProposalsViewModel.ts new file mode 100644 index 000000000000..c3d38c80f100 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/usePendingTransferProposalsViewModel.ts @@ -0,0 +1,162 @@ +import { isCantonAccount } from "@ledgerhq/coin-canton"; +import { useBridgeSync } from "@ledgerhq/live-common/bridge/react/index"; +import { useCantonAcceptOrRejectOffer } from "@ledgerhq/live-common/families/canton/react"; +import { Account } from "@ledgerhq/types-live"; +import { useDispatch, useSelector } from "LLD/hooks/redux"; +import { useCallback, useMemo, useState } from "react"; +import { setDrawer } from "~/renderer/drawers/Provider"; +import { useAccountUnit } from "~/renderer/hooks/useAccountUnit"; +import { getCurrentDevice } from "~/renderer/reducers/devices"; +import { handleTopologyChangeError, TopologyChangeError } from "../hooks/topologyChangeError"; +import PendingTransferProposalsDetails from "./PendingTransferProposalsDetails"; +import type { GroupedProposals, Modal, ProcessedProposal, TransferProposalAction } from "./types"; +import { + groupByDay, + INSTRUCTION_TYPE_MAP, + processTransferProposals, +} from "./utils/transferProposals"; + +const EMPTY_PROPOSALS: { + groupedIncoming: GroupedProposals; + groupedOutgoing: GroupedProposals; + incomingCount: number; + outgoingCount: number; + allProposals: ProcessedProposal[]; +} = { + groupedIncoming: [], + groupedOutgoing: [], + incomingCount: 0, + outgoingCount: 0, + allProposals: [], +}; + +export type PendingTransferProposalsViewModel = { + groupedIncoming: GroupedProposals; + groupedOutgoing: GroupedProposals; + incomingCount: number; + outgoingCount: number; + modal: Modal; + unit: ReturnType; + appName: string; + onRowClick: (contractId: string) => void; + onOpenModal: (contractId: string, action: TransferProposalAction) => void; + onDeviceConfirm: (deviceId: string) => Promise; + onModalClose: () => void; +}; + +export function usePendingTransferProposalsViewModel( + account: Account, + parentAccount: Account, +): PendingTransferProposalsViewModel { + const dispatch = useDispatch(); + const device = useSelector(getCurrentDevice); + const unit = useAccountUnit(account); + const sync = useBridgeSync(); + const [modal, setModal] = useState({ isOpen: false, action: "accept", contractId: "" }); + + const accountXpub = parentAccount.xpub ?? ""; + + const performTransferInstruction = useCantonAcceptOrRejectOffer({ + currency: parentAccount.currency, + account: parentAccount, + partyId: accountXpub, + }); + + const { groupedIncoming, groupedOutgoing, incomingCount, outgoingCount, allProposals } = + useMemo(() => { + if (!isCantonAccount(account)) return EMPTY_PROPOSALS; + + const pendingTransferProposals = account.cantonResources?.pendingTransferProposals ?? []; + const { incoming, outgoing } = processTransferProposals( + pendingTransferProposals, + accountXpub, + ); + + return { + groupedIncoming: groupByDay(incoming), + groupedOutgoing: groupByDay(outgoing), + incomingCount: incoming.length, + outgoingCount: outgoing.length, + allProposals: [...incoming, ...outgoing], + }; + }, [account, accountXpub]); + + const onOpenModal = useCallback((contractId: string, action: TransferProposalAction) => { + setModal({ isOpen: true, action, contractId }); + }, []); + + const onModalClose = useCallback(() => { + setModal(prev => ({ ...prev, isOpen: false })); + }, []); + + const handleModalConfirm = useCallback( + async (contractId: string, action: TransferProposalAction, deviceId: string) => { + try { + const instructionType = INSTRUCTION_TYPE_MAP[action]; + await performTransferInstruction({ contractId, deviceId, reason: "" }, instructionType); + + sync({ + type: "SYNC_ONE_ACCOUNT", + accountId: parentAccount.id, + priority: 10, + reason: "canton-pending-transaction-action", + }); + } catch (error) { + if (error instanceof TopologyChangeError) { + setModal(prev => ({ ...prev, isOpen: false })); + if (device) { + handleTopologyChangeError(dispatch, { + currency: parentAccount.currency, + device, + accounts: [], + mainAccount: parentAccount, + navigationSnapshot: { + type: "transfer-proposal", + handler: onOpenModal, + props: { action, contractId }, + }, + }); + } + return; + } + throw error; + } + }, + [performTransferInstruction, sync, parentAccount, dispatch, device, onOpenModal], + ); + + const onDeviceConfirm = useCallback( + async (deviceId: string) => { + await handleModalConfirm(modal.contractId, modal.action, deviceId); + }, + [handleModalConfirm, modal.contractId, modal.action], + ); + + const onRowClick = useCallback( + (contractId: string) => { + const proposal = allProposals.find(p => p.contractId === contractId) ?? null; + setDrawer(PendingTransferProposalsDetails, { + account, + proposal, + onOpenModal, + }); + }, + [account, allProposals, onOpenModal], + ); + + const appName = parentAccount.currency.managerAppName; + + return { + groupedIncoming, + groupedOutgoing, + incomingCount, + outgoingCount, + modal, + unit, + appName, + onRowClick, + onOpenModal, + onDeviceConfirm, + onModalClose, + }; +} diff --git a/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/utils/transferProposals.ts b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/utils/transferProposals.ts new file mode 100644 index 000000000000..7ef14fa34bc8 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/canton/PendingTransferProposals/utils/transferProposals.ts @@ -0,0 +1,86 @@ +import type { TransferInstructionType } from "@ledgerhq/live-common/families/canton/react"; +import { BigNumber } from "bignumber.js"; +import type { + GroupedProposals, + ProcessedProposal, + RawTransferProposal, + TransferProposalAction, +} from "../types"; + +export const INSTRUCTION_TYPE_MAP: Record = { + accept: "accept-transfer-instruction", + reject: "reject-transfer-instruction", + withdraw: "withdraw-transfer-instruction", +}; + +const startOfDay = (date: Date): Date => { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +}; + +export const processTransferProposals = ( + proposals: RawTransferProposal[], + accountXpub: string, +): { incoming: ProcessedProposal[]; outgoing: ProcessedProposal[] } => { + const currentTime = Date.now(); + const incoming: ProcessedProposal[] = []; + const outgoing: ProcessedProposal[] = []; + + // Iterate in reverse so the most-recently-added proposal appears first + // within each day group after groupByDay sorts descending. + for (let i = proposals.length - 1; i >= 0; i--) { + const proposal = proposals[i]; + const expiresAtMs = proposal.expires_at_micros / 1000; + const expiresAt = new Date(expiresAtMs); + const isExpired = currentTime > expiresAtMs; + const isIncoming = proposal.sender !== accountXpub; + + const processed: ProcessedProposal = { + contractId: proposal.contract_id, + sender: proposal.sender, + receiver: proposal.receiver, + amount: new BigNumber(proposal.amount), + instrumentId: proposal.instrument_id, + memo: proposal.memo ?? "", + expiresAtMicros: proposal.expires_at_micros, + isExpired, + isIncoming, + expiresAt, + day: startOfDay(expiresAt), + }; + + if (isIncoming) { + incoming.push(processed); + } else { + outgoing.push(processed); + } + } + + return { incoming, outgoing }; +}; + +export const groupByDay = (proposals: ProcessedProposal[]): GroupedProposals => { + if (proposals.length === 0) { + return []; + } + + const grouped = new Map(); + + for (const proposal of proposals) { + const dayTimestamp = proposal.day.getTime(); + const existing = grouped.get(dayTimestamp); + if (existing) { + existing.push(proposal); + } else { + grouped.set(dayTimestamp, [proposal]); + } + } + + return Array.from(grouped.entries()) + .sort(([timestampA], [timestampB]) => timestampB - timestampA) + .map(([dayTimestamp, groupedProposals]) => ({ + day: new Date(dayTimestamp), + proposals: groupedProposals, + })); +};