diff --git a/nym-wallet/CHANGELOG.md b/nym-wallet/CHANGELOG.md index a0471cbecb..6bfb833f3d 100644 --- a/nym-wallet/CHANGELOG.md +++ b/nym-wallet/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +## [v1.2.20] (2026-05-06) + +- Delegation query cache, log webview streaming, HTTPS webviews ([#6740]) +- Nym Wallet: deps updates, clipboard/updater/, icon, polishing ([#5844]) + +[#6740]: https://github.com/nymtech/nym/pull/6740 +[#6681]: https://github.com/nymtech/nym/pull/6681 + + ## [v1.2.19] (2025-07-15) - Amended the buy section ([#5841]) 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..5a18a79b3e 100644 --- a/nym-wallet/src-tauri/src/log.rs +++ b/nym-wallet/src-tauri/src/log.rs @@ -62,9 +62,17 @@ 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) { + // Avoid log:: macros here: this runs inside fern's sink and can recurse. + eprintln!("nym-wallet: failed to emit log line to log webview: {err}"); + } + } + }) { + eprintln!("nym-wallet: 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..47c05cdb82 --- /dev/null +++ b/nym-wallet/src/context/delegationQuery.ts @@ -0,0 +1,49 @@ +import Big from 'big.js'; +import type { DecCoin, 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: DecCoin; + totalRewards: DecCoin; + totalDelegationsAndRewards: DecCoin; +}; + +export async function fetchDelegationSummaryQuery(): Promise { + const data = await getDelegationSummary(); + const pending = await getAllPendingDelegations(); + + const delegatedIdentities = new Set(data.delegations.map((d) => d.node_identity)); + const pendingOnNewNodes = pending.filter((event) => !delegatedIdentities.has(event.node_identity)); + 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), + }, + })); + + let combinedAmount = '0'; + try { + combinedAmount = Big(data.total_delegations.amount).plus(Big(data.total_rewards.amount)).toFixed(6); + } catch { + combinedAmount = '0'; + } + + return { + delegations: [...items, ...pendingOnNewNodes], + pendingDelegations: pending, + totalDelegations: decCoinToDisplay(data.total_delegations), + totalRewards: decCoinToDisplay(data.total_rewards), + totalDelegationsAndRewards: { + amount: combinedAmount, + denom: 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..14bbfdc452 --- /dev/null +++ b/nym-wallet/src/context/delegationQueryKeys.test.ts @@ -0,0 +1,11 @@ +import { delegationQueryKeys } from './delegationQueryKeys'; + +describe('delegationQueryKeys', () => { + it('builds a stable summary key per client address', () => { + expect(delegationQueryKeys.summary('nyc1test')).toStrictEqual(['delegation', 'summary', 'nyc1test']); + }); + + it('uses a stable disabled summary key without empty address', () => { + expect(delegationQueryKeys.summaryDisabled).toStrictEqual(['delegation', 'summary', '__disabled__']); + }); +}); diff --git a/nym-wallet/src/context/delegationQueryKeys.ts b/nym-wallet/src/context/delegationQueryKeys.ts new file mode 100644 index 0000000000..b660b14d34 --- /dev/null +++ b/nym-wallet/src/context/delegationQueryKeys.ts @@ -0,0 +1,8 @@ +const delegationRoot = ['delegation'] as const; + +export const delegationQueryKeys = { + all: delegationRoot, + /** Used when no client address so React Query never caches `summary('')`. */ + summaryDisabled: [...delegationRoot, 'summary', '__disabled__'] as const, + summary: (clientAddress: string) => [...delegationRoot, 'summary', clientAddress] as const, +}; diff --git a/nym-wallet/src/context/delegations.tsx b/nym-wallet/src/context/delegations.tsx index d77e52467e..23b51ef7eb 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; @@ -51,8 +53,15 @@ export const isPendingDelegation = (delegation: DelegationWithEvent): delegation export const isDelegation = (delegation: DelegationWithEvent): delegation is DelegationWithEverything => 'owner' in delegation; +function formatDelegationSummaryCoin(coin: DecCoin): string { + return `${coin.amount} ${coin.denom}`; +} + 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 +75,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: clientAddress ? delegationQueryKeys.summary(clientAddress) : delegationQueryKeys.summaryDisabled, + 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 +129,31 @@ 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 ? formatDelegationSummaryCoin(bundle.totalDelegations) : undefined; + const totalRewards = bundle ? formatDelegationSummaryCoin(bundle.totalRewards) : undefined; + const totalDelegationsAndRewards = bundle + ? formatDelegationSummaryCoin(bundle.totalDelegationsAndRewards) + : undefined; - 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 +166,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/mocks/rewards.tsx b/nym-wallet/src/context/mocks/rewards.tsx index fe84e5cfe2..65fa112b8e 100644 --- a/nym-wallet/src/context/mocks/rewards.tsx +++ b/nym-wallet/src/context/mocks/rewards.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { DelegationWithEverything, TransactionExecuteResult } from '@nymproject/types'; -import { RewardsContext, TRewardsTransaction } from '../rewards'; +import { RewardsContext } from '../rewards'; import { useDelegationContext } from '../delegations'; import { mockSleep } from './utils'; @@ -45,69 +45,51 @@ export const MockRewardsContextProvider: FCWithChildren = ({ children }) => { refresh(); }, []); - const claimRewards = async (mixId: number): Promise => { - if (!delegations) { - throw new Error('No delegations'); - } + const claimRewards = useCallback( + async (mixId: number): Promise => { + if (!delegations) { + throw new Error('No delegations'); + } - const d = delegations.find((d1) => (d1 as DelegationWithEverything).mix_id === mixId); + const d = delegations.find((d1) => (d1 as DelegationWithEverything).mix_id === mixId); - if (!d) { - throw new Error(`Unable to find delegation for id = ${mixId}`); - } + if (!d) { + throw new Error(`Unable to find delegation for id = ${mixId}`); + } - await mockSleep(1000); + await mockSleep(1000); - return [ - { - transaction_hash: '55303CD4B91FAC4C2715E40EBB52BB3B92829D9431B3A279D37B5CC58432E354', - fee: { - amount: '1', - denom: 'nym', + return [ + { + transaction_hash: '55303CD4B91FAC4C2715E40EBB52BB3B92829D9431B3A279D37B5CC58432E354', + fee: { + amount: '1', + denom: 'nym', + }, + logs_json: '[]', + msg_responses_json: '[]', + gas_info: { + gas_wanted: { gas_units: BigInt(1) }, + gas_used: { gas_units: BigInt(1) }, + }, }, - logs_json: '[]', - msg_responses_json: '[]', - gas_info: { - gas_wanted: { gas_units: BigInt(1) }, - gas_used: { gas_units: BigInt(1) }, + { + transaction_hash: '55303CD4B91FAC4C2715E40EBB52BB3B92829D9431B3A279D37B5CC58432E354', + fee: { + amount: '1', + denom: 'nym', + }, + msg_responses_json: '[]', + logs_json: '[]', + gas_info: { + gas_wanted: { gas_units: BigInt(1) }, + gas_used: { gas_units: BigInt(1) }, + }, }, - }, - { - transaction_hash: '55303CD4B91FAC4C2715E40EBB52BB3B92829D9431B3A279D37B5CC58432E354', - fee: { - amount: '1', - denom: 'nym', - }, - msg_responses_json: '[]', - logs_json: '[]', - gas_info: { - gas_wanted: { gas_units: BigInt(1) }, - gas_used: { gas_units: BigInt(1) }, - }, - }, - ]; - }; - - const redeemAllRewards = async (): Promise => { - if (!delegations) { - throw new Error('No delegations'); - } - - await mockSleep(1000); - - return [ - { - transactionUrl: - 'https://sandbox-blocks.nymtech.net/transactions/55303CD4B91FAC4C2715E40EBB52BB3B92829D9431B3A279D37B5CC58432E354', - transactionHash: '55303CD4B91FAC4C2715E40EBB52BB3B92829D9431B3A279D37B5CC58432E354', - }, - { - transactionUrl: - 'https://sandbox-blocks.nymtech.net/transactions/55303CD4B91FAC4C2715E40EBB52BB3B92829D9431B3A279D37B5CC58432E354', - transactionHash: '55303CD4B91FAC4C2715E40EBB52BB3B92829D9431B3A279D37B5CC58432E354', - }, - ]; - }; + ]; + }, + [delegations], + ); const memoizedValue = useMemo( () => ({ @@ -116,9 +98,8 @@ export const MockRewardsContextProvider: FCWithChildren = ({ children }) => { totalRewards, refresh, claimRewards, - redeemAllRewards, }), - [isLoading, error, totalRewards], + [isLoading, error, totalRewards, refresh, claimRewards], ); return {children}; diff --git a/nym-wallet/src/context/rewards.tsx b/nym-wallet/src/context/rewards.tsx index dc6b72a44f..f4a22eb8b6 100644 --- a/nym-wallet/src/context/rewards.tsx +++ b/nym-wallet/src/context/rewards.tsx @@ -11,13 +11,8 @@ type TRewardsContext = { claimRewards: (mixId: number, fee?: FeeDetails) => Promise; }; -export type TRewardsTransaction = { - transactionUrl: string; - transactionHash: string; -}; - export const RewardsContext = createContext({ - isLoading: true, + isLoading: false, refresh: async () => undefined, claimRewards: async () => { throw new Error('Not implemented'); @@ -43,11 +38,8 @@ export const RewardsContextProvider: FCWithChildren = ({ children }) => { totalRewards, refresh, claimRewards: claimDelegatorRewards, - redeemAllRewards: async () => { - 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 && (