Skip to content

Commit 0bbaf04

Browse files
committed
feat(common): sync indicator
1 parent 8bbcb66 commit 0bbaf04

File tree

14 files changed

+371
-71
lines changed

14 files changed

+371
-71
lines changed

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

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,26 @@
1-
import { useBatchAccountsSyncState } from "@ledgerhq/live-common/bridge/react/index";
2-
import { Account } from "@ledgerhq/types-live";
3-
4-
export interface AccountWithUpToDateCheck {
5-
account: Account;
6-
isUpToDate?: boolean;
7-
}
1+
import { useMemo } from "react";
2+
import { useAccountsSyncStatus as useAccountsSyncStatusCommon } from "@ledgerhq/live-common/bridge/react/index";
3+
import type { AccountWithUpToDateCheck } from "@ledgerhq/live-common/bridge/react/useAccountsSyncStatus";
84

95
export interface AccountsSyncStatus {
106
allAccounts: AccountWithUpToDateCheck["account"][];
117
listOfErrorAccountNames: string;
128
areAllAccountsUpToDate: boolean;
139
}
1410

15-
/**
16-
* Derives sync status from accounts with up-to-date check:
17-
* which accounts have errors and whether all are up to date.
18-
*/
1911
export function useAccountsSyncStatus(
2012
accountsWithUpToDateCheck: AccountWithUpToDateCheck[],
2113
): AccountsSyncStatus {
22-
const allAccounts = accountsWithUpToDateCheck.map(item => item.account);
23-
const isUpToDateByAccountId = new Map(
24-
accountsWithUpToDateCheck.map(item => [item.account.id, item.isUpToDate === true]),
25-
);
14+
const { allAccounts, accountsWithError, areAllAccountsUpToDate } =
15+
useAccountsSyncStatusCommon(accountsWithUpToDateCheck);
2616

27-
const batchState = useBatchAccountsSyncState({ accounts: allAccounts });
28-
const errorTickersSet = new Set<string>();
29-
for (const { syncState, account } of batchState) {
30-
if (syncState.pending) continue;
31-
const isUpToDate = isUpToDateByAccountId.get(account.id);
32-
if (syncState.error || !isUpToDate) {
17+
const listOfErrorAccountNames = useMemo(() => {
18+
const errorTickersSet = new Set<string>();
19+
for (const account of accountsWithError) {
3320
errorTickersSet.add(account.currency.ticker);
3421
}
35-
}
36-
37-
const listOfErrorAccountNames = [...errorTickersSet].join("/");
38-
const areAllAccountsUpToDate = errorTickersSet.size === 0;
22+
return [...errorTickersSet].join("/");
23+
}, [accountsWithError]);
3924

4025
return {
4126
allAccounts,

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "~/renderer/reducers/syncRefresh";
88
import { getEnv } from "@ledgerhq/live-env";
99
import { track } from "~/renderer/analytics/segment";
10+
import { getAggregateSyncState } from "@ledgerhq/live-common/bridge/react/index";
1011

1112
import { usePortfolioBalanceSync } from "LLD/hooks/usePortfolioBalanceSync";
1213
import { useAccountsSyncStatus } from "./useAccountsSyncStatus";
@@ -19,10 +20,6 @@ import {
1920
} from "../utils/constants";
2021
import { isRecentUserSyncClick } from "../utils/syncRefreshUtils";
2122

22-
/**
23-
* Activity indicator state for the TopBar sync button.
24-
* When isRotating is true, the sync action should be non-interactive (disabled).
25-
*/
2623
export const useActivityIndicator = () => {
2724
const dispatch = useDispatch();
2825
const hasAccounts = useSelector(hasAccountsSelector);
@@ -49,7 +46,14 @@ export const useActivityIndicator = () => {
4946
return () => clearInterval(id);
5047
}, [needsTooltipUpdates]);
5148

52-
const isError = hasCvOrBridgeError || !areAllAccountsUpToDate || hasWalletSyncError;
49+
const { isPending, isError } = getAggregateSyncState({
50+
areAllAccountsUpToDate,
51+
bridgeOrCvPending: false,
52+
bridgeOrCvError: hasCvOrBridgeError,
53+
walletSyncPending: false,
54+
walletSyncError: hasWalletSyncError,
55+
});
56+
5357
const isPlaywrightRun = getEnv("PLAYWRIGHT_RUN");
5458
const userClickSpinMs = isPlaywrightRun
5559
? PLAYWRIGHT_CLICK_SPIN_DURATION_MS

apps/ledger-live-mobile/src/locales/en/common.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8597,5 +8597,12 @@
85978597
"swap": "Swap",
85988598
"earn": "Earn",
85998599
"card": "Card"
8600+
},
8601+
"syncIndicator": {
8602+
"bottomSheet": {
8603+
"title": "Couldn't sync some accounts",
8604+
"description": "There was a temporary network issue. Your assets are safe. List of accounts impacted: {{accounts}}",
8605+
"close": "Close"
8606+
}
86008607
}
86018608
}

apps/ledger-live-mobile/src/mvvm/components/CustomTopBar/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type TopBarActionIcon = {
1515
callback: () => void;
1616
testID: string;
1717
accessibilityLabel: string;
18+
loading?: boolean;
1819
};
1920

2021
type CustomTopBarProps = {
@@ -65,6 +66,7 @@ export function CustomTopBar({ onMyLedgerPress, customIcons }: Readonly<CustomTo
6566
appearance="transparent"
6667
icon={item.icon}
6768
size="md"
69+
loading={item.loading}
6870
/>
6971
))}
7072
</Box>

apps/ledger-live-mobile/src/mvvm/components/TopBar/TopBarView/index.tsx

Lines changed: 79 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
1-
import React, { useCallback, useMemo } from "react";
1+
import React, { useCallback, useMemo, useState } from "react";
22
import { IconButton } from "@ledgerhq/lumen-ui-rnative";
3-
import { Compass, Bell, BellNotification, Settings } from "@ledgerhq/lumen-ui-rnative/symbols";
3+
import {
4+
Compass,
5+
Bell,
6+
BellNotification,
7+
Settings,
8+
Warning,
9+
} from "@ledgerhq/lumen-ui-rnative/symbols";
410
import { CustomTopBar, TopBarActionIcon } from "LLM/components/CustomTopBar";
511
import { ICON_SIZE } from "LLM/components/TopBar/const";
12+
import { SyncErrorBottomSheet } from "../components/SyncErrorBottomSheet";
613

714
type TopBarViewProps = {
815
onMyLedgerPress: () => void;
916
onDiscoverPress: () => void;
1017
onNotificationsPress: () => void;
1118
onSettingsPress: () => void;
1219
hasUnreadNotifications: boolean;
20+
hasAccounts: boolean;
21+
isSyncError: boolean;
22+
isSyncPending: boolean;
23+
listOfErrorAccountNames: string;
24+
syncAccessibilityLabel: string;
1325
};
1426

1527
export function TopBarView({
@@ -18,19 +30,33 @@ export function TopBarView({
1830
onNotificationsPress,
1931
onSettingsPress,
2032
hasUnreadNotifications,
33+
hasAccounts,
34+
isSyncError,
35+
isSyncPending,
36+
listOfErrorAccountNames,
37+
syncAccessibilityLabel,
2138
}: Readonly<TopBarViewProps>) {
22-
const notificationIcon: NonNullable<React.ComponentProps<typeof IconButton>["icon"]> =
23-
useCallback(
24-
({ size, style }) =>
25-
hasUnreadNotifications ? (
26-
<BellNotification size={size ?? ICON_SIZE} style={style} color="base" />
27-
) : (
28-
<Bell size={size ?? ICON_SIZE} style={style} color="base" />
29-
),
30-
[hasUnreadNotifications],
31-
);
32-
33-
const customIcons: readonly TopBarActionIcon[] = useMemo(
39+
const [isSyncDrawerOpen, setIsSyncDrawerOpen] = useState(false);
40+
41+
const openSyncDrawer = useCallback(() => setIsSyncDrawerOpen(true), []);
42+
const closeSyncDrawer = useCallback(() => setIsSyncDrawerOpen(false), []);
43+
44+
const notificationIcon = useCallback<
45+
NonNullable<React.ComponentProps<typeof IconButton>["icon"]>
46+
>(
47+
({ size, style }) => {
48+
const Icon = hasUnreadNotifications ? BellNotification : Bell;
49+
return <Icon size={size ?? ICON_SIZE} style={style} color="base" />;
50+
},
51+
[hasUnreadNotifications],
52+
);
53+
54+
const syncIcon = useCallback<NonNullable<React.ComponentProps<typeof IconButton>["icon"]>>(
55+
({ size, style }) => <Warning size={size ?? ICON_SIZE} style={style} color="base" />,
56+
[],
57+
);
58+
59+
const baseIcons = useMemo<TopBarActionIcon[]>(
3460
() => [
3561
{
3662
id: "discover",
@@ -54,8 +80,45 @@ export function TopBarView({
5480
accessibilityLabel: "Settings",
5581
},
5682
],
57-
[onDiscoverPress, onNotificationsPress, onSettingsPress, notificationIcon],
83+
[notificationIcon, onDiscoverPress, onNotificationsPress, onSettingsPress],
5884
);
5985

60-
return <CustomTopBar onMyLedgerPress={onMyLedgerPress} customIcons={customIcons} />;
86+
const customIcons = useMemo(() => {
87+
const shouldShowSyncStatus = hasAccounts && isSyncError;
88+
89+
if (!shouldShowSyncStatus) {
90+
return baseIcons;
91+
}
92+
93+
const syncStatusIcon: TopBarActionIcon = {
94+
id: "sync",
95+
icon: syncIcon,
96+
callback: openSyncDrawer,
97+
testID: "topbar-sync",
98+
accessibilityLabel: syncAccessibilityLabel,
99+
loading: isSyncPending,
100+
};
101+
102+
return [syncStatusIcon, ...baseIcons];
103+
}, [
104+
hasAccounts,
105+
isSyncError,
106+
syncIcon,
107+
openSyncDrawer,
108+
syncAccessibilityLabel,
109+
isSyncPending,
110+
baseIcons,
111+
]);
112+
113+
return (
114+
<>
115+
<CustomTopBar onMyLedgerPress={onMyLedgerPress} customIcons={customIcons} />
116+
117+
<SyncErrorBottomSheet
118+
isOpen={isSyncDrawerOpen}
119+
onClose={closeSyncDrawer}
120+
listOfErrorAccountNames={listOfErrorAccountNames}
121+
/>
122+
</>
123+
);
61124
}

apps/ledger-live-mobile/src/mvvm/components/TopBar/__tests__/TopBarView.test.tsx

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ jest.mock("@ledgerhq/lumen-ui-rnative/symbols", () => {
1111
Bell: makeIcon("icon-bell"),
1212
BellNotification: makeIcon("icon-bell-notification"),
1313
Settings: makeIcon("icon-settings"),
14+
Warning: makeIcon("icon-warning"),
1415
Nano: makeIcon("device-icon-nano"),
1516
Flex: makeIcon("device-icon-flex"),
1617
Apex: makeIcon("device-icon-apex"),
@@ -24,20 +25,25 @@ describe("TopBarView", () => {
2425
const onNotificationsPress = jest.fn();
2526
const onSettingsPress = jest.fn();
2627

28+
const defaultProps = {
29+
onMyLedgerPress,
30+
onDiscoverPress,
31+
onNotificationsPress,
32+
onSettingsPress,
33+
hasUnreadNotifications: false,
34+
hasAccounts: false,
35+
isSyncError: false,
36+
isSyncPending: false,
37+
listOfErrorAccountNames: "",
38+
syncAccessibilityLabel: "Synchronize",
39+
};
40+
2741
beforeEach(() => {
2842
jest.clearAllMocks();
2943
});
3044

3145
it("should call expected callbacks when top bar buttons are pressed", async () => {
32-
const { user, getByTestId } = renderWithReactQuery(
33-
<TopBarView
34-
onMyLedgerPress={onMyLedgerPress}
35-
onDiscoverPress={onDiscoverPress}
36-
onNotificationsPress={onNotificationsPress}
37-
onSettingsPress={onSettingsPress}
38-
hasUnreadNotifications={false}
39-
/>,
40-
);
46+
const { user, getByTestId } = renderWithReactQuery(<TopBarView {...defaultProps} />);
4147

4248
await user.press(getByTestId("topbar-myledger"));
4349
await user.press(getByTestId("topbar-discover"));
@@ -51,32 +57,42 @@ describe("TopBarView", () => {
5157
});
5258

5359
it("should render bell icon when there are no unread notifications", () => {
54-
const { getByTestId, queryByTestId } = renderWithReactQuery(
55-
<TopBarView
56-
onMyLedgerPress={onMyLedgerPress}
57-
onDiscoverPress={onDiscoverPress}
58-
onNotificationsPress={onNotificationsPress}
59-
onSettingsPress={onSettingsPress}
60-
hasUnreadNotifications={false}
61-
/>,
62-
);
60+
const { getByTestId, queryByTestId } = renderWithReactQuery(<TopBarView {...defaultProps} />);
6361

6462
expect(getByTestId("icon-bell")).toBeTruthy();
6563
expect(queryByTestId("icon-bell-notification")).toBeNull();
6664
});
6765

6866
it("should render bell notification icon when there are unread notifications", () => {
6967
const { getByTestId, queryByTestId } = renderWithReactQuery(
70-
<TopBarView
71-
onMyLedgerPress={onMyLedgerPress}
72-
onDiscoverPress={onDiscoverPress}
73-
onNotificationsPress={onNotificationsPress}
74-
onSettingsPress={onSettingsPress}
75-
hasUnreadNotifications
76-
/>,
68+
<TopBarView {...defaultProps} hasUnreadNotifications />,
7769
);
7870

7971
expect(getByTestId("icon-bell-notification")).toBeTruthy();
8072
expect(queryByTestId("icon-bell")).toBeNull();
8173
});
74+
75+
it("should render sync icon button when there are accounts and sync errors", () => {
76+
const { getByTestId } = renderWithReactQuery(
77+
<TopBarView {...defaultProps} hasAccounts isSyncError />,
78+
);
79+
80+
expect(getByTestId("topbar-sync")).toBeTruthy();
81+
});
82+
83+
it("should not render sync icon button when there are no accounts", () => {
84+
const { queryByTestId } = renderWithReactQuery(
85+
<TopBarView {...defaultProps} hasAccounts={false} isSyncError />,
86+
);
87+
88+
expect(queryByTestId("topbar-sync")).toBeNull();
89+
});
90+
91+
it("should not render sync icon button when there are no sync errors", () => {
92+
const { queryByTestId } = renderWithReactQuery(
93+
<TopBarView {...defaultProps} hasAccounts isSyncError={false} />,
94+
);
95+
96+
expect(queryByTestId("topbar-sync")).toBeNull();
97+
});
8298
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React from "react";
2+
import {
3+
Text,
4+
BottomSheetHeader,
5+
BottomSheetView,
6+
Button,
7+
Spot,
8+
Box,
9+
} from "@ledgerhq/lumen-ui-rnative";
10+
import { useSafeAreaInsets } from "react-native-safe-area-context";
11+
import { useTranslation } from "~/context/Locale";
12+
import QueuedDrawerBottomSheet from "LLM/components/QueuedDrawer/QueuedDrawerBottomSheet";
13+
14+
type SyncErrorBottomSheetProps = {
15+
isOpen: boolean;
16+
onClose: () => void;
17+
listOfErrorAccountNames: string;
18+
};
19+
20+
export function SyncErrorBottomSheet({
21+
isOpen,
22+
onClose,
23+
listOfErrorAccountNames,
24+
}: Readonly<SyncErrorBottomSheetProps>) {
25+
const { t } = useTranslation();
26+
const { bottom: bottomInset } = useSafeAreaInsets();
27+
28+
return (
29+
<QueuedDrawerBottomSheet isRequestingToBeOpened={isOpen} onClose={onClose} enableDynamicSizing>
30+
<BottomSheetView style={{ paddingBottom: bottomInset + 24 }}>
31+
<BottomSheetHeader appearance="compact" />
32+
<Box lx={{ alignItems: "center", gap: "s24", marginBottom: "s32" }}>
33+
<Spot size={72} appearance="warning" />
34+
<Box lx={{ alignItems: "center" }}>
35+
<Text lx={{ marginBottom: "s12", color: "base" }} typography="heading4">
36+
{t("syncIndicator.bottomSheet.title")}
37+
</Text>
38+
{listOfErrorAccountNames.length > 0 && (
39+
<Text typography="body2" lx={{ color: "muted", textAlign: "center" }}>
40+
{t("syncIndicator.bottomSheet.description", {
41+
accounts: listOfErrorAccountNames,
42+
})}
43+
</Text>
44+
)}
45+
</Box>
46+
</Box>
47+
<Button appearance="base" size="lg" onPress={onClose}>
48+
{t("syncIndicator.bottomSheet.close")}
49+
</Button>
50+
</BottomSheetView>
51+
</QueuedDrawerBottomSheet>
52+
);
53+
}

0 commit comments

Comments
 (0)