diff --git a/nym-wallet/package.json b/nym-wallet/package.json index b3964e4947..09541d0e09 100644 --- a/nym-wallet/package.json +++ b/nym-wallet/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@babel/helper-simple-access": "^7.25.9", + "@emotion/cache": "^11.14.0", "@emotion/react": "^11.7.0", "@emotion/styled": "^11.6.0", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", @@ -38,6 +39,7 @@ "@nymproject/node-tester": "^1.3.1", "@nymproject/react": "^1.0.0", "@nymproject/types": "^1.0.0", + "@tanstack/react-query": "^5.62.0", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-opener": "^2.5.3", @@ -46,7 +48,6 @@ "@tauri-apps/tauri-forage": "^1.0.0-beta.2", "big.js": "^6.2.1", "bs58": "^4.0.1", - "@emotion/cache": "^11.14.0", "clsx": "^1.1.1", "date-fns": "^2.28.0", "joi": "^17.11.0", @@ -138,4 +139,4 @@ "@types/minimatch": "5.1.2" }, "private": false -} \ No newline at end of file +} diff --git a/nym-wallet/src-tauri/src/log.rs b/nym-wallet/src-tauri/src/log.rs index f9b61aaff2..ae9e897190 100644 --- a/nym-wallet/src-tauri/src/log.rs +++ b/nym-wallet/src-tauri/src/log.rs @@ -62,9 +62,16 @@ pub fn setup_logging(app_handle: tauri::AppHandle) -> Result<(), log::SetLoggerE message: record.args().to_string(), level: record.level().into(), }; - // Tauri 2: target the log webview explicitly so the dedicated window receives events. - if let Some(log_win) = log_window_app.get_webview_window("log") { - let _ = log_win.emit("log://log", msg); + let app = log_window_app.clone(); + let app_for_emit = app.clone(); + if let Err(e) = app.run_on_main_thread(move || { + if let Some(log_win) = app_for_emit.get_webview_window("log") { + if let Err(err) = log_win.emit("log://log", msg) { + log::warn!("failed to emit log line to log webview: {err}"); + } + } + }) { + log::warn!("failed to schedule log line for log webview: {e}"); } })); diff --git a/nym-wallet/src-tauri/src/operations/app/window.rs b/nym-wallet/src-tauri/src/operations/app/window.rs index 1d5482e409..a40c1d9688 100644 --- a/nym-wallet/src-tauri/src/operations/app/window.rs +++ b/nym-wallet/src-tauri/src/operations/app/window.rs @@ -34,6 +34,7 @@ async fn create_window( ) .title("Nym Wallet") .background_color(NYM_WALLET_WEBVIEW_BG) + .use_https_scheme(true) .build() { Ok(window) => { diff --git a/nym-wallet/src-tauri/src/operations/help/log.rs b/nym-wallet/src-tauri/src/operations/help/log.rs index 08a90a0085..81a1d7fc0a 100644 --- a/nym-wallet/src-tauri/src/operations/help/log.rs +++ b/nym-wallet/src-tauri/src/operations/help/log.rs @@ -1,5 +1,6 @@ use crate::error::BackendError; use crate::webview_theme::NYM_WALLET_WEBVIEW_BG; +use tauri::webview::PageLoadEvent; use tauri::Manager; #[tauri::command] @@ -20,6 +21,22 @@ pub fn help_log_toggle_window(app_handle: tauri::AppHandle) -> Result<(), Backen ) .title("Nym Wallet Logs") .background_color(NYM_WALLET_WEBVIEW_BG) + .use_https_scheme(true) + .on_page_load(|window, payload| match payload.event() { + PageLoadEvent::Started => { + log::debug!("Log webview load started: {}", payload.url()); + } + PageLoadEvent::Finished => { + log::info!("Log webview load finished: {}", payload.url()); + if std::env::var("NYM_WALLET_LOG_WEBVIEW_DEVTOOLS") + .ok() + .as_deref() + == Some("1") + { + window.open_devtools(); + } + } + }) .build() { Ok(window) => { diff --git a/nym-wallet/src/common.tsx b/nym-wallet/src/common.tsx index 03b90c8d62..15f1739f34 100644 --- a/nym-wallet/src/common.tsx +++ b/nym-wallet/src/common.tsx @@ -1,4 +1,5 @@ import React, { ComponentType, useEffect } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ErrorBoundary } from 'react-error-boundary'; import { BrowserRouter, HashRouter } from 'react-router-dom'; import { SnackbarProvider } from 'notistack'; @@ -11,6 +12,15 @@ import { useTauriTextEditingClipboard } from './hooks/useTauriTextEditingClipboa type RouterComponent = ComponentType<{ children?: React.ReactNode }>; +const walletQueryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + const ClipboardBridge: FCWithChildren = ({ children }) => { useTauriTextEditingClipboard(); return children; @@ -48,18 +58,20 @@ export const AppCommon = ({ return ( - - - - {children} - - - + + + + + {children} + + + + ); diff --git a/nym-wallet/src/components/LogViewer/index.tsx b/nym-wallet/src/components/LogViewer/index.tsx index 424280f15d..370c801615 100644 --- a/nym-wallet/src/components/LogViewer/index.tsx +++ b/nym-wallet/src/components/LogViewer/index.tsx @@ -3,16 +3,24 @@ import type { UnlistenFn } from '@tauri-apps/api/event'; import { getCurrentWebview } from '@tauri-apps/api/webview'; import { Box, - Paper, + Button, Chip, + Paper, + Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, + Typography, useTheme, } from '@mui/material'; +import type { Theme } from '@mui/material/styles'; +import { writeText } from '@tauri-apps/plugin-clipboard-manager'; +import { useSnackbar } from 'notistack'; +import { helpLogToggleWindow } from 'src/requests/logging'; +import { Console } from 'src/utils/console'; // see https://github.com/tauri-apps/tauri-plugin-log/blob/dev/webview-src/index.ts#L4 enum LogLevel { @@ -40,7 +48,7 @@ const getLogLevelName = (value: LogLevel) => { } }; -const getLogLevelColor = (level: LogLevel, theme: any) => { +const getLogLevelColor = (level: LogLevel, theme: Theme) => { switch (level) { case LogLevel.Trace: return { @@ -90,6 +98,7 @@ interface RecordPayload { export const LogViewer: FC = () => { const theme = useTheme(); + const { enqueueSnackbar } = useSnackbar(); const unlisten = useRef(null); const [messages, setMessages] = useState([]); const [messageCount, setMessageCount] = useState(0); @@ -131,6 +140,44 @@ export const LogViewer: FC = () => { }; }, []); + const handleCloseLogs = async () => { + try { + await helpLogToggleWindow(); + } catch (e) { + Console.error(e); + enqueueSnackbar('Could not close the log window', { variant: 'error' }); + } + }; + + const formatLine = (m: RecordPayload) => `${getLogLevelName(m.level)}\t${m.message}`; + + const handleCopyAll = async () => { + if (messages.length === 0) return; + try { + const chronological = [...messages].reverse(); + await writeText(chronological.map(formatLine).join('\n')); + } catch (e) { + Console.error(e); + enqueueSnackbar('Could not copy logs to the clipboard', { variant: 'error' }); + } + }; + + const handleCopyLatest = async () => { + const latest = messages[0]; + if (!latest) return; + try { + await writeText(formatLine(latest)); + } catch (e) { + Console.error(e); + enqueueSnackbar('Could not copy to the clipboard', { variant: 'error' }); + } + }; + + const handleClearList = () => { + setMessages([]); + setMessageCount(0); + }; + return ( { - {messages.map((m, index) => { - const levelColors = getLogLevelColor(m.level, theme); - return ( - - + + + No log lines yet. The wallet streams new lines here in real time - older log history is not + replayed. Use the wallet or set RUST_LOG for more detail. Close with the button below or use Open + logs again in Settings - Advanced (same action toggles this window). + + + + ) : ( + messages.map((m, index) => { + const levelColors = getLogLevelColor(m.level, theme); + return ( + - - - - {m.message} - - - ); - })} + > + + + + {m.message} + + + ); + }) + )} @@ -235,14 +298,32 @@ export const LogViewer: FC = () => { - {messageCount} log entries since opening this window + + + + {messageCount} log entries since opening this window + + + + + + + + + ); diff --git a/nym-wallet/src/context/delegationQuery.ts b/nym-wallet/src/context/delegationQuery.ts new file mode 100644 index 0000000000..3431db38cd --- /dev/null +++ b/nym-wallet/src/context/delegationQuery.ts @@ -0,0 +1,44 @@ +import type { DelegationWithEverything, WrappedDelegationEvent } from '@nymproject/types'; +import { getAllPendingDelegations, getDelegationSummary } from 'src/requests'; +import { decCoinToDisplay } from 'src/utils'; + +export type DelegationSummaryBundle = { + delegations: (DelegationWithEverything | WrappedDelegationEvent)[]; + pendingDelegations: WrappedDelegationEvent[]; + totalDelegations: string; + totalRewards: string; + totalDelegationsAndRewards: string; +}; + +export async function fetchDelegationSummaryQuery(): Promise { + const data = await getDelegationSummary(); + const pending = await getAllPendingDelegations(); + + const pendingOnNewNodes = pending.filter((event) => { + const some = data.delegations.some(({ node_identity }) => node_identity === event.node_identity); + return !some; + }); + const items = data.delegations.map((delegation) => ({ + ...delegation, + amount: decCoinToDisplay(delegation.amount), + unclaimed_rewards: delegation.unclaimed_rewards && decCoinToDisplay(delegation.unclaimed_rewards), + cost_params: delegation.cost_params && { + ...delegation.cost_params, + interval_operating_cost: decCoinToDisplay(delegation.cost_params.interval_operating_cost), + }, + })); + + const td = parseFloat(data.total_delegations.amount); + const tr = parseFloat(data.total_rewards.amount); + const delegationsAndRewards = Number.isFinite(td) && Number.isFinite(tr) ? (td + tr).toFixed(6) : '0'; + + return { + delegations: [...items, ...pendingOnNewNodes], + pendingDelegations: pending, + totalDelegations: `${data.total_delegations.amount} ${data.total_delegations.denom}`, + totalRewards: `${data.total_rewards.amount} ${data.total_rewards.denom}`, + totalDelegationsAndRewards: `${delegationsAndRewards} ${data.total_delegations.denom}`, + }; +} + +export { delegationQueryKeys } from './delegationQueryKeys'; diff --git a/nym-wallet/src/context/delegationQueryKeys.test.ts b/nym-wallet/src/context/delegationQueryKeys.test.ts new file mode 100644 index 0000000000..12197eb769 --- /dev/null +++ b/nym-wallet/src/context/delegationQueryKeys.test.ts @@ -0,0 +1,7 @@ +import { delegationQueryKeys } from './delegationQueryKeys'; + +describe('delegationQueryKeys', () => { + it('builds a stable summary key per client address', () => { + expect(delegationQueryKeys.summary('nyc1test')).toStrictEqual(['delegation', 'summary', 'nyc1test']); + }); +}); diff --git a/nym-wallet/src/context/delegationQueryKeys.ts b/nym-wallet/src/context/delegationQueryKeys.ts new file mode 100644 index 0000000000..19e4ce54b2 --- /dev/null +++ b/nym-wallet/src/context/delegationQueryKeys.ts @@ -0,0 +1,4 @@ +export const delegationQueryKeys = { + all: ['delegation'] as const, + summary: (clientAddress: string) => [...delegationQueryKeys.all, 'summary', clientAddress] as const, +}; diff --git a/nym-wallet/src/context/delegations.tsx b/nym-wallet/src/context/delegations.tsx index d77e52467e..351be2049e 100644 --- a/nym-wallet/src/context/delegations.tsx +++ b/nym-wallet/src/context/delegations.tsx @@ -1,5 +1,7 @@ -import React, { createContext, FC, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { getDelegationSummary, undelegateFromMixnode } from 'src/requests/delegation'; +import React, { createContext, FC, useCallback, useContext, useEffect, useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { undelegateFromMixnode } from 'src/requests/delegation'; import { DecCoin, DelegationWithEverything, @@ -9,19 +11,19 @@ import { WrappedDelegationEvent, } from '@nymproject/types'; import type { Network } from 'src/types'; -import { - delegateToMixnode, - getAllPendingDelegations, - vestingDelegateToMixnode, - vestingUndelegateFromMixnode, -} from 'src/requests'; +import { delegateToMixnode, vestingDelegateToMixnode, vestingUndelegateFromMixnode } from 'src/requests'; import { TPoolOption } from 'src/components'; -import { decCoinToDisplay } from 'src/utils'; import { Console } from 'src/utils/console'; +import { AppContext } from 'src/context/main'; +import { delegationQueryKeys } from './delegationQueryKeys'; +import { fetchDelegationSummaryQuery } from './delegationQuery'; export type TDelegationContext = { delegationItemErrors?: { nodeId: string; errors: string }; isLoading: boolean; + isFetching: boolean; + isError: boolean; + lastUpdatedAtMs: number; delegations?: TDelegations; pendingDelegations?: WrappedDelegationEvent[]; totalDelegations?: string; @@ -52,7 +54,10 @@ export const isDelegation = (delegation: DelegationWithEvent): delegation is Del 'owner' in delegation; export const DelegationContext = createContext({ - isLoading: true, + isLoading: false, + isFetching: false, + isError: false, + lastUpdatedAtMs: 0, refresh: async () => undefined, addDelegation: async () => { throw new Error('Not implemented'); @@ -66,18 +71,47 @@ export const DelegationContext = createContext({ setDelegationItemErrors: () => undefined, }); +function isDelegationRoutePath(pathname: string): boolean { + return pathname === '/delegation' || pathname.endsWith('/delegation'); +} + export const DelegationContextProvider: FC<{ network?: Network; children: React.ReactNode; // eslint-disable-next-line @typescript-eslint/no-unused-vars }> = ({ network, children }) => { - const [isLoading, setIsLoading] = useState(true); - const [delegationItemErrors, setDelegationItemErrors] = useState<{ nodeId: string; errors: string }>(); - const [delegations, setDelegations] = useState(); - const [totalDelegations, setTotalDelegations] = useState(); - const [totalRewards, setTotalRewards] = useState(); - const [totalDelegationsAndRewards, setTotalDelegationsAndRewards] = useState(); - const [pendingDelegations, setPendingDelegations] = useState(); + const location = useLocation(); + const queryClient = useQueryClient(); + const { clientDetails } = useContext(AppContext); + const clientAddress = clientDetails?.client_address; + const onDelegationRoute = isDelegationRoutePath(location.pathname); + + const [delegationItemErrors, setDelegationItemErrors] = React.useState<{ nodeId: string; errors: string }>(); + + const query = useQuery({ + queryKey: delegationQueryKeys.summary(clientAddress ?? ''), + queryFn: fetchDelegationSummaryQuery, + enabled: Boolean(clientAddress) && onDelegationRoute, + staleTime: 5 * 60 * 1000, + gcTime: 30 * 60 * 1000, + }); + + useEffect(() => { + if (!clientAddress) { + queryClient.removeQueries({ queryKey: delegationQueryKeys.all }); + } + }, [clientAddress, queryClient]); + + const bundle = clientAddress && onDelegationRoute ? query.data : undefined; + + const refresh = useCallback(async () => { + if (!clientAddress || !onDelegationRoute) { + return; + } + await queryClient.invalidateQueries({ + queryKey: delegationQueryKeys.summary(clientAddress), + }); + }, [clientAddress, onDelegationRoute, queryClient]); const addDelegation = async (data: { mix_id: number; amount: DecCoin }, tokenPool: TPoolOption, fee?: FeeDetails) => { try { @@ -91,51 +125,29 @@ export const DelegationContextProvider: FC<{ return tx; } catch (e) { - throw new Error(e as string); + const message = e instanceof Error ? e.message : String(e); + throw new Error(message); } }; - const refresh = useCallback(async () => { - setIsLoading(true); - try { - const data = await getDelegationSummary(); - const pending = await getAllPendingDelegations(); + const delegations = bundle?.delegations; + const pendingDelegations = bundle?.pendingDelegations; + const totalDelegations = bundle?.totalDelegations; + const totalRewards = bundle?.totalRewards; + const totalDelegationsAndRewards = bundle?.totalDelegationsAndRewards; - const pendingOnNewNodes = pending.filter((event) => { - const some = data.delegations.some(({ node_identity }) => node_identity === event.node_identity); - return !some; - }); - const items = data.delegations.map((delegation) => ({ - ...delegation, - amount: decCoinToDisplay(delegation.amount), - unclaimed_rewards: delegation.unclaimed_rewards && decCoinToDisplay(delegation.unclaimed_rewards), - cost_params: delegation.cost_params && { - ...delegation.cost_params, - interval_operating_cost: decCoinToDisplay(delegation.cost_params.interval_operating_cost), - }, - })); - - const delegationsAndRewards = (+data.total_delegations.amount + +data.total_rewards.amount).toFixed(6); - - setPendingDelegations(pending); - setDelegations([...items, ...pendingOnNewNodes]); - setTotalDelegations(`${data.total_delegations.amount} ${data.total_delegations.denom}`); - setTotalRewards(`${data.total_rewards.amount} ${data.total_rewards.denom}`); - setTotalDelegationsAndRewards(`${delegationsAndRewards} ${data.total_delegations.denom}`); - } catch (e) { - Console.error(e); - } - setIsLoading(false); - }, []); - - useEffect(() => { - refresh(); - }, []); + const isLoading = Boolean(clientAddress) && onDelegationRoute && query.isPending; + const isFetching = Boolean(clientAddress) && onDelegationRoute && query.isFetching; + const isError = Boolean(clientAddress) && onDelegationRoute && query.isError && !query.data; + const lastUpdatedAtMs = bundle ? query.dataUpdatedAt : 0; const memoizedValue = useMemo( () => ({ delegationItemErrors, isLoading, + isFetching, + isError, + lastUpdatedAtMs, delegations, pendingDelegations, totalDelegations, @@ -148,16 +160,26 @@ export const DelegationContextProvider: FC<{ undelegateVesting: vestingUndelegateFromMixnode, }), [ - isLoading, - delegations, delegationItemErrors, + isLoading, + isFetching, + isError, + lastUpdatedAtMs, + delegations, pendingDelegations, totalDelegations, totalRewards, totalDelegationsAndRewards, + refresh, ], ); + useEffect(() => { + if (query.isError && query.error) { + Console.error(query.error); + } + }, [query.isError, query.error]); + return {children}; }; diff --git a/nym-wallet/src/context/mocks/delegations.tsx b/nym-wallet/src/context/mocks/delegations.tsx index f2aea600bc..27c15d3e1d 100644 --- a/nym-wallet/src/context/mocks/delegations.tsx +++ b/nym-wallet/src/context/mocks/delegations.tsx @@ -7,7 +7,7 @@ import { FeeDetails, TransactionExecuteResult, } from '@nymproject/types'; -import { DelegationContext, TDelegationTransaction } from '../delegations'; +import { DelegationContext } from '../delegations'; import { mockSleep } from './utils'; import { TPoolOption } from '../../components'; @@ -71,6 +71,9 @@ export const MockDelegationContextProvider: FCWithChildren = ({ children }) => { const [error, setError] = useState(); const [delegations, setDelegations] = useState(); const [totalDelegations, setTotalDelegations] = useState(); + const [totalRewards, setTotalRewards] = useState(); + const [totalDelegationsAndRewards, setTotalDelegationsAndRewards] = useState(); + const [lastUpdatedAtMs, setLastUpdatedAtMs] = useState(0); const [delegationItemErrors, setDelegationItemErrors] = useState<{ nodeId: string; errors: string }>(); const triggerStateUpdate = () => setTrigger(new Date()); @@ -81,8 +84,16 @@ export const MockDelegationContextProvider: FCWithChildren = ({ children }) => { const recalculate = async () => { const newDelegations = await getDelegations(); const newTotalDelegations = `${newDelegations.length * 100} NYM`; + const rewardsSum = newDelegations.reduce((acc, d) => { + const n = parseFloat(d.unclaimed_rewards?.amount ?? '0'); + return acc + (Number.isFinite(n) ? n : 0); + }, 0); + const newTotalRewards = `${rewardsSum} nym`; setDelegations(newDelegations); setTotalDelegations(newTotalDelegations); + setTotalRewards(newTotalRewards); + setTotalDelegationsAndRewards(`${newTotalDelegations} + ${newTotalRewards}`); + setLastUpdatedAtMs(Date.now()); }; const addDelegation = async ( @@ -91,7 +102,6 @@ export const MockDelegationContextProvider: FCWithChildren = ({ children }) => { _fee?: FeeDetails, ): Promise => { await mockSleep(SLEEP_MS); - // mockDelegations.push({ ...newDelegation }); await recalculate(); triggerStateUpdate(); @@ -118,52 +128,6 @@ export const MockDelegationContextProvider: FCWithChildren = ({ children }) => { }; }; - const updateDelegation = async ( - newDelegation: DelegationWithEverything, - ignorePendingForStorybook?: boolean, - ): Promise => { - if (ignorePendingForStorybook) { - mockDelegations = mockDelegations.map((d) => { - if (d.node_identity === newDelegation.node_identity) { - return { ...newDelegation }; - } - return d; - }); - await recalculate(); - triggerStateUpdate(); - return { - transactionUrl: - 'https://sandbox-blocks.nymtech.net/transactions/55303CD4B91FAC4C2715E40EBB52BB3B92829D9431B3A279D37B5CC58432E354', - }; - } - - await mockSleep(SLEEP_MS); - mockDelegations = mockDelegations.map((d) => { - if (d.node_identity === newDelegation.node_identity) { - return { ...newDelegation, isPending: { blockHeight: 1234, actionType: 'delegate' } }; - } - return d; - }); - await recalculate(); - triggerStateUpdate(); - - setTimeout(async () => { - mockDelegations = mockDelegations.map((d) => { - if (d.node_identity === newDelegation.node_identity) { - return { ...d, isPending: undefined }; - } - return d; - }); - await recalculate(); - triggerStateUpdate(); - }, 3000); - - return { - transactionUrl: - 'https://sandbox-blocks.nymtech.net/transactions/55303CD4B91FAC4C2715E40EBB52BB3B92829D9431B3A279D37B5CC58432E354', - }; - }; - const undelegate = async (mix_id: number, _fee?: Fee): Promise => { await mockSleep(SLEEP_MS); mockDelegations = mockDelegations.map((d) => { @@ -193,10 +157,8 @@ export const MockDelegationContextProvider: FCWithChildren = ({ children }) => { }; }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const undelegateVesting = async (mix_id: number, _fee?: FeeDetails) => ({ + const undelegateVesting = async (_mix_id: number): Promise => ({ msg_responses_json: '', - data_json: '', logs_json: '', transaction_hash: '', gas_info: { @@ -210,6 +172,9 @@ export const MockDelegationContextProvider: FCWithChildren = ({ children }) => { setIsLoading(true); setError(undefined); setTotalDelegations(undefined); + setTotalRewards(undefined); + setTotalDelegationsAndRewards(undefined); + setLastUpdatedAtMs(0); setDelegations([]); }; @@ -227,7 +192,6 @@ export const MockDelegationContextProvider: FCWithChildren = ({ children }) => { }, []); useEffect(() => { - // reset state and refresh resetState(); refresh(); }, []); @@ -237,17 +201,34 @@ export const MockDelegationContextProvider: FCWithChildren = ({ children }) => { delegationItemErrors, setDelegationItemErrors, isLoading, - error, + isFetching: isLoading, + isError: Boolean(error), + lastUpdatedAtMs, delegations, + pendingDelegations: [], totalDelegations, + totalRewards, + totalDelegationsAndRewards, refresh, - getDelegations, addDelegation, - updateDelegation, undelegate, undelegateVesting, }), - [isLoading, error, delegations, totalDelegations, trigger], + [ + isLoading, + error, + delegations, + totalDelegations, + totalRewards, + totalDelegationsAndRewards, + lastUpdatedAtMs, + refresh, + addDelegation, + undelegate, + undelegateVesting, + delegationItemErrors, + trigger, + ], ); return {children}; diff --git a/nym-wallet/src/context/rewards.tsx b/nym-wallet/src/context/rewards.tsx index dc6b72a44f..060f5d3e48 100644 --- a/nym-wallet/src/context/rewards.tsx +++ b/nym-wallet/src/context/rewards.tsx @@ -3,25 +3,29 @@ import { FeeDetails, TransactionExecuteResult } from '@nymproject/types'; import { useDelegationContext } from './delegations'; import { claimDelegatorRewards } from '../requests'; +export type TRewardsTransaction = { + transactionUrl: string; + transactionHash: string; +}; + type TRewardsContext = { isLoading: boolean; error?: string; totalRewards?: string; refresh: () => Promise; claimRewards: (mixId: number, fee?: FeeDetails) => Promise; -}; - -export type TRewardsTransaction = { - transactionUrl: string; - transactionHash: string; + redeemAllRewards: () => Promise; }; export const RewardsContext = createContext({ - isLoading: true, + isLoading: false, refresh: async () => undefined, claimRewards: async () => { throw new Error('Not implemented'); }, + redeemAllRewards: async () => { + throw new Error('Not implemented'); + }, }); export const RewardsContextProvider: FCWithChildren = ({ children }) => { @@ -47,7 +51,7 @@ export const RewardsContextProvider: FCWithChildren = ({ children }) => { throw new Error('Not implemented'); }, }), - [isLoading, error, totalRewards], + [isLoading, error, totalRewards, refresh], ); return {children}; diff --git a/nym-wallet/src/log.tsx b/nym-wallet/src/log.tsx index 8fc9f166c1..24330d7f26 100644 --- a/nym-wallet/src/log.tsx +++ b/nym-wallet/src/log.tsx @@ -1,15 +1,18 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import { ErrorBoundary } from 'react-error-boundary'; +import { SnackbarProvider } from 'notistack'; import { LogViewer } from './components/LogViewer'; import { ErrorFallback } from './components'; import { NymWalletTheme } from './theme'; const Log = () => ( - - - + + + + + ); diff --git a/nym-wallet/src/pages/delegation/index.tsx b/nym-wallet/src/pages/delegation/index.tsx index 7b3abbfc54..6d367b48b5 100644 --- a/nym-wallet/src/pages/delegation/index.tsx +++ b/nym-wallet/src/pages/delegation/index.tsx @@ -1,4 +1,4 @@ -import React, { FC, useContext, useEffect, useState } from 'react'; +import React, { FC, useCallback, useContext, useEffect, useRef, useState } from 'react'; import { OpenInNew } from '@mui/icons-material'; import { Alert, AlertTitle, Box, Button, CircularProgress, LinearProgress, Stack, Typography } from '@mui/material'; import { alpha, useTheme } from '@mui/material/styles'; @@ -11,6 +11,7 @@ import { Console } from 'src/utils/console'; import { OverSaturatedBlockerModal } from 'src/components/Delegation/DelegateBlocker'; import { getSpendableCoins, migrateVestedDelegations, userBalance } from 'src/requests'; import { LoadingModal } from 'src/components/Modals/LoadingModal'; +import { format } from 'date-fns'; import { getIntervalAsDate, toPercentIntegerString } from 'src/utils'; import { DelegationContextProvider, isDelegation, TDelegations, useDelegationContext } from '../../context/delegations'; import { RewardsContextProvider, useRewardsContext } from '../../context/rewards'; @@ -44,6 +45,9 @@ export const Delegation: FC = () => { const { delegations, isLoading, + isFetching, + isError, + lastUpdatedAtMs, addDelegation, undelegate, undelegateVesting, @@ -55,9 +59,11 @@ export const Delegation: FC = () => { [delegations], ); - const { refresh: refreshRewards, claimRewards } = useRewardsContext(); + const { claimRewards } = useRewardsContext(); - const refresh = async () => Promise.all([refreshDelegations(), refreshRewards()]); + const refreshWithData = useCallback(async () => { + await refreshDelegations(); + }, [refreshDelegations]); // If an action modal is open, don't show the loading modal const isActionModalOpen = @@ -83,36 +89,54 @@ export const Delegation: FC = () => { }; }; - const getNextInterval = async () => { + const getNextInterval = useCallback(async () => { try { const { nextEpoch: newNextEpoch } = await getIntervalAsDate(); setNextEpoch(newNextEpoch); } catch { setNextEpoch(Error()); } - }; + }, []); - const refreshWithIntervalUpdate = async () => { - refresh(); - getNextInterval(); - }; + const refreshWithIntervalUpdate = useCallback(async () => { + await refreshWithData(); + await getNextInterval(); + }, [refreshWithData, getNextInterval]); + + const refreshWithIntervalUpdateRef = useRef(refreshWithIntervalUpdate); + refreshWithIntervalUpdateRef.current = refreshWithIntervalUpdate; - // Refresh the rewards and delegations periodically when page is mounted useEffect(() => { - const timer = setInterval(refreshWithIntervalUpdate, 5 * 60 * 1000); // every 5 minutes + const timer = setInterval(() => { + refreshWithIntervalUpdateRef.current().catch((err) => { + Console.error(err); + }); + }, 5 * 60 * 1000); return () => clearInterval(timer); }, []); const doMigrateNow = async () => { setShowVestingMigrationProgressModal(true); await migrateVestedDelegations(); - await refresh(); + await refreshDelegations(); setShowVestingMigrationProgressModal(false); }; useEffect(() => { - refreshWithIntervalUpdate(); - }, [clientDetails, confirmationModalProps]); + getNextInterval().catch((err) => { + Console.error(err); + }); + }, [clientDetails, getNextInterval]); + + const prevConfirmationModalProps = useRef(undefined); + useEffect(() => { + if (prevConfirmationModalProps.current !== undefined && confirmationModalProps === undefined) { + refreshWithIntervalUpdate().catch((err) => { + Console.error(err); + }); + } + prevConfirmationModalProps.current = confirmationModalProps; + }, [confirmationModalProps, refreshWithIntervalUpdate]); const handleDelegationItemActionClick = (item: DelegationWithEverything, action: DelegationListItemActions) => { if ( @@ -409,6 +433,14 @@ export const Delegation: FC = () => { }} > + {isError && ( + Could not load delegation data. Check your connection and try again. + )} + {lastUpdatedAtMs > 0 && ( + + Last updated: {format(lastUpdatedAtMs, 'yyyy-MM-dd HH:mm:ss')} + + )} {!!delegations?.length && ( { )} - {isLoading && delegations !== undefined && !isActionModalOpen && ( + {isFetching && delegations !== undefined && !isActionModalOpen && (