Skip to content

Commit

Permalink
feat(wallet-mobile): Add in-app notifications (#3775)
Browse files Browse the repository at this point in the history
  • Loading branch information
michaeljscript authored Jan 6, 2025
1 parent 4ed8aa3 commit 0ce90f4
Show file tree
Hide file tree
Showing 26 changed files with 644 additions and 253 deletions.
3 changes: 3 additions & 0 deletions apps/wallet-mobile/src/AppNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import {useDeepLinkWatcher} from './features/Links/common/useDeepLinkWatcher'
import {useInitNotifications} from './features/Notifications/useCases/common/hooks'
import {NotificationsDevScreen} from './features/Notifications/useCases/NotificationsDevScreen'
import {NotificationUIHandler} from './features/Notifications/useCases/NotificationUIHandler'
import {SearchProvider} from './features/Search/SearchContext'
import {SetupWalletNavigator} from './features/SetupWallet/SetupWalletNavigator'
import {useHasWallets} from './features/WalletManager/common/hooks/useHasWallets'
Expand Down Expand Up @@ -118,6 +119,8 @@ export const AppNavigator = () => {
onReady={onReady}
ref={navRef}
>
<NotificationUIHandler />

<ModalProvider>
<Stack.Navigator
screenOptions={{
Expand Down
15 changes: 15 additions & 0 deletions apps/wallet-mobile/src/components/Icon/Bell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as React from 'react'
import {Path, Svg} from 'react-native-svg'

import {IconProps} from './type'

export const Bell = ({size = 36, color = 'black'}: IconProps) => {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
d="M20.529 15.207l-1.802-1.81v-4.46a6.855 6.855 0 00-1.624-4.53 6.869 6.869 0 00-4.202-2.35A6.754 6.754 0 005.878 5.95a6.733 6.733 0 00-.605 2.777v4.67l-1.802 1.81a1.64 1.64 0 001.161 2.79h3.364v.34A3.838 3.838 0 0012 21.997a3.846 3.846 0 004.004-3.66v-.34h3.363a1.643 1.643 0 001.603-1.952 1.64 1.64 0 00-.441-.838zm-6.527 3.13A1.879 1.879 0 0112 19.997a1.883 1.883 0 01-2.002-1.66v-.34h4.004v.34zm-8.499-2.34l1.182-1.18a2 2 0 00.59-1.42v-4.67a4.725 4.725 0 011.622-3.56 4.676 4.676 0 013.744-1.17 4.857 4.857 0 014.084 4.9v4.5a2 2 0 00.58 1.42l1.192 1.18H5.503z"
fill={color}
/>
</Svg>
)
}
2 changes: 2 additions & 0 deletions apps/wallet-mobile/src/components/Icon/Icon.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,8 @@ storiesOf('Icon', module).add('Gallery', () => {
<Item icon={<Icon.Bluetooth />} title="Bluetooth" />

<Item icon={<Icon.Usb />} title="Usb" />

<Item icon={<Icon.Bell />} title="Bell" />
</ScrollView>
</FilterProvider>
)
Expand Down
2 changes: 2 additions & 0 deletions apps/wallet-mobile/src/components/Icon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {ArrowRight} from './ArrowRight'
import {Assets} from './Assets'
import {Backspace} from './Backspace'
import {Backward} from './Backward'
import {Bell} from './Bell'
import {Bio} from './Bio'
import {Bluetooth} from './Bluetooth'
import {Bug} from './Bug'
Expand Down Expand Up @@ -286,4 +287,5 @@ export const Icon = {
InfoCircle,
AngleUp,
AngleDown,
Bell,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {useNotificationManager} from '@yoroi/notifications'
import {Notifications} from '@yoroi/types'
import * as React from 'react'
import {useMemo} from 'react'

import {useNotificationDisplaySettings} from '../../Settings/useCases/changeWalletSettings/Notifications/NotificationsDisplaySettings'
import {useWalletManager} from '../../WalletManager/context/WalletManagerProvider'
import {NotificationPopup} from './common/NotificationPopup'
import {NotificationStack} from './common/NotificationStack'

const displayLimit = 3
const displayTime = 20 * 1000

export const NotificationUIHandler = () => {
const enabled = useNotificationDisplaySettings()
const {events, removeEvent} = useCollectNewNotifications({enabled})
const reversed = useMemo(() => [...events].reverse(), [events])
const displayed = reversed.slice(0, displayLimit)

if (displayed.length === 0) {
return null
}

return (
<NotificationStack>
{displayed.map((event) => (
<NotificationPopup
key={event.id}
event={event}
onCancel={() => removeEvent(event.id)}
onPress={() => removeEvent(event.id)}
/>
))}
</NotificationStack>
)
}

const useCollectNewNotifications = ({enabled}: {enabled: boolean}) => {
const manager = useNotificationManager()
const walletManager = useWalletManager()
const selectedWalletId = walletManager.selected.wallet?.id
const [events, setEvents] = React.useState<Notifications.Event[]>([])

React.useEffect(() => {
if (!enabled) return
const pushEvent = (event: Notifications.Event) => {
setEvents((e) => [...e, event])
setTimeout(() => setEvents((e) => e.filter((ev) => ev.id !== event.id)), displayTime)
}

const subscription = manager.newEvents$.subscribe((event) => {
if (event.trigger === Notifications.Trigger.RewardsUpdated && event.metadata.walletId === selectedWalletId) {
pushEvent(event)
}

if (event.trigger === Notifications.Trigger.TransactionReceived && event.metadata.walletId === selectedWalletId) {
pushEvent(event)
}
})
return () => {
subscription.unsubscribe()
}
}, [manager, setEvents, selectedWalletId, enabled])

const removeEvent = (id: number) => {
setEvents((e) => e.filter((ev) => ev.id !== id))
}

return {events, removeEvent}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {SafeAreaView} from 'react-native-safe-area-context'
import {Button} from '../../../components/Button/Button'
import {ScrollView} from '../../../components/ScrollView/ScrollView'
import {Text} from '../../../components/Text'
import {useWalletManager} from '../../WalletManager/context/WalletManagerProvider'
import {notificationManager} from './common/notification-manager'
import {createTransactionReceivedNotification} from './common/transaction-received-notification'

Expand All @@ -28,6 +29,8 @@ export const NotificationsDevScreen = () => {

const Screen = () => {
const manager = useNotificationManager()
const walletManager = useWalletManager()
const selectedWalletId = walletManager.selected.wallet?.id ?? 'walletId'

const handleOnTriggerTransactionReceived = () => {
manager.events.push(
Expand All @@ -36,6 +39,7 @@ const Screen = () => {
nextTxsCounter: 1,
txId: '123',
isSentByUser: false,
walletId: selectedWalletId,
}),
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import {useTheme} from '@yoroi/theme'
import {Notifications} from '@yoroi/types'
import * as React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'

import {Icon} from '../../../../components/Icon'
import {Text} from '../../../../components/Text'
import {useWalletNavigation} from '../../../../kernel/navigation'
import {SwipeOutWrapper} from './SwipeOutWrapper'
import {useStrings} from './useStrings'

type Props = {
event: Notifications.Event
onPress: () => void
onCancel: () => void
}

export const NotificationPopup = ({event, onPress, onCancel}: Props) => {
const navigation = useWalletNavigation()
const strings = useStrings()

if (event.trigger === Notifications.Trigger.TransactionReceived) {
return (
<SwipeOutWrapper onSwipeOut={onCancel}>
<NotificationItem
onPress={() => {
onPress()
navigation.navigateToTxHistory()
}}
icon={<TransactionReceivedIcon />}
title={strings.assetsReceived}
description={strings.tapToView}
/>
</SwipeOutWrapper>
)
}

if (event.trigger === Notifications.Trigger.RewardsUpdated) {
return (
<SwipeOutWrapper onSwipeOut={onCancel}>
<NotificationItem
onPress={() => {
onPress()
navigation.navigateToStakingDashboard()
}}
icon={<RewardsUpdatedIcon />}
title={strings.stakingRewardsReceived}
description={strings.tapToView}
/>
</SwipeOutWrapper>
)
}

return null
}

const NotificationItem = ({
onPress,
icon,
title,
description,
}: {
onPress: () => void
icon: React.ReactNode
title: string
description: string
}) => {
const {styles} = useStyles()
return (
<TouchableOpacity style={styles.container} onPress={onPress}>
{icon}

<View style={styles.content}>
<Text style={styles.title}>{title}</Text>

<Text style={styles.description}>{description}</Text>
</View>
</TouchableOpacity>
)
}

const TransactionReceivedIcon = () => {
const {styles, colors} = useStyles()
return (
<View style={[styles.icon, {backgroundColor: colors.iconBackground}]}>
<Icon.Received color={colors.iconColor} />
</View>
)
}

const RewardsUpdatedIcon = () => {
const {styles, colors} = useStyles()
return (
<View style={[styles.icon, {backgroundColor: colors.iconBackground}]}>
<Icon.Staking color={colors.iconColor} />
</View>
)
}

const useStyles = () => {
const {atoms, color} = useTheme()
const styles = StyleSheet.create({
container: {
flex: 1,
height: 76,
borderRadius: 6,
...atoms.p_lg,
...atoms.gap_lg,
...atoms.flex_row,
backgroundColor: color.bg_color_max,
},
icon: {
width: 40,
height: 40,
borderRadius: 20,
...atoms.align_center,
...atoms.justify_center,
},
content: {
...atoms.flex_col,
...atoms.gap_xs,
},
title: {
...atoms.body_2_md_regular,
...atoms.font_semibold,
},
description: {
...atoms.link_2_md,
color: color.gray_600,
},
})

return {styles, colors: {iconColor: color.secondary_600, iconBackground: color.secondary_100}}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {useTheme} from '@yoroi/theme'
import * as React from 'react'
import {StyleSheet, View} from 'react-native'
import {SafeAreaView} from 'react-native-safe-area-context'

type Props = {
children: React.ReactNode
}

export const NotificationStack = ({children}: Props) => {
const {styles} = useStyles()
return (
<View style={styles.absolute}>
<SafeAreaView edges={['top']}>
<View style={styles.flex}>{children}</View>
</SafeAreaView>
</View>
)
}

const useStyles = () => {
const {atoms} = useTheme()
const styles = StyleSheet.create({
absolute: {
...atoms.absolute,
top: 0,
left: 0,
right: 0,
...atoms.z_50,
...atoms.p_lg,
},
flex: {
...atoms.gap_sm,
...atoms.flex_col,
},
})

return {styles}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as React from 'react'
import {Animated, Dimensions, PanResponder} from 'react-native'

type Props = {
children: React.ReactNode
onSwipeOut: () => void
}

export const SwipeOutWrapper = ({children, onSwipeOut}: Props) => {
const {pan, panResponder} = usePanAnimation({onRelease: onSwipeOut})

return (
<Animated.View
style={{
transform: [{translateX: pan.x}],
}}
{...panResponder.panHandlers}
>
{children}
</Animated.View>
)
}

const usePanAnimation = ({onRelease}: {onRelease: () => void}) => {
const pan = React.useRef(new Animated.ValueXY()).current
const screenWidth = Dimensions.get('window').width
const screenLimitInPercentAfterWhichShouldRelease = 0.3

const panResponder = React.useRef(
PanResponder.create({
onMoveShouldSetPanResponder: () => true,
onPanResponderMove: (e, gestureState) => {
if (gestureState.dx > 0) {
Animated.event([null, {dx: pan.x, dy: pan.y}], {useNativeDriver: false})(e, gestureState)
}
},
onPanResponderRelease: (e, gestureState) => {
if (gestureState.dx > screenWidth * screenLimitInPercentAfterWhichShouldRelease) {
Animated.spring(pan, {
toValue: {x: screenWidth, y: 0},
useNativeDriver: false,
}).start(() => onRelease())
} else {
Animated.spring(pan, {
toValue: {x: 0, y: 0},
useNativeDriver: false,
}).start()
}
},
}),
).current

return {pan, panResponder}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const init = () => {
export const useInitNotifications = ({enabled}: {enabled: boolean}) => {
React.useEffect(() => (enabled ? init() : undefined), [enabled])
useTransactionReceivedNotifications({enabled})
usePrimaryTokenPriceChangedNotification({enabled})
usePrimaryTokenPriceChangedNotification({enabled: false}) // Temporarily disabled until requested by product team
useRewardsUpdatedNotifications({enabled})
usePushNotifications({enabled})
}
Expand Down
Loading

0 comments on commit 0ce90f4

Please sign in to comment.