Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: pending recovery widget #2772

Merged
merged 16 commits into from
Nov 20, 2023
Merged
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 Relaying from '@/components/dashboard/Relaying'
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
6 changes: 6 additions & 0 deletions src/hooks/__tests__/useLoadRecovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ describe('useLoadRecovery', () => {
txNonce,
queueNonce,
queue: [
{
...transactionsAdded[0],
timestamp: 69,
validFrom: BigNumber.from(69).add(txCooldown),
expiresAt: null,
},
{
...transactionsAdded[1],
timestamp: 420,
Expand Down
Loading
Loading