= ({ 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,
+ }));
+};