Skip to content

Commit d3b0289

Browse files
authored
Merge pull request #14600 from LedgerHQ/feat/24885_manual-refresh
feat/live 24885 manual refresh
2 parents 48a3ef1 + bf8ddd4 commit d3b0289

File tree

16 files changed

+586
-294
lines changed

16 files changed

+586
-294
lines changed

.changeset/tough-mails-rest.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ledger-live-desktop": minor
3+
---
4+
5+
feat(lwd): balance loading on manual sync

apps/ledger-live-desktop/src/mvvm/components/TopBar/hooks/__tests__/useActivityIndicator.test.ts

Lines changed: 47 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -3,78 +3,39 @@ import { renderHook, act } from "tests/testSetup";
33
import { useActivityIndicator } from "../useActivityIndicator";
44
import { BTC_ACCOUNT } from "LLD/features/__mocks__/accounts.mock";
55

6-
const mockBridgeSync = jest.fn();
7-
const mockCvPoll = jest.fn();
8-
const mockOnUserRefresh = jest.fn();
6+
const mockTriggerRefresh = jest.fn();
7+
8+
const defaultPortfolioBalanceSync = {
9+
isBalanceLoading: false,
10+
stableSyncPending: false,
11+
hasCvOrBridgeError: false,
12+
hasWalletSyncError: false,
13+
triggerRefresh: mockTriggerRefresh,
14+
};
15+
16+
jest.mock("LLD/hooks/usePortfolioBalanceSync", () => ({
17+
usePortfolioBalanceSync: jest.fn(() => defaultPortfolioBalanceSync),
18+
}));
19+
20+
const mockUsePortfolioBalanceSync = jest.requireMock(
21+
"LLD/hooks/usePortfolioBalanceSync",
22+
).usePortfolioBalanceSync;
923

1024
// Bridge: useActivityIndicator + useAccountsSyncStatus both use this package
1125
jest.mock("@ledgerhq/live-common/bridge/react/index", () => ({
12-
useBridgeSync: jest.fn(() => mockBridgeSync),
26+
useBridgeSync: jest.fn(),
1327
useGlobalSyncState: jest.fn(() => ({ pending: false, error: null })),
1428
useBatchAccountsSyncState: jest.fn(({ accounts }: { accounts: { id: string }[] }) =>
1529
accounts.map(account => ({ syncState: { pending: false, error: null }, account })),
1630
),
1731
}));
1832

19-
jest.mock("@ledgerhq/live-countervalues-react", () => ({
20-
...jest.requireActual<typeof import("@ledgerhq/live-countervalues-react")>(
21-
"@ledgerhq/live-countervalues-react",
22-
),
23-
useCountervaluesPolling: jest.fn(() => ({
24-
pending: false,
25-
error: null,
26-
poll: mockCvPoll,
27-
start: jest.fn(),
28-
stop: jest.fn(),
29-
wipe: jest.fn(),
30-
})),
31-
}));
32-
33-
jest.mock("LLD/features/WalletSync/components/WalletSyncContext", () => ({
34-
useWalletSyncUserState: jest.fn(() => ({
35-
visualPending: false,
36-
walletSyncError: null,
37-
onUserRefresh: mockOnUserRefresh,
38-
})),
39-
}));
40-
41-
jest.mock("LLD/hooks/usePortfolioSyncStatus", () => ({
42-
usePortfolioSyncStatus: jest.fn(() => ({ isColdStart: false })),
43-
}));
44-
45-
const mockUseGlobalSyncState = jest.requireMock(
46-
"@ledgerhq/live-common/bridge/react/index",
47-
).useGlobalSyncState;
48-
const mockUseCountervaluesPolling = jest.requireMock(
49-
"@ledgerhq/live-countervalues-react",
50-
).useCountervaluesPolling;
51-
const mockUseWalletSyncUserState = jest.requireMock(
52-
"LLD/features/WalletSync/components/WalletSyncContext",
53-
).useWalletSyncUserState;
54-
const mockUsePortfolioSyncStatus = jest.requireMock(
55-
"LLD/hooks/usePortfolioSyncStatus",
56-
).usePortfolioSyncStatus;
57-
5833
const defaultInitialState: { accounts: unknown[] } = { accounts: [] };
5934

6035
describe("useActivityIndicator", () => {
6136
beforeEach(() => {
6237
jest.clearAllMocks();
63-
mockUseGlobalSyncState.mockReturnValue({ pending: false, error: null });
64-
mockUseCountervaluesPolling.mockReturnValue({
65-
pending: false,
66-
error: null,
67-
poll: mockCvPoll,
68-
start: jest.fn(),
69-
stop: jest.fn(),
70-
wipe: jest.fn(),
71-
});
72-
mockUseWalletSyncUserState.mockReturnValue({
73-
visualPending: false,
74-
walletSyncError: null,
75-
onUserRefresh: mockOnUserRefresh,
76-
});
77-
mockUsePortfolioSyncStatus.mockReturnValue({ isColdStart: false });
38+
mockUsePortfolioBalanceSync.mockReturnValue(defaultPortfolioBalanceSync);
7839
});
7940

8041
it("returns hasAccounts, handleSync, isError, isRotating, tooltip, icon", () => {
@@ -86,32 +47,33 @@ describe("useActivityIndicator", () => {
8647
hasAccounts: true,
8748
isError: false,
8849
isRotating: false,
50+
isDisabled: false,
8951
});
9052
expect(result.current.tooltip).toBeDefined();
9153
expect(typeof result.current.tooltip).toBe("string");
9254
expect(result.current.handleSync).toBeDefined();
9355
expect(result.current.icon).toBe(Refresh);
9456
});
9557

96-
it("returns isRotating false when only countervalues polling is pending (no user click)", () => {
97-
mockUseCountervaluesPolling.mockReturnValue({
98-
pending: true,
99-
error: null,
100-
poll: mockCvPoll,
101-
start: jest.fn(),
102-
stop: jest.fn(),
103-
wipe: jest.fn(),
58+
it("returns isRotating and isDisabled true when balance is loading (e.g. countervalues polling)", () => {
59+
mockUsePortfolioBalanceSync.mockReturnValue({
60+
...defaultPortfolioBalanceSync,
61+
isBalanceLoading: true,
10462
});
10563

10664
const { result } = renderHook(() => useActivityIndicator(), {
10765
initialState: { ...defaultInitialState, accounts: [BTC_ACCOUNT] },
10866
});
10967

110-
expect(result.current.isRotating).toBe(false);
68+
expect(result.current.isRotating).toBe(true);
69+
expect(result.current.isDisabled).toBe(true);
11170
});
11271

11372
it("returns isRotating true on cold start when portfolio balance is not available", () => {
114-
mockUsePortfolioSyncStatus.mockReturnValue({ isColdStart: true });
73+
mockUsePortfolioBalanceSync.mockReturnValue({
74+
...defaultPortfolioBalanceSync,
75+
isBalanceLoading: true,
76+
});
11577

11678
const { result } = renderHook(() => useActivityIndicator(), {
11779
initialState: { ...defaultInitialState, accounts: [BTC_ACCOUNT] },
@@ -121,45 +83,42 @@ describe("useActivityIndicator", () => {
12183
expect(result.current.tooltip).toBeNull();
12284
});
12385

124-
it("returns isRotating false when only global sync state is pending (no user click)", () => {
125-
mockUseGlobalSyncState.mockReturnValue({ pending: true, error: null });
86+
it("returns isRotating true when sync is pending", () => {
87+
mockUsePortfolioBalanceSync.mockReturnValue({
88+
...defaultPortfolioBalanceSync,
89+
stableSyncPending: true,
90+
isBalanceLoading: true,
91+
});
12692

12793
const { result } = renderHook(() => useActivityIndicator(), {
12894
initialState: { ...defaultInitialState, accounts: [BTC_ACCOUNT] },
12995
});
13096

131-
expect(result.current.isRotating).toBe(false);
97+
expect(result.current.isRotating).toBe(true);
13298
});
13399

134-
it("returns isRotating false when only wallet sync visualPending is true (no user click)", () => {
135-
mockUseWalletSyncUserState.mockReturnValue({
136-
visualPending: true,
137-
walletSyncError: null,
138-
onUserRefresh: mockOnUserRefresh,
139-
});
140-
141-
const { result } = renderHook(() => useActivityIndicator(), {
142-
initialState: { ...defaultInitialState, accounts: [BTC_ACCOUNT] },
100+
it("returns isRotating true after user click when sync is pending", () => {
101+
mockUsePortfolioBalanceSync.mockReturnValue({
102+
...defaultPortfolioBalanceSync,
103+
stableSyncPending: true,
104+
isBalanceLoading: true,
143105
});
144106

145-
expect(result.current.isRotating).toBe(false);
146-
});
147-
148-
it("returns isRotating false after user click when sync is not pending", () => {
149107
const { result } = renderHook(() => useActivityIndicator(), {
150108
initialState: { ...defaultInitialState, accounts: [BTC_ACCOUNT] },
151109
});
152110

153-
expect(result.current.isRotating).toBe(false);
111+
expect(result.current.isRotating).toBe(true);
154112

155113
act(() => {
156114
result.current.handleSync();
157115
});
158116

159-
expect(result.current.isRotating).toBe(false);
117+
expect(result.current.isRotating).toBe(true);
118+
expect(result.current.isDisabled).toBe(true);
160119
});
161120

162-
it("handleSync calls onUserRefresh, cvPolling.poll, bridgeSync and track", () => {
121+
it("handleSync calls triggerRefresh and track", () => {
163122
const { result } = renderHook(() => useActivityIndicator(), {
164123
initialState: { ...defaultInitialState, accounts: [BTC_ACCOUNT] },
165124
});
@@ -169,13 +128,7 @@ describe("useActivityIndicator", () => {
169128
result.current.handleSync();
170129
});
171130

172-
expect(mockOnUserRefresh).toHaveBeenCalledTimes(1);
173-
expect(mockCvPoll).toHaveBeenCalledTimes(1);
174-
expect(mockBridgeSync).toHaveBeenCalledWith({
175-
type: "SYNC_ALL_ACCOUNTS",
176-
priority: 5,
177-
reason: "user-click",
178-
});
131+
expect(mockTriggerRefresh).toHaveBeenCalledTimes(1);
179132
expect(track).toHaveBeenCalledWith("SyncRefreshClick");
180133
});
181134
});

apps/ledger-live-desktop/src/mvvm/components/TopBar/hooks/__tests__/useTopBarViewModel.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe("useTopBarViewModel", () => {
3333
isError: false,
3434
tooltip: "Refresh",
3535
icon: Refresh,
36+
isDisabled: false,
3637
});
3738
mockUseSettings.mockReturnValue({
3839
handleSettings: mockHandleSettings,
@@ -82,6 +83,7 @@ describe("useTopBarViewModel", () => {
8283
isError: false,
8384
tooltip: "Refresh",
8485
icon: Refresh,
86+
isDisabled: false,
8587
});
8688

8789
const { result } = renderHook(() => useTopBarViewModel());
@@ -104,6 +106,7 @@ describe("useTopBarViewModel", () => {
104106
isError: true,
105107
tooltip: "Error",
106108
icon: Refresh,
109+
isDisabled: true,
107110
});
108111

109112
const { result } = renderHook(() => useTopBarViewModel());

apps/ledger-live-desktop/src/mvvm/components/TopBar/hooks/useActivityIndicator.ts

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
import { useCallback, useEffect, useReducer, useState } from "react";
22
import { useSelector } from "LLD/hooks/redux";
33
import { hasAccountsSelector, isUpToDateSelector } from "~/renderer/reducers/accounts";
4-
import { useWalletSyncUserState } from "LLD/features/WalletSync/components/WalletSyncContext";
5-
import { useCountervaluesPolling } from "@ledgerhq/live-countervalues-react";
6-
import { useBridgeSync, useGlobalSyncState } from "@ledgerhq/live-common/bridge/react/index";
74
import { getEnv } from "@ledgerhq/live-env";
85
import { track } from "~/renderer/analytics/segment";
96

10-
import { usePortfolioSyncStatus } from "LLD/hooks/usePortfolioSyncStatus";
7+
import { usePortfolioBalanceSync } from "LLD/hooks/usePortfolioBalanceSync";
118
import { useAccountsSyncStatus } from "./useAccountsSyncStatus";
129
import { useActivityIndicatorTooltip } from "./useActivityIndicatorTooltip";
1310
import { getActivityIndicatorIcon } from "../utils/getActivityIndicatorIcon";
@@ -17,14 +14,20 @@ import {
1714
USER_CLICK_SPIN_DURATION_MS,
1815
} from "../utils/constants";
1916

17+
/**
18+
* Activity indicator state for the TopBar sync button.
19+
* When isRotating is true, the sync action should be non-interactive (disabled).
20+
*/
2021
export const useActivityIndicator = () => {
2122
const hasAccounts = useSelector(hasAccountsSelector);
2223
const accountsWithUpToDateCheck = useSelector(isUpToDateSelector);
23-
const wsUserState = useWalletSyncUserState();
24-
const cvPolling = useCountervaluesPolling();
25-
const { isColdStart } = usePortfolioSyncStatus();
26-
const bridgeSync = useBridgeSync();
27-
const globalSyncState = useGlobalSyncState();
24+
const {
25+
isBalanceLoading,
26+
stableSyncPending,
27+
hasCvOrBridgeError,
28+
hasWalletSyncError,
29+
triggerRefresh,
30+
} = usePortfolioBalanceSync();
2831
const [lastClickTime, setLastClickTime] = useState(0);
2932
const [, forceTooltipUpdate] = useReducer((tick: number) => tick + 1, 0);
3033

@@ -40,16 +43,13 @@ export const useActivityIndicator = () => {
4043
return () => clearInterval(id);
4144
}, [needsTooltipUpdates]);
4245

43-
const isPending = cvPolling.pending || globalSyncState.pending || wsUserState.visualPending;
44-
const hasWalletSyncError = !!wsUserState.walletSyncError;
45-
const hasBridgeOrCvSyncError = !isPending && (!!cvPolling.error || !!globalSyncState.error);
46-
const isError = hasBridgeOrCvSyncError || !areAllAccountsUpToDate || hasWalletSyncError;
46+
const isError = hasCvOrBridgeError || !areAllAccountsUpToDate || hasWalletSyncError;
4747
const isPlaywrightRun = getEnv("PLAYWRIGHT_RUN");
4848
const userClickSpinMs = isPlaywrightRun
4949
? PLAYWRIGHT_CLICK_SPIN_DURATION_MS
5050
: USER_CLICK_SPIN_DURATION_MS;
5151
const isUserClick = Date.now() - lastClickTime < userClickSpinMs;
52-
const isRotating = isColdStart || (isUserClick && isPending);
52+
const isRotating = isBalanceLoading || (isUserClick && stableSyncPending);
5353

5454
const icon = getActivityIndicatorIcon(isError, isRotating);
5555
const tooltip = useActivityIndicatorTooltip({
@@ -60,22 +60,17 @@ export const useActivityIndicator = () => {
6060
});
6161

6262
const handleSync = useCallback(() => {
63-
wsUserState.onUserRefresh();
64-
cvPolling.poll();
65-
bridgeSync({
66-
type: "SYNC_ALL_ACCOUNTS",
67-
priority: 5,
68-
reason: "user-click",
69-
});
63+
triggerRefresh();
7064
setLastClickTime(Date.now());
7165
track("SyncRefreshClick");
72-
}, [wsUserState, cvPolling, bridgeSync]);
66+
}, [triggerRefresh]);
7367

7468
return {
7569
hasAccounts,
7670
handleSync,
7771
isError,
7872
isRotating,
73+
isDisabled: isRotating,
7974
tooltip,
8075
icon,
8176
};

apps/ledger-live-desktop/src/mvvm/features/Portfolio/__integrations__/Portfolio.integration.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ describe("PortfolioView", () => {
300300
expect(balanceElement).not.toHaveTextContent("••••"); // Ensure no placeholders
301301
});
302302

303-
it("should not show loading when countervalues are polling but balance is already available", () => {
303+
it("should display loading state when countervalues are being polled", () => {
304304
mockUseCountervaluesPolling.mockReturnValue({
305305
...defaultPollingMock,
306306
pending: true,

apps/ledger-live-desktop/src/mvvm/features/Portfolio/components/Balance/BalanceView.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const BalanceView = ({
1010
valueChange,
1111
navigateToAnalytics,
1212
handleKeyDown,
13+
isLoading,
1314
isColdStart,
1415
shouldDisplayBalanceRefreshRework,
1516
}: BalanceViewProps) => {
@@ -27,7 +28,7 @@ export const BalanceView = ({
2728
formatter={formatter}
2829
hidden={discreet}
2930
animate={shouldDisplayBalanceRefreshRework}
30-
loading={shouldDisplayBalanceRefreshRework && isColdStart}
31+
loading={shouldDisplayBalanceRefreshRework && isLoading}
3132
data-testid="portfolio-total-balance"
3233
/>
3334
{!isColdStart && <Trend valueChange={valueChange} />}

apps/ledger-live-desktop/src/mvvm/features/Portfolio/components/Balance/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface BalanceViewProps {
99
readonly navigateToAnalytics: () => void;
1010
readonly handleKeyDown: (event: React.KeyboardEvent<HTMLButtonElement>) => void;
1111
readonly isColdStart: boolean;
12+
readonly isLoading: boolean;
1213
readonly shouldDisplayBalanceRefreshRework: boolean;
1314
}
1415

0 commit comments

Comments
 (0)