diff --git a/webapp/app/[locale]/tunnel/transaction-history/_components/reloadHistory.tsx b/webapp/app/[locale]/tunnel/transaction-history/_components/reloadHistory.tsx new file mode 100644 index 00000000..81a7985f --- /dev/null +++ b/webapp/app/[locale]/tunnel/transaction-history/_components/reloadHistory.tsx @@ -0,0 +1,29 @@ +import { useTunnelHistory } from 'hooks/useTunnelHistory' +import { useTranslations } from 'next-intl' + +const ReloadIcon = () => ( + + + +) + +export const ReloadHistory = function () { + const t = useTranslations() + const { resyncHistory } = useTunnelHistory() + + return ( + + ) +} diff --git a/webapp/app/[locale]/tunnel/transaction-history/_components/transactionHistory.tsx b/webapp/app/[locale]/tunnel/transaction-history/_components/transactionHistory.tsx index 0b9aa868..a7d7f9a7 100644 --- a/webapp/app/[locale]/tunnel/transaction-history/_components/transactionHistory.tsx +++ b/webapp/app/[locale]/tunnel/transaction-history/_components/transactionHistory.tsx @@ -35,6 +35,7 @@ import { Amount } from './amount' import { Chain as ChainComponent } from './chain' import { DepositAction } from './depositAction' import { Paginator } from './paginator' +import { ReloadHistory } from './reloadHistory' import { TxLink } from './txLink' import { TxStatus } from './txStatus' import { TxTime } from './txTime' @@ -192,7 +193,7 @@ const columnsBuilder = ( ) : ( ), - header: () =>
, + header: () => , id: 'action', }, ] diff --git a/webapp/context/tunnelHistoryContext/index.tsx b/webapp/context/tunnelHistoryContext/index.tsx index 8d06e153..952fc86b 100644 --- a/webapp/context/tunnelHistoryContext/index.tsx +++ b/webapp/context/tunnelHistoryContext/index.tsx @@ -1,7 +1,6 @@ 'use client' import { featureFlags } from 'app/featureFlags' -import { useBitcoin } from 'hooks/useBitcoin' import { useHemi } from 'hooks/useHemi' import { useNetworks } from 'hooks/useNetworks' import { useSyncHistory } from 'hooks/useSyncHistory' @@ -10,12 +9,11 @@ import { type HistoryReducerState, } from 'hooks/useSyncHistory/types' import dynamic from 'next/dynamic' -import { createContext, useMemo, ReactNode } from 'react' +import { createContext, ReactNode } from 'react' import { type RemoteChain } from 'types/chain' import { type DepositTunnelOperation, type EvmWithdrawOperation, - type WithdrawTunnelOperation, } from 'types/tunnel' import { useAccount } from 'wagmi' @@ -58,6 +56,7 @@ type TunnelHistoryContext = { withdrawal: Omit, ) => void deposits: DepositTunnelOperation[] + resyncHistory: () => void syncStatus: HistoryReducerState['status'] updateDeposit: ( deposit: DepositTunnelOperation, @@ -74,6 +73,7 @@ export const TunnelHistoryContext = createContext({ addDepositToTunnelHistory: () => undefined, addWithdrawalToTunnelHistory: () => undefined, deposits: [], + resyncHistory: () => undefined, syncStatus: 'idle', updateDeposit: () => undefined, updateWithdrawal: () => undefined, @@ -86,12 +86,11 @@ type Props = { export const TunnelHistoryProvider = function ({ children }: Props) { const { address, isConnected } = useAccount() - const bitcoin = useBitcoin() const l2ChainId = useHemi().id const { remoteNetworks } = useNetworks() - const [history, dispatch] = useSyncHistory(l2ChainId) + const [history, dispatch, context] = useSyncHistory(l2ChainId) const historyChainSync = [] @@ -114,41 +113,8 @@ export const TunnelHistoryProvider = function ({ children }: Props) { ) } - const value = useMemo( - () => ({ - addDepositToTunnelHistory: (deposit: DepositTunnelOperation) => - dispatch({ payload: deposit, type: 'add-deposit' }), - addWithdrawalToTunnelHistory: (withdrawal: WithdrawTunnelOperation) => - dispatch({ payload: withdrawal, type: 'add-withdraw' }), - deposits: history.deposits - .filter(d => featureFlags.btcTunnelEnabled || d.chainId !== bitcoin.id) - .flatMap(d => d.content), - syncStatus: history.status, - updateDeposit: ( - deposit: DepositTunnelOperation, - updates: Partial, - ) => - dispatch({ - payload: { deposit, updates }, - type: 'update-deposit', - }), - updateWithdrawal: ( - withdraw: WithdrawTunnelOperation, - updates: Partial, - ) => - dispatch({ - payload: { updates, withdraw }, - type: 'update-withdraw', - }), - withdrawals: history.withdrawals - .filter(w => featureFlags.btcTunnelEnabled || w.chainId !== bitcoin.id) - .flatMap(w => w.content), - }), - [bitcoin.id, dispatch, history], - ) - return ( - + {/* Move to web worker https://github.com/hemilabs/ui-monorepo/issues/487 */} {/* Track updates on bitcoin deposits, in bitcoin or in Hemi */} {featureFlags.btcTunnelEnabled && } diff --git a/webapp/hooks/useSyncHistory/index.ts b/webapp/hooks/useSyncHistory/index.ts index d03d539f..11c9dcc6 100644 --- a/webapp/hooks/useSyncHistory/index.ts +++ b/webapp/hooks/useSyncHistory/index.ts @@ -1,8 +1,9 @@ -import { hemiSepolia } from 'hemi-viem' +import { featureFlags } from 'app/featureFlags' +import { useBitcoin } from 'hooks/useBitcoin' import { useConnectedToSupportedEvmChain } from 'hooks/useConnectedToSupportedChain' import { useNetworks } from 'hooks/useNetworks' import debounce from 'lodash/debounce' -import { useEffect, useReducer, useState } from 'react' +import { useEffect, useMemo, useReducer, useState } from 'react' import { type RemoteChain } from 'types/chain' import { type DepositTunnelOperation, @@ -14,178 +15,23 @@ import { chainConfiguration } from 'utils/sync-history/chainConfiguration' import { type Address, type Chain } from 'viem' import { useAccount } from 'wagmi' +import { historyReducer, initialState } from './reducer' +import { type HistoryReducerState, type StorageChain } from './types' import { - type HistoryActions, - type HistoryReducerState, - type StorageChain, -} from './types' -import { - addOperation, - getSyncStatus, - getTunnelHistoryDepositFallbackStorageKey, getTunnelHistoryDepositStorageKey, getTunnelHistoryWithdrawStorageKey, - getTunnelHistoryWithdrawStorageKeyFallback, - syncContent, - updateChainSyncStatus, - updateOperation, } from './utils' -// the _:never is used to fail compilation if a case is missing -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const compilationError = function (_: never): never { - throw new Error('Missing implementation of action in reducer') -} - -const initialState: HistoryReducerState = { - deposits: [], - status: 'idle', - withdrawals: [], -} - -const historyReducer = function ( - state: HistoryReducerState, - action: HistoryActions, -): HistoryReducerState { - const getNewState = function (): Omit { - const { type } = action - switch (type) { - case 'add-deposit': { - const { payload: newDeposit } = action - const deposits = addOperation(state.deposits, newDeposit) - - return { - ...state, - deposits, - } - } - case 'add-withdraw': { - const { payload: newWithdrawal } = action - const withdrawals = addOperation(state.withdrawals, newWithdrawal) - return { - ...state, - withdrawals, - } - } - case 'reset': - return { ...initialState } - case 'restore': { - const { payload } = action - return { - ...state, - deposits: payload.deposits.map(chainDeposits => ({ - ...chainDeposits, - // See https://github.com/hemilabs/ui-monorepo/issues/376 - content: chainDeposits.content.map( - deposit => - ({ - ...deposit, - l1ChainId: deposit.l1ChainId, - l2ChainId: deposit.l2ChainId ?? hemiSepolia.id, - }) as DepositTunnelOperation, - ), - status: 'ready', - })), - withdrawals: payload.withdrawals.map(chainWithdrawals => ({ - ...chainWithdrawals, - // See https://github.com/hemilabs/ui-monorepo/issues/376 - content: chainWithdrawals.content.map( - withdrawal => - ({ - ...withdrawal, - l1ChainId: withdrawal.l1ChainId, - l2ChainId: withdrawal.l2ChainId ?? hemiSepolia.id, - }) as WithdrawTunnelOperation, - ), - status: 'ready', - })), - } - } - case 'sync': { - const { chainId } = action.payload - return updateChainSyncStatus(state, chainId, 'syncing') - } - case 'sync-deposits': { - const { chainId } = action.payload - const deposits = state.deposits.map(currentDeposits => - currentDeposits.chainId === chainId - ? syncContent(currentDeposits, action.payload) - : currentDeposits, - ) - - return { - ...state, - deposits, - } - } - case 'sync-finished': { - const { chainId } = action.payload - return updateChainSyncStatus(state, chainId, 'finished') - } - case 'sync-withdrawals': { - const { chainId } = action.payload - const withdrawals = state.withdrawals.map(chainWithdrawals => - chainWithdrawals.chainId === chainId - ? syncContent(chainWithdrawals, action.payload) - : chainWithdrawals, - ) - return { - ...state, - withdrawals, - } - } - case 'update-deposit': { - const { deposit, updates } = action.payload - const deposits = updateOperation(state.deposits, { - operation: deposit, - updates, - }) - - return { - ...state, - deposits, - } - } - case 'update-withdraw': { - const { withdraw, updates } = action.payload - const withdrawals = updateOperation(state.withdrawals, { - operation: withdraw, - updates, - }) - - return { - ...state, - withdrawals, - } - } - default: - // if a switch statement is missing on all possible actions - // this will fail on compile time - return compilationError(type) - } - } - - const newState = getNewState() - return { - ...newState, - status: getSyncStatus(newState), - } -} - const readStateFromStorage = function ({ chainId, configChainId, - fallbackKey, key, }: { chainId: RemoteChain['id'] configChainId: RemoteChain['id'] - fallbackKey?: string key: string }) { - const restored = - localStorage.getItem(key) || - (fallbackKey ? localStorage.getItem(fallbackKey) : null) + const restored = localStorage.getItem(key) if (!restored) { const chain = findChainById(chainId) if (isEvmNetwork(chain)) { @@ -213,6 +59,24 @@ const readStateFromStorage = function ({ } } +const clearHistoryInLocalStorage = ({ + address, + l2ChainId, + remoteNetworks, +}: { + address: Address + l2ChainId: Chain['id'] + remoteNetworks: RemoteChain[] +}) => + remoteNetworks.forEach(function (remoteNetwork) { + localStorage.removeItem( + getTunnelHistoryWithdrawStorageKey(remoteNetwork.id, l2ChainId, address), + ) + localStorage.removeItem( + getTunnelHistoryDepositStorageKey(remoteNetwork.id, l2ChainId, address), + ) + }) + const debounceTime = 500 const debouncedSaveToStorage = debounce( ({ @@ -265,9 +129,14 @@ const debouncedSaveToStorage = debounce( export const useSyncHistory = function (l2ChainId: Chain['id']) { const { address } = useAccount() + const bitcoin = useBitcoin() const { remoteNetworks } = useNetworks() + // use this boolean to check if the past history was restored from local storage const [loadedFromLocalStorage, setLoadedFromLocalStorage] = useState(false) + // use this boolean to force a resync of the history + const [forceResync, setForceResync] = useState(false) + const reducer = useReducer(historyReducer, initialState) const supportedEvmChain = useConnectedToSupportedEvmChain() @@ -302,10 +171,6 @@ export const useSyncHistory = function (l2ChainId: Chain['id']) { readStateFromStorage({ chainId: id, configChainId: id, - fallbackKey: getTunnelHistoryDepositFallbackStorageKey( - id, - address, - ), key: getTunnelHistoryDepositStorageKey(id, l2ChainId, address), }) as StorageChain, ) @@ -317,10 +182,6 @@ export const useSyncHistory = function (l2ChainId: Chain['id']) { readStateFromStorage({ chainId: id, configChainId: l2ChainId, - fallbackKey: getTunnelHistoryWithdrawStorageKeyFallback( - l2ChainId, - address, - ), key: getTunnelHistoryWithdrawStorageKey(id, l2ChainId, address), }) as StorageChain, ) @@ -348,7 +209,9 @@ export const useSyncHistory = function (l2ChainId: Chain['id']) { if ( !supportedEvmChain || !loadedFromLocalStorage || - !['finished', 'syncing'].includes(history.status) + !['finished', 'syncing'].includes(history.status) || + // if we started resync, do not save! + forceResync ) { return } @@ -356,6 +219,7 @@ export const useSyncHistory = function (l2ChainId: Chain['id']) { }, [ address, + forceResync, history, l2ChainId, loadedFromLocalStorage, @@ -364,5 +228,69 @@ export const useSyncHistory = function (l2ChainId: Chain['id']) { ], ) - return reducer + // re-sync needs to take place in an effect, because we need the workers to be tear down + // (so they start again from the last block), and then, clear the local storage + // (as local storage is saved in a debounce process, we need to ensure no other effect + // saves its sync data, overriding what we have just cleared). Once all that is completed + // we just reset the state of this hook so it starts all over. + useEffect( + function clearAndResyncHistory() { + if (!forceResync) { + return + } + // clear local storage + clearHistoryInLocalStorage({ address, l2ChainId, remoteNetworks }) + // reset the history in memory + dispatch({ type: 'reset' }) + // update flag so data is reloaded again + setLoadedFromLocalStorage(false) + // mark resync as finished + setForceResync(false) + }, + [ + address, + dispatch, + forceResync, + l2ChainId, + remoteNetworks, + setForceResync, + setLoadedFromLocalStorage, + ], + ) + + const historyContext = useMemo( + () => ({ + addDepositToTunnelHistory: (deposit: DepositTunnelOperation) => + dispatch({ payload: deposit, type: 'add-deposit' }), + addWithdrawalToTunnelHistory: (withdrawal: WithdrawTunnelOperation) => + dispatch({ payload: withdrawal, type: 'add-withdraw' }), + deposits: history.deposits + .filter(d => featureFlags.btcTunnelEnabled || d.chainId !== bitcoin.id) + .flatMap(d => d.content), + resyncHistory: () => setForceResync(true), + syncStatus: history.status, + updateDeposit: ( + deposit: DepositTunnelOperation, + updates: Partial, + ) => + dispatch({ + payload: { deposit, updates }, + type: 'update-deposit', + }), + updateWithdrawal: ( + withdraw: WithdrawTunnelOperation, + updates: Partial, + ) => + dispatch({ + payload: { updates, withdraw }, + type: 'update-withdraw', + }), + withdrawals: history.withdrawals + .filter(w => featureFlags.btcTunnelEnabled || w.chainId !== bitcoin.id) + .flatMap(w => w.content), + }), + [bitcoin.id, dispatch, history], + ) + + return [...reducer, historyContext] as const } diff --git a/webapp/hooks/useSyncHistory/reducer.ts b/webapp/hooks/useSyncHistory/reducer.ts new file mode 100644 index 00000000..15a2fb23 --- /dev/null +++ b/webapp/hooks/useSyncHistory/reducer.ts @@ -0,0 +1,155 @@ +import { hemiSepolia } from 'hemi-viem' +import { + type DepositTunnelOperation, + type WithdrawTunnelOperation, +} from 'types/tunnel' + +import { type HistoryActions, type HistoryReducerState } from './types' +import { + addOperation, + getSyncStatus, + syncContent, + updateChainSyncStatus, + updateOperation, +} from './utils' + +// the _:never is used to fail compilation if a case is missing +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const compilationError = function (_: never): never { + throw new Error('Missing implementation of action in reducer') +} + +export const initialState: HistoryReducerState = { + deposits: [], + status: 'idle', + withdrawals: [], +} + +export const historyReducer = function ( + state: HistoryReducerState, + action: HistoryActions, +): HistoryReducerState { + const getNewState = function (): Omit { + const { type } = action + switch (type) { + case 'add-deposit': { + const { payload: newDeposit } = action + const deposits = addOperation(state.deposits, newDeposit) + + return { + ...state, + deposits, + } + } + case 'add-withdraw': { + const { payload: newWithdrawal } = action + const withdrawals = addOperation(state.withdrawals, newWithdrawal) + return { + ...state, + withdrawals, + } + } + case 'reset': + return { ...initialState } + case 'restore': { + const { payload } = action + return { + ...state, + deposits: payload.deposits.map(chainDeposits => ({ + ...chainDeposits, + // See https://github.com/hemilabs/ui-monorepo/issues/376 + content: chainDeposits.content.map( + deposit => + ({ + ...deposit, + l1ChainId: deposit.l1ChainId, + l2ChainId: deposit.l2ChainId ?? hemiSepolia.id, + }) as DepositTunnelOperation, + ), + status: 'ready', + })), + withdrawals: payload.withdrawals.map(chainWithdrawals => ({ + ...chainWithdrawals, + // See https://github.com/hemilabs/ui-monorepo/issues/376 + content: chainWithdrawals.content.map( + withdrawal => + ({ + ...withdrawal, + l1ChainId: withdrawal.l1ChainId, + l2ChainId: withdrawal.l2ChainId ?? hemiSepolia.id, + }) as WithdrawTunnelOperation, + ), + status: 'ready', + })), + } + } + case 'sync': { + const { chainId } = action.payload + return updateChainSyncStatus(state, chainId, 'syncing') + } + case 'sync-deposits': { + const { chainId } = action.payload + const deposits = state.deposits.map(currentDeposits => + currentDeposits.chainId === chainId + ? syncContent(currentDeposits, action.payload) + : currentDeposits, + ) + + return { + ...state, + deposits, + } + } + case 'sync-finished': { + const { chainId } = action.payload + return updateChainSyncStatus(state, chainId, 'finished') + } + case 'sync-withdrawals': { + const { chainId } = action.payload + const withdrawals = state.withdrawals.map(chainWithdrawals => + chainWithdrawals.chainId === chainId + ? syncContent(chainWithdrawals, action.payload) + : chainWithdrawals, + ) + return { + ...state, + withdrawals, + } + } + case 'update-deposit': { + const { deposit, updates } = action.payload + const deposits = updateOperation(state.deposits, { + operation: deposit, + updates, + }) + + return { + ...state, + deposits, + } + } + case 'update-withdraw': { + const { withdraw, updates } = action.payload + const withdrawals = updateOperation(state.withdrawals, { + operation: withdraw, + updates, + }) + + return { + ...state, + withdrawals, + } + } + default: + // if a switch statement is missing on all possible actions + // this will fail on compile time + return compilationError(type) + } + } + + const newState = getNewState() + return { + ...newState, + status: getSyncStatus(newState), + } +} diff --git a/webapp/hooks/useSyncHistory/utils.ts b/webapp/hooks/useSyncHistory/utils.ts index b05245fb..aa89201b 100644 --- a/webapp/hooks/useSyncHistory/utils.ts +++ b/webapp/hooks/useSyncHistory/utils.ts @@ -10,11 +10,6 @@ import { type SyncType, } from './types' -export const getTunnelHistoryDepositFallbackStorageKey = ( - l1ChainId: RemoteChain['id'], - address: Address, -) => `portal.transaction-history-L1-${l1ChainId}-${address}-deposits` - export const getTunnelHistoryDepositStorageKey = ( l1ChainId: RemoteChain['id'], l2ChainId: Chain['id'], @@ -28,11 +23,6 @@ export const getTunnelHistoryWithdrawStorageKey = ( ) => `portal.transaction-history-${l1ChainId}-${l2ChainId}-${address}-withdrawals` -export const getTunnelHistoryWithdrawStorageKeyFallback = ( - l2ChainId: Chain['id'], - address: Address, -) => `portal.transaction-history-L2-${l2ChainId}-${address}-withdrawals` - const removeDuplicates = (merged: T[]) => Array.from(new Set(merged.map(({ transactionHash }) => transactionHash))).map( transactionHash => diff --git a/webapp/messages/en.json b/webapp/messages/en.json index c4dc6b2d..28b0ac95 100644 --- a/webapp/messages/en.json +++ b/webapp/messages/en.json @@ -68,6 +68,7 @@ "column-headers": { "amount": "Amount", "from": "From", + "reload": "Reload", "status": "Status", "time": "Time", "to": "To", diff --git a/webapp/messages/es.json b/webapp/messages/es.json index 750192bd..77f2e22c 100644 --- a/webapp/messages/es.json +++ b/webapp/messages/es.json @@ -68,6 +68,7 @@ "column-headers": { "amount": "Monto", "from": "Desde", + "reload": "Recargar", "status": "Estado", "time": "Hora", "to": "Hacia",