Skip to content

Commit

Permalink
feat: pending recovery widget (#2772)
Browse files Browse the repository at this point in the history
* 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
iamacook authored Nov 20, 2023
1 parent 41841cc commit a759ff4
Show file tree
Hide file tree
Showing 8 changed files with 460 additions and 2 deletions.
18 changes: 18 additions & 0 deletions public/images/common/recovery-pending.svg
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 src/components/dashboard/RecoveryInProgress/index.test.tsx
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()
})
})
})
139 changes: 139 additions & 0 deletions src/components/dashboard/RecoveryInProgress/index.tsx
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,
})
3 changes: 3 additions & 0 deletions src/components/dashboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Recovery } from './Recovery'
import { FEATURES } from '@/utils/chains'
import { useHasFeature } from '@/hooks/useChains'
import { CREATION_MODAL_QUERY_PARM } from '../new-safe/create/logic'
import { RecoveryInProgress } from './RecoveryInProgress'

const Dashboard = (): ReactElement => {
const router = useRouter()
Expand All @@ -20,6 +21,8 @@ const Dashboard = (): ReactElement => {
return (
<>
<Grid container spacing={3}>
<RecoveryInProgress />

<Grid item xs={12} lg={6}>
<Overview />
</Grid>
Expand Down
Loading

0 comments on commit a759ff4

Please sign in to comment.