Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,41 +1,26 @@
import { useBatchAccountsSyncState } from "@ledgerhq/live-common/bridge/react/index";
import { Account } from "@ledgerhq/types-live";

export interface AccountWithUpToDateCheck {
account: Account;
isUpToDate?: boolean;
}
import { useMemo } from "react";
import { useAccountsSyncStatus as useAccountsSyncStatusCommon } from "@ledgerhq/live-common/bridge/react/index";
import type { AccountWithUpToDateCheck } from "@ledgerhq/live-common/bridge/react/useAccountsSyncStatus";

export interface AccountsSyncStatus {
allAccounts: AccountWithUpToDateCheck["account"][];
listOfErrorAccountNames: string;
areAllAccountsUpToDate: boolean;
}

/**
* Derives sync status from accounts with up-to-date check:
* which accounts have errors and whether all are up to date.
*/
export function useAccountsSyncStatus(
accountsWithUpToDateCheck: AccountWithUpToDateCheck[],
): AccountsSyncStatus {
const allAccounts = accountsWithUpToDateCheck.map(item => item.account);
const isUpToDateByAccountId = new Map(
accountsWithUpToDateCheck.map(item => [item.account.id, item.isUpToDate === true]),
);
const { allAccounts, accountsWithError, areAllAccountsUpToDate } =
useAccountsSyncStatusCommon(accountsWithUpToDateCheck);

const batchState = useBatchAccountsSyncState({ accounts: allAccounts });
const errorTickersSet = new Set<string>();
for (const { syncState, account } of batchState) {
if (syncState.pending) continue;
const isUpToDate = isUpToDateByAccountId.get(account.id);
if (syncState.error || !isUpToDate) {
const listOfErrorAccountNames = useMemo(() => {
const errorTickersSet = new Set<string>();
for (const account of accountsWithError) {
errorTickersSet.add(account.currency.ticker);
}
}

const listOfErrorAccountNames = [...errorTickersSet].join("/");
const areAllAccountsUpToDate = errorTickersSet.size === 0;
return [...errorTickersSet].join("/");
}, [accountsWithError]);

return {
allAccounts,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "~/renderer/reducers/syncRefresh";
import { getEnv } from "@ledgerhq/live-env";
import { track } from "~/renderer/analytics/segment";
import { getAggregateSyncState } from "@ledgerhq/live-common/bridge/react/index";

import { usePortfolioBalanceSync } from "LLD/hooks/usePortfolioBalanceSync";
import { useAccountsSyncStatus } from "./useAccountsSyncStatus";
Expand All @@ -19,10 +20,6 @@ import {
} from "../utils/constants";
import { isRecentUserSyncClick } from "../utils/syncRefreshUtils";

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

const isError = hasCvOrBridgeError || !areAllAccountsUpToDate || hasWalletSyncError;
const { isPending, isError } = getAggregateSyncState({
areAllAccountsUpToDate,
bridgeOrCvPending: false,
bridgeOrCvError: hasCvOrBridgeError,
walletSyncPending: false,
walletSyncError: hasWalletSyncError,
});

const isPlaywrightRun = getEnv("PLAYWRIGHT_RUN");
const userClickSpinMs = isPlaywrightRun
? PLAYWRIGHT_CLICK_SPIN_DURATION_MS
Expand Down
7 changes: 7 additions & 0 deletions apps/ledger-live-mobile/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -8597,5 +8597,12 @@
"swap": "Swap",
"earn": "Earn",
"card": "Card"
},
"syncIndicator": {
"bottomSheet": {
"title": "Couldn't sync some accounts",
"description": "There was a temporary network issue. Your assets are safe. List of accounts impacted: {{accounts}}",
"close": "Close"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type TopBarActionIcon = {
callback: () => void;
testID: string;
accessibilityLabel: string;
loading?: boolean;
};

type CustomTopBarProps = {
Expand Down Expand Up @@ -65,6 +66,7 @@ export function CustomTopBar({ onMyLedgerPress, customIcons }: Readonly<CustomTo
appearance="transparent"
icon={item.icon}
size="md"
loading={item.loading}
/>
))}
</Box>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import React, { useCallback, useMemo } from "react";
import React, { useCallback, useMemo, useState } from "react";
import { IconButton } from "@ledgerhq/lumen-ui-rnative";
import { Compass, Bell, BellNotification, Settings } from "@ledgerhq/lumen-ui-rnative/symbols";
import {
Compass,
Bell,
BellNotification,
Settings,
Warning,
} from "@ledgerhq/lumen-ui-rnative/symbols";
import { CustomTopBar, TopBarActionIcon } from "LLM/components/CustomTopBar";
import { ICON_SIZE } from "LLM/components/TopBar/const";
import { SyncErrorBottomSheet } from "../components/SyncErrorBottomSheet";

type TopBarViewProps = {
onMyLedgerPress: () => void;
onDiscoverPress: () => void;
onNotificationsPress: () => void;
onSettingsPress: () => void;
hasUnreadNotifications: boolean;
hasAccounts: boolean;
isSyncError: boolean;
isSyncPending: boolean;
listOfErrorAccountNames: string;
syncAccessibilityLabel: string;
};

export function TopBarView({
Expand All @@ -18,19 +30,33 @@ export function TopBarView({
onNotificationsPress,
onSettingsPress,
hasUnreadNotifications,
hasAccounts,
isSyncError,
isSyncPending,
listOfErrorAccountNames,
syncAccessibilityLabel,
}: Readonly<TopBarViewProps>) {
const notificationIcon: NonNullable<React.ComponentProps<typeof IconButton>["icon"]> =
useCallback(
({ size, style }) =>
hasUnreadNotifications ? (
<BellNotification size={size ?? ICON_SIZE} style={style} color="base" />
) : (
<Bell size={size ?? ICON_SIZE} style={style} color="base" />
),
[hasUnreadNotifications],
);

const customIcons: readonly TopBarActionIcon[] = useMemo(
const [isSyncDrawerOpen, setIsSyncDrawerOpen] = useState(false);

const openSyncDrawer = useCallback(() => setIsSyncDrawerOpen(true), []);
const closeSyncDrawer = useCallback(() => setIsSyncDrawerOpen(false), []);

const notificationIcon = useCallback<
NonNullable<React.ComponentProps<typeof IconButton>["icon"]>
>(
({ size, style }) => {
const Icon = hasUnreadNotifications ? BellNotification : Bell;
return <Icon size={size ?? ICON_SIZE} style={style} color="base" />;
},
[hasUnreadNotifications],
);

const syncIcon = useCallback<NonNullable<React.ComponentProps<typeof IconButton>["icon"]>>(
({ size, style }) => <Warning size={size ?? ICON_SIZE} style={style} color="base" />,
[],
);

const baseIcons = useMemo<TopBarActionIcon[]>(
() => [
{
id: "discover",
Expand All @@ -54,8 +80,45 @@ export function TopBarView({
accessibilityLabel: "Settings",
},
],
[onDiscoverPress, onNotificationsPress, onSettingsPress, notificationIcon],
[notificationIcon, onDiscoverPress, onNotificationsPress, onSettingsPress],
);

return <CustomTopBar onMyLedgerPress={onMyLedgerPress} customIcons={customIcons} />;
const customIcons = useMemo(() => {
const shouldShowSyncStatus = hasAccounts && isSyncError;

if (!shouldShowSyncStatus) {
return baseIcons;
}

const syncStatusIcon: TopBarActionIcon = {
id: "sync",
icon: syncIcon,
callback: openSyncDrawer,
testID: "topbar-sync",
accessibilityLabel: syncAccessibilityLabel,
loading: isSyncPending,
};

return [syncStatusIcon, ...baseIcons];
}, [
hasAccounts,
isSyncError,
syncIcon,
openSyncDrawer,
syncAccessibilityLabel,
isSyncPending,
baseIcons,
]);

return (
<>
<CustomTopBar onMyLedgerPress={onMyLedgerPress} customIcons={customIcons} />

<SyncErrorBottomSheet
isOpen={isSyncDrawerOpen}
onClose={closeSyncDrawer}
listOfErrorAccountNames={listOfErrorAccountNames}
/>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ jest.mock("@ledgerhq/lumen-ui-rnative/symbols", () => {
Bell: makeIcon("icon-bell"),
BellNotification: makeIcon("icon-bell-notification"),
Settings: makeIcon("icon-settings"),
Warning: makeIcon("icon-warning"),
Nano: makeIcon("device-icon-nano"),
Flex: makeIcon("device-icon-flex"),
Apex: makeIcon("device-icon-apex"),
Expand All @@ -24,20 +25,25 @@ describe("TopBarView", () => {
const onNotificationsPress = jest.fn();
const onSettingsPress = jest.fn();

const defaultProps = {
onMyLedgerPress,
onDiscoverPress,
onNotificationsPress,
onSettingsPress,
hasUnreadNotifications: false,
hasAccounts: false,
isSyncError: false,
isSyncPending: false,
listOfErrorAccountNames: "",
syncAccessibilityLabel: "Synchronize",
};

beforeEach(() => {
jest.clearAllMocks();
});

it("should call expected callbacks when top bar buttons are pressed", async () => {
const { user, getByTestId } = renderWithReactQuery(
<TopBarView
onMyLedgerPress={onMyLedgerPress}
onDiscoverPress={onDiscoverPress}
onNotificationsPress={onNotificationsPress}
onSettingsPress={onSettingsPress}
hasUnreadNotifications={false}
/>,
);
const { user, getByTestId } = renderWithReactQuery(<TopBarView {...defaultProps} />);

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

it("should render bell icon when there are no unread notifications", () => {
const { getByTestId, queryByTestId } = renderWithReactQuery(
<TopBarView
onMyLedgerPress={onMyLedgerPress}
onDiscoverPress={onDiscoverPress}
onNotificationsPress={onNotificationsPress}
onSettingsPress={onSettingsPress}
hasUnreadNotifications={false}
/>,
);
const { getByTestId, queryByTestId } = renderWithReactQuery(<TopBarView {...defaultProps} />);

expect(getByTestId("icon-bell")).toBeTruthy();
expect(queryByTestId("icon-bell-notification")).toBeNull();
});

it("should render bell notification icon when there are unread notifications", () => {
const { getByTestId, queryByTestId } = renderWithReactQuery(
<TopBarView
onMyLedgerPress={onMyLedgerPress}
onDiscoverPress={onDiscoverPress}
onNotificationsPress={onNotificationsPress}
onSettingsPress={onSettingsPress}
hasUnreadNotifications
/>,
<TopBarView {...defaultProps} hasUnreadNotifications />,
);

expect(getByTestId("icon-bell-notification")).toBeTruthy();
expect(queryByTestId("icon-bell")).toBeNull();
});

it("should render sync icon button when there are accounts and sync errors", () => {
const { getByTestId } = renderWithReactQuery(
<TopBarView {...defaultProps} hasAccounts isSyncError />,
);

expect(getByTestId("topbar-sync")).toBeTruthy();
});

it("should not render sync icon button when there are no accounts", () => {
const { queryByTestId } = renderWithReactQuery(
<TopBarView {...defaultProps} hasAccounts={false} isSyncError />,
);

expect(queryByTestId("topbar-sync")).toBeNull();
});

it("should not render sync icon button when there are no sync errors", () => {
const { queryByTestId } = renderWithReactQuery(
<TopBarView {...defaultProps} hasAccounts isSyncError={false} />,
);

expect(queryByTestId("topbar-sync")).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from "react";
import {
Text,
BottomSheetHeader,
BottomSheetView,
Button,
Spot,
Box,
} from "@ledgerhq/lumen-ui-rnative";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "~/context/Locale";
import QueuedDrawerBottomSheet from "LLM/components/QueuedDrawer/QueuedDrawerBottomSheet";

type SyncErrorBottomSheetProps = {
isOpen: boolean;
onClose: () => void;
listOfErrorAccountNames: string;
};

export function SyncErrorBottomSheet({
isOpen,
onClose,
listOfErrorAccountNames,
}: Readonly<SyncErrorBottomSheetProps>) {
const { t } = useTranslation();
const { bottom: bottomInset } = useSafeAreaInsets();

return (
<QueuedDrawerBottomSheet isRequestingToBeOpened={isOpen} onClose={onClose} enableDynamicSizing>
<BottomSheetView style={{ paddingBottom: bottomInset + 24 }}>
<BottomSheetHeader appearance="compact" />
<Box lx={{ alignItems: "center", gap: "s24", marginBottom: "s32" }}>
<Spot size={72} appearance="warning" />
<Box lx={{ alignItems: "center" }}>
<Text lx={{ marginBottom: "s12", color: "base" }} typography="heading4">
{t("syncIndicator.bottomSheet.title")}
</Text>
{listOfErrorAccountNames.length > 0 && (
<Text typography="body2" lx={{ color: "muted", textAlign: "center" }}>
{t("syncIndicator.bottomSheet.description", {
accounts: listOfErrorAccountNames,
})}
</Text>
)}
</Box>
</Box>
<Button appearance="base" size="lg" onPress={onClose}>
{t("syncIndicator.bottomSheet.close")}
</Button>
</BottomSheetView>
</QueuedDrawerBottomSheet>
);
}
Loading
Loading