-
Notifications
You must be signed in to change notification settings - Fork 463
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: pending recovery widget (#2772)
* feat: poll recovery state of Safe * fix: remove builders * fix: add `timestamp` and validity to state * fix: types * feat: pending recovery widget * fix: `gap` * fix: add flag check * fix: don't use `Date.now` * fix: use fake timers * fix: test * fix: tests, logo + add `Skeleton` * fix: test * fix: reduce interval + remove `Skeleton`
- Loading branch information
Showing
8 changed files
with
460 additions
and
2 deletions.
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
175 changes: 175 additions & 0 deletions
175
src/components/dashboard/RecoveryInProgress/index.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,175 @@ | ||
import { render } from '@testing-library/react' | ||
import { BigNumber } from 'ethers' | ||
|
||
import { _getCountdown, _RecoveryInProgress } from '.' | ||
import type { RecoveryQueueItem, RecoveryState } from '@/store/recoverySlice' | ||
|
||
describe('getCountdown', () => { | ||
it('should convert 0 seconds to 0 days, 0 hours, and 0 minutes', () => { | ||
const result = _getCountdown(0) | ||
expect(result).toEqual({ days: 0, hours: 0, minutes: 0 }) | ||
}) | ||
|
||
it('should convert 3600 seconds to 0 days, 1 hour, and 0 minutes', () => { | ||
const result = _getCountdown(3600) | ||
expect(result).toEqual({ days: 0, hours: 1, minutes: 0 }) | ||
}) | ||
|
||
it('should convert 86400 seconds to 1 day, 0 hours, and 0 minutes', () => { | ||
const result = _getCountdown(86400) | ||
expect(result).toEqual({ days: 1, hours: 0, minutes: 0 }) | ||
}) | ||
|
||
it('should convert 123456 seconds to 1 day, 10 hours, and 17 minutes', () => { | ||
const result = _getCountdown(123456) | ||
expect(result).toEqual({ days: 1, hours: 10, minutes: 17 }) | ||
}) | ||
}) | ||
|
||
describe('RecoveryInProgress', () => { | ||
beforeEach(() => { | ||
jest.resetAllMocks() | ||
}) | ||
|
||
it('should return null if the chain does not support recovery', () => { | ||
const result = render( | ||
<_RecoveryInProgress | ||
supportsRecovery={false} | ||
blockTimestamp={0} | ||
recovery={[{ queue: [{ timestamp: 0 } as RecoveryQueueItem] }] as RecoveryState} | ||
/>, | ||
) | ||
|
||
expect(result.container).toBeEmptyDOMElement() | ||
}) | ||
|
||
it('should return a loader if there is no block timestamp', () => { | ||
const result = render( | ||
<_RecoveryInProgress | ||
supportsRecovery={false} | ||
blockTimestamp={undefined} | ||
recovery={[{ queue: [{ timestamp: 0 } as RecoveryQueueItem] }] as RecoveryState} | ||
/>, | ||
) | ||
|
||
expect(result.container).toBeEmptyDOMElement() | ||
}) | ||
|
||
it('should return null if there are no delayed transactions', () => { | ||
const result = render( | ||
<_RecoveryInProgress | ||
supportsRecovery={true} | ||
blockTimestamp={69420} | ||
recovery={[{ queue: [] as Array<RecoveryQueueItem> }] as RecoveryState} | ||
/>, | ||
) | ||
|
||
expect(result.container).toBeEmptyDOMElement() | ||
}) | ||
|
||
it('should return null if all the delayed transactions are expired and invalid', () => { | ||
const result = render( | ||
<_RecoveryInProgress | ||
supportsRecovery={true} | ||
blockTimestamp={69420} | ||
recovery={ | ||
[ | ||
{ | ||
queue: [ | ||
{ | ||
timestamp: 0, | ||
validFrom: BigNumber.from(69), | ||
expiresAt: BigNumber.from(420), | ||
} as RecoveryQueueItem, | ||
], | ||
}, | ||
] as RecoveryState | ||
} | ||
/>, | ||
) | ||
|
||
expect(result.container).toBeEmptyDOMElement() | ||
}) | ||
|
||
it('should return the countdown of the latest non-expired/invalid transactions if none are non-expired/valid', () => { | ||
const mockBlockTimestamp = 69420 | ||
|
||
const { queryByText } = render( | ||
<_RecoveryInProgress | ||
supportsRecovery={true} | ||
blockTimestamp={mockBlockTimestamp} | ||
recovery={ | ||
[ | ||
{ | ||
queue: [ | ||
{ | ||
timestamp: mockBlockTimestamp + 1, | ||
validFrom: BigNumber.from(mockBlockTimestamp + 1), // Invalid | ||
expiresAt: BigNumber.from(mockBlockTimestamp + 1), // Non-expired | ||
} as RecoveryQueueItem, | ||
{ | ||
// Older - should render this | ||
timestamp: mockBlockTimestamp, | ||
validFrom: BigNumber.from(mockBlockTimestamp * 4), // Invalid | ||
expiresAt: null, // Non-expired | ||
} as RecoveryQueueItem, | ||
], | ||
}, | ||
] as RecoveryState | ||
} | ||
/>, | ||
) | ||
|
||
expect(queryByText('Account recovery in progress')).toBeInTheDocument() | ||
expect( | ||
queryByText('The recovery process has started. This Account will be ready to recover in:'), | ||
).toBeInTheDocument() | ||
;['day', 'hr', 'min'].forEach((unit) => { | ||
// May be pluralised | ||
expect(queryByText(unit, { exact: false })).toBeInTheDocument() | ||
}) | ||
// Days | ||
expect(queryByText('2')).toBeInTheDocument() | ||
// Hours | ||
expect(queryByText('9')).toBeInTheDocument() | ||
// Mins | ||
expect(queryByText('51')).toBeInTheDocument() | ||
}) | ||
|
||
it('should return the info of the latest non-expired/valid transactions', () => { | ||
const mockBlockTimestamp = 69420 | ||
|
||
const { queryByText } = render( | ||
<_RecoveryInProgress | ||
supportsRecovery={true} | ||
blockTimestamp={mockBlockTimestamp} | ||
recovery={ | ||
[ | ||
{ | ||
queue: [ | ||
{ | ||
timestamp: mockBlockTimestamp - 1, | ||
validFrom: BigNumber.from(mockBlockTimestamp - 1), // Invalid | ||
expiresAt: BigNumber.from(mockBlockTimestamp - 1), // Non-expired | ||
} as RecoveryQueueItem, | ||
{ | ||
// Older - should render this | ||
timestamp: mockBlockTimestamp - 2, | ||
validFrom: BigNumber.from(mockBlockTimestamp - 1), // Invalid | ||
expiresAt: null, // Non-expired | ||
} as RecoveryQueueItem, | ||
], | ||
}, | ||
] as RecoveryState | ||
} | ||
/>, | ||
) | ||
|
||
expect(queryByText('Account recovery possible')).toBeInTheDocument() | ||
expect(queryByText('The recovery process is possible. This Account can be recovered.')).toBeInTheDocument() | ||
;['day', 'hr', 'min'].forEach((unit) => { | ||
// May be pluralised | ||
expect(queryByText(unit, { exact: false })).not.toBeInTheDocument() | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,139 @@ | ||
import { Box, Card, Grid, Typography } from '@mui/material' | ||
import { useMemo } from 'react' | ||
import type { ReactElement } from 'react' | ||
|
||
import { useAppSelector } from '@/store' | ||
import { useBlockTimestamp } from '@/hooks/useBlockTimestamp' | ||
import { WidgetContainer, WidgetBody } from '../styled' | ||
import RecoveryPending from '@/public/images/common/recovery-pending.svg' | ||
import ExternalLink from '@/components/common/ExternalLink' | ||
import { useHasFeature } from '@/hooks/useChains' | ||
import { FEATURES } from '@/utils/chains' | ||
import { selectRecovery } from '@/store/recoverySlice' | ||
import type { RecoveryState } from '@/store/recoverySlice' | ||
import madProps from '@/utils/mad-props' | ||
|
||
export function _RecoveryInProgress({ | ||
blockTimestamp, | ||
supportsRecovery, | ||
recovery, | ||
}: { | ||
blockTimestamp?: number | ||
supportsRecovery: boolean | ||
recovery: RecoveryState | ||
}): ReactElement | null { | ||
const allRecoveryTxs = useMemo(() => { | ||
return recovery.flatMap(({ queue }) => queue).sort((a, b) => a.timestamp - b.timestamp) | ||
}, [recovery]) | ||
|
||
if (!supportsRecovery || !blockTimestamp) { | ||
return null | ||
} | ||
|
||
const nonExpiredTxs = allRecoveryTxs.filter((delayedTx) => { | ||
return delayedTx.expiresAt ? delayedTx.expiresAt.gt(blockTimestamp) : true | ||
}) | ||
|
||
if (nonExpiredTxs.length === 0) { | ||
return null | ||
} | ||
|
||
const nextTx = nonExpiredTxs[0] | ||
|
||
// TODO: Migrate `isValid` components when https://github.com/safe-global/safe-wallet-web/issues/2758 is done | ||
const isValid = nextTx.validFrom.lte(blockTimestamp) | ||
const secondsUntilValid = nextTx.validFrom.sub(blockTimestamp).toNumber() | ||
|
||
return ( | ||
<Grid item xs={12}> | ||
<WidgetContainer> | ||
<WidgetBody> | ||
<Card sx={{ py: 3, px: 4 }}> | ||
<Grid container display="flex" alignItems="center" gap={3}> | ||
<Grid item> | ||
<RecoveryPending /> | ||
</Grid> | ||
<Grid item xs> | ||
<Typography variant="h6" fontWeight={700} mb={1}> | ||
{isValid ? 'Account recovery possible' : 'Account recovery in progress'} | ||
</Typography> | ||
<Typography color="primary.light" mb={1}> | ||
{isValid | ||
? 'The recovery process is possible. This Account can be recovered.' | ||
: 'The recovery process has started. This Account will be ready to recover in:'} | ||
</Typography> | ||
<Countdown seconds={secondsUntilValid} /> | ||
</Grid> | ||
<Grid item> | ||
<ExternalLink | ||
href="#" // TODO: Link to docs | ||
title="Learn about the Account recovery process" | ||
> | ||
Learn more | ||
</ExternalLink> | ||
</Grid> | ||
</Grid> | ||
</Card> | ||
</WidgetBody> | ||
</WidgetContainer> | ||
</Grid> | ||
) | ||
} | ||
|
||
export function _getCountdown(seconds: number): { days: number; hours: number; minutes: number } { | ||
const MINUTE_IN_SECONDS = 60 | ||
const HOUR_IN_SECONDS = 60 * MINUTE_IN_SECONDS | ||
const DAY_IN_SECONDS = 24 * HOUR_IN_SECONDS | ||
|
||
const days = Math.floor(seconds / DAY_IN_SECONDS) | ||
|
||
const remainingSeconds = seconds % DAY_IN_SECONDS | ||
const hours = Math.floor(remainingSeconds / HOUR_IN_SECONDS) | ||
const minutes = Math.floor((remainingSeconds % HOUR_IN_SECONDS) / MINUTE_IN_SECONDS) | ||
|
||
return { days, hours, minutes } | ||
} | ||
|
||
function Countdown({ seconds }: { seconds: number }): ReactElement | null { | ||
if (seconds <= 0) { | ||
return null | ||
} | ||
|
||
const { days, hours, minutes } = _getCountdown(seconds) | ||
|
||
return ( | ||
<Box display="flex" gap={1}> | ||
<TimeLeft value={days} unit="day" /> | ||
<TimeLeft value={hours} unit="hr" /> | ||
<TimeLeft value={minutes} unit="min" /> | ||
</Box> | ||
) | ||
} | ||
|
||
function TimeLeft({ value, unit }: { value: number; unit: string }): ReactElement | null { | ||
if (value === 0) { | ||
return null | ||
} | ||
|
||
return ( | ||
<div> | ||
<Typography fontWeight={700} component="span"> | ||
{value} | ||
</Typography>{' '} | ||
<Typography color="primary.light" component="span"> | ||
{value === 1 ? unit : `${unit}s`} | ||
</Typography> | ||
</div> | ||
) | ||
} | ||
|
||
// Appease React TypeScript warnings | ||
const _useBlockTimestamp = () => useBlockTimestamp(60_000) // Countdown does not display | ||
const _useSupportsRecovery = () => useHasFeature(FEATURES.RECOVERY) | ||
const _useRecovery = () => useAppSelector(selectRecovery) | ||
|
||
export const RecoveryInProgress = madProps(_RecoveryInProgress, { | ||
blockTimestamp: _useBlockTimestamp, | ||
supportsRecovery: _useSupportsRecovery, | ||
recovery: _useRecovery, | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.