diff --git a/apps/ledger-live-desktop/src/mvvm/components/TopBar/hooks/useAccountsSyncStatus.ts b/apps/ledger-live-desktop/src/mvvm/components/TopBar/hooks/useAccountsSyncStatus.ts index 5ba28675e2b..b0b758ed4ee 100644 --- a/apps/ledger-live-desktop/src/mvvm/components/TopBar/hooks/useAccountsSyncStatus.ts +++ b/apps/ledger-live-desktop/src/mvvm/components/TopBar/hooks/useAccountsSyncStatus.ts @@ -1,10 +1,6 @@ -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"][]; @@ -12,30 +8,19 @@ export interface AccountsSyncStatus { 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(); - 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(); + 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, diff --git a/apps/ledger-live-desktop/src/mvvm/components/TopBar/hooks/useActivityIndicator.ts b/apps/ledger-live-desktop/src/mvvm/components/TopBar/hooks/useActivityIndicator.ts index 2570b52fe35..677a98cc2d7 100644 --- a/apps/ledger-live-desktop/src/mvvm/components/TopBar/hooks/useActivityIndicator.ts +++ b/apps/ledger-live-desktop/src/mvvm/components/TopBar/hooks/useActivityIndicator.ts @@ -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"; @@ -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); @@ -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 diff --git a/apps/ledger-live-mobile/src/locales/en/common.json b/apps/ledger-live-mobile/src/locales/en/common.json index c0c50edd631..6d3b30f6223 100644 --- a/apps/ledger-live-mobile/src/locales/en/common.json +++ b/apps/ledger-live-mobile/src/locales/en/common.json @@ -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" + } } } diff --git a/apps/ledger-live-mobile/src/mvvm/components/CustomTopBar/index.tsx b/apps/ledger-live-mobile/src/mvvm/components/CustomTopBar/index.tsx index bf09d3d4c21..fc820a1ac97 100644 --- a/apps/ledger-live-mobile/src/mvvm/components/CustomTopBar/index.tsx +++ b/apps/ledger-live-mobile/src/mvvm/components/CustomTopBar/index.tsx @@ -15,6 +15,7 @@ export type TopBarActionIcon = { callback: () => void; testID: string; accessibilityLabel: string; + loading?: boolean; }; type CustomTopBarProps = { @@ -65,6 +66,7 @@ export function CustomTopBar({ onMyLedgerPress, customIcons }: Readonly ))} diff --git a/apps/ledger-live-mobile/src/mvvm/components/TopBar/TopBarView/index.tsx b/apps/ledger-live-mobile/src/mvvm/components/TopBar/TopBarView/index.tsx index 430c70d83aa..ea9c60f3d99 100644 --- a/apps/ledger-live-mobile/src/mvvm/components/TopBar/TopBarView/index.tsx +++ b/apps/ledger-live-mobile/src/mvvm/components/TopBar/TopBarView/index.tsx @@ -1,8 +1,15 @@ -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; @@ -10,6 +17,11 @@ type TopBarViewProps = { onNotificationsPress: () => void; onSettingsPress: () => void; hasUnreadNotifications: boolean; + hasAccounts: boolean; + isSyncError: boolean; + isSyncPending: boolean; + listOfErrorAccountNames: string; + syncAccessibilityLabel: string; }; export function TopBarView({ @@ -18,19 +30,33 @@ export function TopBarView({ onNotificationsPress, onSettingsPress, hasUnreadNotifications, + hasAccounts, + isSyncError, + isSyncPending, + listOfErrorAccountNames, + syncAccessibilityLabel, }: Readonly) { - const notificationIcon: NonNullable["icon"]> = - useCallback( - ({ size, style }) => - hasUnreadNotifications ? ( - - ) : ( - - ), - [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["icon"]> + >( + ({ size, style }) => { + const Icon = hasUnreadNotifications ? BellNotification : Bell; + return ; + }, + [hasUnreadNotifications], + ); + + const syncIcon = useCallback["icon"]>>( + ({ size, style }) => , + [], + ); + + const baseIcons = useMemo( () => [ { id: "discover", @@ -54,8 +80,45 @@ export function TopBarView({ accessibilityLabel: "Settings", }, ], - [onDiscoverPress, onNotificationsPress, onSettingsPress, notificationIcon], + [notificationIcon, onDiscoverPress, onNotificationsPress, onSettingsPress], ); - return ; + 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 ( + <> + + + + + ); } diff --git a/apps/ledger-live-mobile/src/mvvm/components/TopBar/__tests__/TopBarView.test.tsx b/apps/ledger-live-mobile/src/mvvm/components/TopBar/__tests__/TopBarView.test.tsx index 5c2cb79f3bb..2bc91ffbf40 100644 --- a/apps/ledger-live-mobile/src/mvvm/components/TopBar/__tests__/TopBarView.test.tsx +++ b/apps/ledger-live-mobile/src/mvvm/components/TopBar/__tests__/TopBarView.test.tsx @@ -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"), @@ -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( - , - ); + const { user, getByTestId } = renderWithReactQuery(); await user.press(getByTestId("topbar-myledger")); await user.press(getByTestId("topbar-discover")); @@ -51,15 +57,7 @@ describe("TopBarView", () => { }); it("should render bell icon when there are no unread notifications", () => { - const { getByTestId, queryByTestId } = renderWithReactQuery( - , - ); + const { getByTestId, queryByTestId } = renderWithReactQuery(); expect(getByTestId("icon-bell")).toBeTruthy(); expect(queryByTestId("icon-bell-notification")).toBeNull(); @@ -67,16 +65,34 @@ describe("TopBarView", () => { it("should render bell notification icon when there are unread notifications", () => { const { getByTestId, queryByTestId } = renderWithReactQuery( - , + , ); 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( + , + ); + + expect(getByTestId("topbar-sync")).toBeTruthy(); + }); + + it("should not render sync icon button when there are no accounts", () => { + const { queryByTestId } = renderWithReactQuery( + , + ); + + expect(queryByTestId("topbar-sync")).toBeNull(); + }); + + it("should not render sync icon button when there are no sync errors", () => { + const { queryByTestId } = renderWithReactQuery( + , + ); + + expect(queryByTestId("topbar-sync")).toBeNull(); + }); }); diff --git a/apps/ledger-live-mobile/src/mvvm/components/TopBar/components/SyncErrorBottomSheet.tsx b/apps/ledger-live-mobile/src/mvvm/components/TopBar/components/SyncErrorBottomSheet.tsx new file mode 100644 index 00000000000..bd92d194307 --- /dev/null +++ b/apps/ledger-live-mobile/src/mvvm/components/TopBar/components/SyncErrorBottomSheet.tsx @@ -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) { + const { t } = useTranslation(); + const { bottom: bottomInset } = useSafeAreaInsets(); + + return ( + + + + + + + + {t("syncIndicator.bottomSheet.title")} + + {listOfErrorAccountNames.length > 0 && ( + + {t("syncIndicator.bottomSheet.description", { + accounts: listOfErrorAccountNames, + })} + + )} + + + + + + ); +} diff --git a/apps/ledger-live-mobile/src/mvvm/components/TopBar/hooks/useSyncIndicator.ts b/apps/ledger-live-mobile/src/mvvm/components/TopBar/hooks/useSyncIndicator.ts new file mode 100644 index 00000000000..3580d2912ea --- /dev/null +++ b/apps/ledger-live-mobile/src/mvvm/components/TopBar/hooks/useSyncIndicator.ts @@ -0,0 +1,42 @@ +import { useMemo } from "react"; +import { useSelector } from "~/context/hooks"; +import { accountsWithUpToDateCheckSelector, hasNoAccountsSelector } from "~/reducers/accounts"; +import { useBatchMaybeAccountName } from "~/reducers/wallet"; +import { useCountervaluesPolling } from "@ledgerhq/live-countervalues-react"; +import { getDefaultAccountName } from "@ledgerhq/live-wallet/accountName"; +import { + useAccountsSyncStatus, + useGlobalSyncState, +} from "@ledgerhq/live-common/bridge/react/index"; + +export function useSyncIndicator() { + const hasAccounts = !useSelector(hasNoAccountsSelector); + const accountsWithUpToDateCheck = useSelector(accountsWithUpToDateCheckSelector); + const cvPolling = useCountervaluesPolling(); + const globalSyncState = useGlobalSyncState(); + + const { accountsWithError } = useAccountsSyncStatus(accountsWithUpToDateCheck); + + const maybeAccountNames = useBatchMaybeAccountName(accountsWithError); + const listOfErrorAccountNames = useMemo( + () => + maybeAccountNames + .map((name, i) => name ?? getDefaultAccountName(accountsWithError[i])) + .join("/"), + [maybeAccountNames, accountsWithError], + ); + + const hasSyncError = accountsWithError.length > 0; + const isPending = cvPolling.pending || globalSyncState.pending; + const isError = hasSyncError || (!isPending && (!!cvPolling.error || !!globalSyncState.error)); + + const syncAccessibilityLabel = isError ? "Sync error" : isPending ? "Syncing" : "Synchronize"; + + return { + hasAccounts, + isError, + isPending, + listOfErrorAccountNames, + syncAccessibilityLabel, + }; +} diff --git a/apps/ledger-live-mobile/src/mvvm/components/TopBar/index.tsx b/apps/ledger-live-mobile/src/mvvm/components/TopBar/index.tsx index 4e2be9cac99..6b7fd3e623b 100644 --- a/apps/ledger-live-mobile/src/mvvm/components/TopBar/index.tsx +++ b/apps/ledger-live-mobile/src/mvvm/components/TopBar/index.tsx @@ -17,6 +17,11 @@ export function TopBar({ screenName }: Readonly) { onNotificationsPress, onSettingsPress, hasUnreadNotifications, + hasAccounts, + isSyncError, + isSyncPending, + listOfErrorAccountNames, + syncAccessibilityLabel, } = useTopBarViewModel(navigation, screenName); return ( @@ -26,6 +31,11 @@ export function TopBar({ screenName }: Readonly) { onNotificationsPress={onNotificationsPress} onSettingsPress={onSettingsPress} hasUnreadNotifications={hasUnreadNotifications} + hasAccounts={hasAccounts} + isSyncError={isSyncError} + isSyncPending={isSyncPending} + listOfErrorAccountNames={listOfErrorAccountNames} + syncAccessibilityLabel={syncAccessibilityLabel} /> ); } diff --git a/apps/ledger-live-mobile/src/mvvm/components/TopBar/useTopBarViewModel.ts b/apps/ledger-live-mobile/src/mvvm/components/TopBar/useTopBarViewModel.ts index 6931a2828f1..14789b1a21e 100644 --- a/apps/ledger-live-mobile/src/mvvm/components/TopBar/useTopBarViewModel.ts +++ b/apps/ledger-live-mobile/src/mvvm/components/TopBar/useTopBarViewModel.ts @@ -4,6 +4,7 @@ import { NavigatorName, ScreenName } from "~/const"; import useDynamicContent from "~/dynamicContent/useDynamicContent"; import { track } from "~/analytics"; import useFeature from "@ledgerhq/live-common/featureFlags/useFeature"; +import { useSyncIndicator } from "./hooks/useSyncIndicator"; export function useTopBarViewModel( navigation: NativeStackNavigationProp<{ [key: string]: object | undefined }>, @@ -12,6 +13,8 @@ export function useTopBarViewModel( const { notificationCards } = useDynamicContent(); const web3hub = useFeature("web3hub"); const page = screenName ?? ScreenName.Portfolio; + const { hasAccounts, isError, isPending, listOfErrorAccountNames, syncAccessibilityLabel } = + useSyncIndicator(); const hasUnreadNotifications = useMemo( () => notificationCards.some(n => !n.viewed), @@ -61,5 +64,10 @@ export function useTopBarViewModel( onNotificationsPress, onSettingsPress, hasUnreadNotifications, + hasAccounts, + isSyncError: isError, + isSyncPending: isPending, + listOfErrorAccountNames, + syncAccessibilityLabel, }; } diff --git a/apps/ledger-live-mobile/src/reducers/accounts.ts b/apps/ledger-live-mobile/src/reducers/accounts.ts index 9929446ac1d..fc0dd665f69 100644 --- a/apps/ledger-live-mobile/src/reducers/accounts.ts +++ b/apps/ledger-live-mobile/src/reducers/accounts.ts @@ -25,6 +25,7 @@ import { makeEmptyTokenAccount, isAccountBalanceUnconfirmed, } from "@ledgerhq/live-common/account/index"; +import { getEnv } from "@ledgerhq/live-env"; import type { AccountsState, State } from "./types"; import type { AccountsDeleteAccountPayload, @@ -308,6 +309,21 @@ export const isUpToDateSelector = createSelector(accountsSelector, accounts => accounts.every(isUpToDateAccount), ); +export const accountsWithUpToDateCheckSelector = createSelector(accountsSelector, accounts => + accounts.map(a => { + const { lastSyncDate } = a; + const { blockAvgTime } = a.currency; + let isUpToDate = false; + // if (blockAvgTime) { + // const outdated = + // Date.now() - (lastSyncDate.getTime() || 0) > + // blockAvgTime * 1000 + getEnv("SYNC_OUTDATED_CONSIDERED_DELAY") * 0; + // isUpToDate = !outdated; + // } + return { account: a, isUpToDate }; + }), +); + function accountHasPositiveBalance(account: AccountLike) { return Boolean(account.balance?.gt(0)); } diff --git a/libs/ledger-live-common/src/bridge/react/index.ts b/libs/ledger-live-common/src/bridge/react/index.ts index 6cdc270ea7c..f94046257dd 100644 --- a/libs/ledger-live-common/src/bridge/react/index.ts +++ b/libs/ledger-live-common/src/bridge/react/index.ts @@ -4,3 +4,5 @@ export * from "./SyncOneAccountOnMount"; export * from "./SyncSkipUnderPriority"; export * from "./useAccountSyncState"; export * from "./useGlobalSyncState"; +export * from "./useAccountsSyncStatus"; +export * from "./syncIndicatorUtils"; diff --git a/libs/ledger-live-common/src/bridge/react/syncIndicatorUtils.ts b/libs/ledger-live-common/src/bridge/react/syncIndicatorUtils.ts new file mode 100644 index 00000000000..07715ff4bf2 --- /dev/null +++ b/libs/ledger-live-common/src/bridge/react/syncIndicatorUtils.ts @@ -0,0 +1,42 @@ +import type { Sync } from "./types"; + +export interface AggregateSyncStateParams { + areAllAccountsUpToDate: boolean; + bridgeOrCvPending: boolean; + bridgeOrCvError: boolean; + walletSyncPending: boolean; + walletSyncError: boolean; +} + +export function getAggregateSyncState({ + areAllAccountsUpToDate, + bridgeOrCvPending, + bridgeOrCvError, + walletSyncPending, + walletSyncError, +}: AggregateSyncStateParams): { isPending: boolean; isError: boolean } { + const isPending = bridgeOrCvPending || walletSyncPending; + const hasBridgeOrCvSyncError = !isPending && bridgeOrCvError; + const isError = hasBridgeOrCvSyncError || !areAllAccountsUpToDate || walletSyncError; + return { isPending, isError }; +} + +export interface CreateTriggerSyncParams { + onUserRefresh: () => void; + poll: () => void; + bridgeSync: Sync; + reason?: string; +} + +export function createTriggerSync({ + onUserRefresh, + poll, + bridgeSync, + reason = "user-click", +}: CreateTriggerSyncParams): () => void { + return () => { + onUserRefresh(); + poll(); + bridgeSync({ type: "SYNC_ALL_ACCOUNTS", priority: 5, reason }); + }; +} diff --git a/libs/ledger-live-common/src/bridge/react/useAccountsSyncStatus.ts b/libs/ledger-live-common/src/bridge/react/useAccountsSyncStatus.ts new file mode 100644 index 00000000000..f1766447f77 --- /dev/null +++ b/libs/ledger-live-common/src/bridge/react/useAccountsSyncStatus.ts @@ -0,0 +1,43 @@ +import { useBatchAccountsSyncState } from "./useAccountSyncState"; +import type { Account } from "@ledgerhq/types-live"; + +export interface AccountWithUpToDateCheck { + account: Account; + isUpToDate?: boolean; +} + +export interface AccountsSyncStatus { + allAccounts: Account[]; + accountsWithError: Account[]; + areAllAccountsUpToDate: boolean; + lastSyncMs: number; +} + +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 batchState = useBatchAccountsSyncState({ accounts: allAccounts }); + const accountsWithError: Account[] = []; + for (const { syncState, account } of batchState) { + if (syncState.pending) continue; + const isUpToDate = isUpToDateByAccountId.get(account.id); + if (syncState.error || !isUpToDate) { + accountsWithError.push(account); + } + } + + const areAllAccountsUpToDate = accountsWithError.length === 0; + const lastSyncMs = Math.max(...allAccounts.map(a => a.lastSyncDate?.getTime() ?? 0), 0); + + return { + allAccounts, + accountsWithError, + areAllAccountsUpToDate, + lastSyncMs, + }; +}