Skip to content

Commit

Permalink
feat: notifications renewal (#4699)
Browse files Browse the repository at this point in the history
  • Loading branch information
tmjssz authored Jan 8, 2025
1 parent aa96af5 commit ebfb7f4
Show file tree
Hide file tree
Showing 21 changed files with 1,457 additions and 338 deletions.
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
"date-fns": "^2.30.0",
"ethers": "^6.13.4",
"exponential-backoff": "^3.1.0",
"firebase": "^10.3.1",
"firebase": "^11.1.0",
"fuse.js": "^7.0.0",
"idb-keyval": "^6.2.1",
"js-cookie": "^3.0.1",
Expand Down
33 changes: 26 additions & 7 deletions apps/web/src/components/common/Notifications/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { ReactElement, SyntheticEvent } from 'react'
import { useCallback, useEffect } from 'react'
import React, { useCallback, useEffect } from 'react'
import groupBy from 'lodash/groupBy'
import { useAppDispatch, useAppSelector } from '@/store'
import type { Notification } from '@/store/notificationsSlice'
import { closeNotification, readNotification, selectNotifications } from '@/store/notificationsSlice'
import type { AlertColor, SnackbarCloseReason } from '@mui/material'
import { Alert, Link, Snackbar, Typography } from '@mui/material'
import { Alert, Box, Link, Snackbar, Typography } from '@mui/material'
import css from './styles.module.css'
import NextLink from 'next/link'
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
Expand All @@ -26,20 +26,39 @@ export const NotificationLink = ({
return null
}

const LinkWrapper = ({ children }: React.PropsWithChildren) =>
'href' in link ? (
<NextLink href={link.href} passHref legacyBehavior>
{children}
</NextLink>
) : (
<Box display="flex">{children}</Box>
)

const handleClick = (event: SyntheticEvent) => {
if ('onClick' in link) {
link.onClick()
}
onClick(event)
}

const isExternal =
typeof link.href === 'string' ? !isRelativeUrl(link.href) : !!(link.href.host || link.href.hostname)
'href' in link &&
(typeof link.href === 'string' ? !isRelativeUrl(link.href) : !!(link.href.host || link.href.hostname))

return (
<Track {...OVERVIEW_EVENTS.NOTIFICATION_INTERACTION} label={link.title} as="span">
<NextLink href={link.href} passHref legacyBehavior>
<LinkWrapper>
<Link
className={css.link}
onClick={onClick}
onClick={handleClick}
sx={{ cursor: 'pointer' }}
{...(isExternal && { target: '_blank', rel: 'noopener noreferrer' })}
>
{link.title} <ChevronRightIcon />
{link.title}
<ChevronRightIcon />
</Link>
</NextLink>
</LinkWrapper>
</Track>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics'
import SvgIcon from '@mui/icons-material/ExpandLess'
import { useHasFeature } from '@/hooks/useChains'
import { FEATURES } from '@/utils/chains'
import { useShowNotificationsRenewalMessage } from '@/components/settings/PushNotifications/hooks/useShowNotificationsRenewalMessage'

const NOTIFICATION_CENTER_LIMIT = 4

Expand All @@ -38,6 +39,9 @@ const NotificationCenter = (): ReactElement => {
const hasPushNotifications = useHasFeature(FEATURES.PUSH_NOTIFICATIONS)
const dispatch = useAppDispatch()

// This hook is used to show the notification renewal message when the app is opened
useShowNotificationsRenewalMessage()

const notifications = useAppSelector(selectNotifications)
const chronologicalNotifications = useMemo(() => {
// Clone as Redux returns read-only array
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useState, type ReactElement } from 'react'
import { Alert, Box, Button, Typography } from '@mui/material'
import useSafeInfo from '@/hooks/useSafeInfo'
import CheckWallet from '@/components/common/CheckWallet'
import { useNotificationsRenewal } from '@/components/settings/PushNotifications/hooks/useNotificationsRenewal'
import { useIsNotificationsRenewalEnabled } from '@/components/settings/PushNotifications/hooks/useNotificationsTokenVersion'
import { RENEWAL_MESSAGE } from '@/components/settings/PushNotifications/constants'

const NotificationRenewal = (): ReactElement => {
const { safe } = useSafeInfo()
const [isRegistering, setIsRegistering] = useState(false)
const { renewNotifications, needsRenewal } = useNotificationsRenewal()
const isNotificationsRenewalEnabled = useIsNotificationsRenewalEnabled()

if (!needsRenewal || !isNotificationsRenewalEnabled) {
// No need to renew any Safe's notifications
return <></>
}

const handeSignClick = async () => {
setIsRegistering(true)
await renewNotifications()
setIsRegistering(false)
}

return (
<>
<Alert severity="warning">
<Typography variant="body2" fontWeight={700} mb={1}>
Signature needed
</Typography>
<Typography variant="body2">{RENEWAL_MESSAGE}</Typography>
</Alert>
<Box>
<CheckWallet allowNonOwner checkNetwork={!isRegistering && safe.deployed}>
{(isOk) => (
<Button
variant="contained"
size="small"
sx={{ width: '200px' }}
onClick={handeSignClick}
disabled={!isOk || isRegistering || !safe.deployed}
>
Sign now
</Button>
)}
</CheckWallet>
</Box>
</>
)
}

export default NotificationRenewal
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import useAllOwnedSafes from '@/features/myAccounts/hooks/useAllOwnedSafes'
import useWallet from '@/hooks/wallets/useWallet'
import { selectAllAddedSafes, type AddedSafesState } from '@/store/addedSafesSlice'
import { maybePlural } from '@/utils/formatters'
import { useNotificationsRenewal } from './hooks/useNotificationsRenewal'

// UI logic

Expand Down Expand Up @@ -268,6 +269,8 @@ export const GlobalPushNotifications = (): ReactElement | null => {
const { unregisterDeviceNotifications, unregisterSafeNotifications, registerNotifications } =
useNotificationRegistrations()

const { safesForRenewal } = useNotificationsRenewal()

// Safes selected in the UI
const [selectedSafes, setSelectedSafes] = useState<NotifiableSafes>({})

Expand Down Expand Up @@ -349,7 +352,11 @@ export const GlobalPushNotifications = (): ReactElement | null => {

const registrationPromises: Array<Promise<unknown>> = []

const safesToRegister = _getSafesToRegister(selectedSafes, currentNotifiedSafes)
const newlySelectedSafes = _getSafesToRegister(selectedSafes, currentNotifiedSafes)

// Merge Safes that need to be registered with the ones for which notifications need to be renewed
const safesToRegister = _mergeNotifiableSafes(newlySelectedSafes, {}, safesForRenewal)

if (safesToRegister) {
registrationPromises.push(registerNotifications(safesToRegister))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const RENEWAL_NOTIFICATION_KEY = 'renewal'
export const RENEWAL_MESSAGE =
'We’ve upgraded your notification experience! To continue receiving important updates seamlessly, you’ll need to sign this message on every chain you use.'
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,71 @@ describe('useNotificationPreferences', () => {
})
})

describe('getChainPreferences', () => {
const chainId1 = '1'
const safeAddress1 = toBeHex('0x1', 20)
const safeAddress2 = toBeHex('0x2', 20)

const chainId2 = '2'

const preferences = {
[`${chainId1}:${safeAddress1}`]: {
chainId: chainId1,
safeAddress: safeAddress1,
preferences: DEFAULT_NOTIFICATION_PREFERENCES,
},
[`${chainId1}:${safeAddress2}`]: {
chainId: chainId1,
safeAddress: safeAddress2,
preferences: DEFAULT_NOTIFICATION_PREFERENCES,
},
[`${chainId2}:${safeAddress1}`]: {
chainId: chainId2,
safeAddress: safeAddress1,
preferences: DEFAULT_NOTIFICATION_PREFERENCES,
},
}

it('should return existing chain preferences', async () => {
await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb())

const { result } = renderHook(() => useNotificationPreferences())

await waitFor(() => {
expect(result.current.getChainPreferences(chainId1)).toEqual([
{
chainId: chainId1,
safeAddress: safeAddress1,
preferences: DEFAULT_NOTIFICATION_PREFERENCES,
},
{
chainId: chainId1,
safeAddress: safeAddress2,
preferences: DEFAULT_NOTIFICATION_PREFERENCES,
},
])
})
})

it('should return an empty array if no preferences exist for the chain', async () => {
await setMany(Object.entries(preferences), createPushNotificationPrefsIndexedDb())

const { result } = renderHook(() => useNotificationPreferences())

await waitFor(() => {
expect(result.current.getChainPreferences('3')).toEqual([])
})
})

it('should return an empty array if no preferences exist', async () => {
const { result } = renderHook(() => useNotificationPreferences())

await waitFor(() => {
expect(result.current.getChainPreferences('1')).toEqual([])
})
})
})

describe('createPreferences', () => {
it('should create preferences, then hydrate the preferences state', async () => {
const { result } = renderHook(() => useNotificationPreferences())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ import * as web3 from '@/hooks/wallets/web3'
import * as wallet from '@/hooks/wallets/useWallet'
import * as logic from '../../logic'
import * as preferences from '../useNotificationPreferences'
import * as tokenVersion from '../useNotificationsTokenVersion'
import * as notificationsSlice from '@/store/notificationsSlice'
import type { ConnectedWallet } from '@/hooks/wallets/useOnboard'
import { MockEip1193Provider } from '@/tests/mocks/providers'
import { NotificationsTokenVersion } from '@/services/push-notifications/preferences'

jest.mock('@safe-global/safe-gateway-typescript-sdk')

jest.mock('../useNotificationPreferences')
jest.mock('../useNotificationsTokenVersion')

Object.defineProperty(globalThis, 'crypto', {
value: {
Expand All @@ -28,9 +31,19 @@ describe('useNotificationRegistrations', () => {
})

describe('registerNotifications', () => {
const setTokenVersionMock = jest.fn()

beforeEach(() => {
const mockProvider = new BrowserProvider(MockEip1193Provider)
jest.spyOn(web3, 'createWeb3').mockImplementation(() => mockProvider)
jest
.spyOn(tokenVersion, 'useNotificationsTokenVersion')
.mockImplementation(
() =>
({ setTokenVersion: setTokenVersionMock }) as unknown as ReturnType<
typeof tokenVersion.useNotificationsTokenVersion
>,
)
jest.spyOn(wallet, 'default').mockImplementation(
() =>
({
Expand Down Expand Up @@ -83,6 +96,7 @@ describe('useNotificationRegistrations', () => {
await result.current.registerNotifications({})

expect(registerDeviceSpy).not.toHaveBeenCalled()
expect(setTokenVersionMock).not.toHaveBeenCalled()
})

it('does not create preferences/notify if registration does not succeed', async () => {
Expand Down Expand Up @@ -115,6 +129,7 @@ describe('useNotificationRegistrations', () => {
expect(registerDeviceSpy).toHaveBeenCalledWith(payload)

expect(createPreferencesMock).not.toHaveBeenCalled()
expect(setTokenVersionMock).not.toHaveBeenCalled()
})

it('does not create preferences/notify if registration throws', async () => {
Expand Down Expand Up @@ -147,6 +162,7 @@ describe('useNotificationRegistrations', () => {
expect(registerDeviceSpy).toHaveBeenCalledWith(payload)

expect(createPreferencesMock).not.toHaveBeenCalledWith()
expect(setTokenVersionMock).not.toHaveBeenCalledWith()
})

it('creates preferences/notifies if registration succeeded', async () => {
Expand Down Expand Up @@ -181,6 +197,9 @@ describe('useNotificationRegistrations', () => {

expect(createPreferencesMock).toHaveBeenCalled()

expect(setTokenVersionMock).toHaveBeenCalledTimes(1)
expect(setTokenVersionMock).toHaveBeenCalledWith(NotificationsTokenVersion.V2, safesToRegister)

expect(showNotificationSpy).toHaveBeenCalledWith({
groupKey: 'notifications',
message: 'You will now receive notifications for these Safe Accounts in your browser.',
Expand Down
Loading

0 comments on commit ebfb7f4

Please sign in to comment.