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.
162 changes: 162 additions & 0 deletions src/components/dashboard/RecoveryInProgress/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { useBlockTimestamp } from '@/hooks/useBlockTimestamp'
import { useHasFeature } from '@/hooks/useChains'
import { useAppSelector } from '@/store'
import { render } from '@testing-library/react'
import { BigNumber } from 'ethers'
import { RecoveryInProgress, _getCountdown } from '.'

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 })
})
})

jest.mock('@/hooks/useBlockTimestamp')
jest.mock('@/store')
jest.mock('@/hooks/useChains')

const mockUseBlockTimestamp = useBlockTimestamp as jest.MockedFunction<typeof useBlockTimestamp>
const mockUseAppSelector = useAppSelector as jest.MockedFunction<typeof useAppSelector>
const mockUseHasFeature = useHasFeature as jest.MockedFunction<typeof useHasFeature>
usame-algan marked this conversation as resolved.
Show resolved Hide resolved

describe('RecoveryInProgress', () => {
it('should return null if the chain does not support recovery', () => {
mockUseHasFeature.mockReturnValue(false)
mockUseBlockTimestamp.mockReturnValue(0)
mockUseAppSelector.mockReturnValue([{ queue: [{ timestamp: 0 }] }])

const result = render(<RecoveryInProgress />)

expect(result.container).toBeEmptyDOMElement()
})

it('should return null if there is no block timestamp', () => {
mockUseHasFeature.mockReturnValue(true)
mockUseBlockTimestamp.mockReturnValue(undefined)
mockUseAppSelector.mockReturnValue([{ queue: [{ timestamp: 0 }] }])

const result = render(<RecoveryInProgress />)

expect(result.container).toBeEmptyDOMElement()
})

it('should return null if there are no delayed transactions', () => {
mockUseHasFeature.mockReturnValue(true)
mockUseBlockTimestamp.mockReturnValue(undefined)
mockUseAppSelector.mockReturnValue([{ queue: [] }])

const result = render(<RecoveryInProgress />)

expect(result.container).toBeEmptyDOMElement()
})

it('should return null if all the delayed transactions are expired and invalid', () => {
mockUseHasFeature.mockReturnValue(true)
mockUseBlockTimestamp.mockReturnValue(69420)
mockUseAppSelector.mockReturnValue([
{
queue: [
{
timestamp: 0,
validFrom: BigNumber.from(69),
expiresAt: BigNumber.from(420),
},
],
},
])

const result = render(<RecoveryInProgress />)

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

mockUseHasFeature.mockReturnValue(true)
mockUseBlockTimestamp.mockReturnValue(mockBlockTimestamp)
mockUseAppSelector.mockReturnValue([
{
queue: [
{
timestamp: mockBlockTimestamp + 1,
validFrom: BigNumber.from(mockBlockTimestamp + 1), // Invalid
expiresAt: BigNumber.from(mockBlockTimestamp + 1), // Non-expired
},
{
// Older - should render this
timestamp: mockBlockTimestamp,
validFrom: BigNumber.from(mockBlockTimestamp * 4), // Invalid
expiresAt: null, // Non-expired
},
],
},
])

const { queryByText } = render(<RecoveryInProgress />)

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

mockUseHasFeature.mockReturnValue(true)
mockUseBlockTimestamp.mockReturnValue(mockBlockTimestamp)
mockUseAppSelector.mockReturnValue([
{
queue: [
{
timestamp: mockBlockTimestamp - 1,
validFrom: BigNumber.from(mockBlockTimestamp - 1), // Invalid
expiresAt: BigNumber.from(mockBlockTimestamp - 1), // Non-expired
},
{
// Older - should render this
timestamp: mockBlockTimestamp - 2,
validFrom: BigNumber.from(mockBlockTimestamp - 1), // Invalid
expiresAt: null, // Non-expired
},
],
},
])

const { queryByText } = render(<RecoveryInProgress />)

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()
})
})
})
116 changes: 116 additions & 0 deletions src/components/dashboard/RecoveryInProgress/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { Box, Card, Grid, Typography } from '@mui/material'
import type { ReactElement } from 'react'

import { useAppSelector } from '@/store'
import { selectRecovery } from '@/store/recoverySlice'
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'

export function RecoveryInProgress(): ReactElement | null {
const blockTimestamp = useBlockTimestamp()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that it can take a few seconds for the blockTimestamp to be there. I even navigated away when I first opened the dashboard because it didn't show up for a while. I would suggest adding a loading state and a Skeleton.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a Skeleton in 9d5270e.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we adjust it so the Skeleton is only displayed if the safe has recovery enabled? I can see it flashing now even in safes where no recovery module is enabled.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loading recovery takes longer than the timestamp so we do not know whether it is enabled. I would sooner suggest we opt for no loader, but dispatch a notification as well in case the user navigates away. What do you think @TanyaEfremova?

(Due to capacity, I will remove this for now and adjust it in a followup PR as to remove blockers.)

const supportsRecovery = useHasFeature(FEATURES.RECOVERY)
const recovery = useAppSelector(selectRecovery)
usame-algan marked this conversation as resolved.
Show resolved Hide resolved

if (!blockTimestamp || !supportsRecovery) {
return null
}

const nonExpiredTxs = recovery
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somehow these queue items contain a lot more properties than what the type says e.g. functions like getBlock etc. but they are also not visible in the Redux store, only in the console.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redux only shows serializable data, hence it not showing in the DevTools. As we are not persisting the data, nor relying on these "hidden" properties, I think we can ignore these.

.flatMap(({ queue }) => queue)
.sort((a, b) => a.timestamp - b.timestamp)
.filter((delayedTx) => {
return delayedTx.expiresAt ? delayedTx.expiresAt.gt(blockTimestamp) : true
})
usame-algan marked this conversation as resolved.
Show resolved Hide resolved

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>
{isValid ? null : <Countdown seconds={secondsUntilValid} />}
usame-algan marked this conversation as resolved.
Show resolved Hide resolved
</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 = 60
const hour = 60 * minute
const day = 24 * hour
usame-algan marked this conversation as resolved.
Show resolved Hide resolved

const days = Math.floor(seconds / day)

const remainingSeconds = seconds % day
const hours = Math.floor(remainingSeconds / hour)
const minutes = Math.floor((remainingSeconds % hour) / minute)

return { days, hours, minutes }
}

function Countdown({ seconds }: { seconds: number }): ReactElement | 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>
)
}
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