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(Mobile): add notifications opt-In screen #4837

Merged
merged 16 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function RootLayout() {
<Stack.Screen name="signers" options={{ headerShown: true, title: 'Signers' }} />
<Stack.Screen name="notifications" options={{ headerShown: true, title: 'Notifications' }} />
<Stack.Screen name="app-settings" options={{ headerShown: true, title: 'Settings' }} />
<Stack.Screen name="notifications-opt-in" options={{ headerShown: true, title: '' }} />
<Stack.Screen name="+not-found" />
</Stack>
</SafeToastProvider>
Expand Down
41 changes: 41 additions & 0 deletions apps/mobile/app/notifications-opt-in.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react'
import { useColorScheme } from 'react-native'
import { OptIn } from '@/src/components/OptIn'
import useNotifications from '@/src/hooks/useNotifications'
import { router, useFocusEffect } from 'expo-router'

function NotificationsOptIn() {
const { enableNotifications, isAppNotificationEnabled } = useNotifications()
const colorScheme = useColorScheme()

useFocusEffect(() => {
if (isAppNotificationEnabled) {
router.replace('/(tabs)')
}
})

const image =
colorScheme === 'dark'
? require('@/assets/images/notifications-dark.png')
: require('@/assets/images/notifications-light.png')

return (
<OptIn
testID="notifications-opt-in"
title="Stay in the loop with account activity"
description="Get notified when you receive assets, and when transactions require your action."
image={image}
isVisible
ctaButton={{
onPress: enableNotifications,
label: 'Enable notifications',
}}
secondaryButton={{
onPress: () => router.back(),
label: 'Maybe later',
}}
/>
)
}

export default NotificationsOptIn
Binary file added apps/mobile/assets/images/notifications-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion apps/mobile/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ module.exports = {
'<rootDir>/__mocks__/fileMock.js',
},
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|react-redux|moti/.*)',
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|@notifee/react-native|react-redux|moti/.*)',
],
testPathIgnorePatterns: ['/node_modules/', '/e2e/'],
}
61 changes: 61 additions & 0 deletions apps/mobile/src/components/FloatingContainer/FloatingContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Layout } from '@/src/store/constants'
import React, { FC, useMemo } from 'react'
import { useSafeAreaInsets } from 'react-native-safe-area-context'

import { KeyboardAvoidingView, KeyboardAvoidingViewProps, Platform, StyleSheet, View } from 'react-native'

interface FloatingContainerProps {
children: React.ReactNode
noOffset?: boolean
sticky?: boolean
keyboardAvoidEnabled?: boolean
onLayout?: KeyboardAvoidingViewProps['onLayout']
testID?: string
}

export const FloatingContainer: FC<FloatingContainerProps> = ({
children,
noOffset,
sticky,
keyboardAvoidEnabled,
onLayout,
testID,
}: FloatingContainerProps) => {
const bottomInset = useSafeAreaInsets().bottom
const deviceBottom = Layout.isSmallDevice ? 10 : 20

const bottomPadding = useMemo(() => {
return Math.max(bottomInset, deviceBottom)
}, [bottomInset])

const keyboardVerticalOffset = useMemo(() => {
return noOffset ? 0 : Platform.select({ ios: 40, default: 0 })
}, [noOffset])

return (
<KeyboardAvoidingView
testID={testID}
behavior={sticky ? 'height' : 'position'}
keyboardVerticalOffset={keyboardVerticalOffset}
enabled={keyboardAvoidEnabled}
style={[styles.floatingContainer, { paddingBottom: bottomPadding }]}
onLayout={onLayout}
>
<View style={styles.childContainer}>{children}</View>
</KeyboardAvoidingView>
)
}

const styles = StyleSheet.create({
floatingContainer: {
position: 'fixed',
bottom: -40,
width: '100%',
zIndex: 1,
},
childContainer: {
flexDirection: 'column',
justifyContent: 'space-between',
flexGrow: 1,
},
})
2 changes: 2 additions & 0 deletions apps/mobile/src/components/FloatingContainer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { FloatingContainer } from './FloatingContainer'
export { FloatingContainer }
76 changes: 76 additions & 0 deletions apps/mobile/src/components/OptIn/OptIn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React from 'react'
import { ImageSourcePropType, StyleSheet } from 'react-native'
import { View, Image, Text } from 'tamagui'
import { SafeButton } from '@/src/components/SafeButton'
import { WINDOW_HEIGHT } from '@/src/store/constants'
import { FloatingContainer } from '../FloatingContainer'

interface OptInProps {
title: string
ctaButton: {
onPress: () => void
label: string
}
kicker?: string
description?: string
image?: ImageSourcePropType
secondaryButton?: {
onPress: () => void
label: string
}
testID?: string
isVisible?: boolean
}

export const OptIn: React.FC<OptInProps> = React.memo(
({ testID, kicker, title, description, image, ctaButton, secondaryButton, isVisible }: OptInProps) => {
if (!isVisible) {
return
}

return (
<View
testID={testID}
style={styles.wrapper}
padding="$4"
gap="$8"
alignItems="center"
justifyContent="flex-start"
>
{kicker && (
<Text textAlign="center" fontWeight={700} fontSize="$4" lineHeight="$6">
{kicker}
</Text>
)}
<Text textAlign="center" fontWeight={600} fontSize="$8" lineHeight="$8">
{title}
</Text>
{description && (
<Text textAlign="center" fontWeight={400} fontSize="$4">
{description}
</Text>
)}
{image && <Image style={styles.image} source={image} />}

<FloatingContainer sticky testID="notifications-opt-in-cta-buttons">
<SafeButton onPress={ctaButton.onPress} label={ctaButton.label} />
{secondaryButton && (
<SafeButton variant="secondary" onPress={secondaryButton.onPress} label={secondaryButton.label} />
)}
</FloatingContainer>
</View>
)
},
)

const styles = StyleSheet.create({
wrapper: {
flex: 1,
},
image: {
width: '100%',
height: Math.abs(WINDOW_HEIGHT * 0.42),
},
})

OptIn.displayName = 'OptIn'
2 changes: 2 additions & 0 deletions apps/mobile/src/components/OptIn/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { OptIn } from './OptIn'
export { OptIn }
11 changes: 8 additions & 3 deletions apps/mobile/src/components/SafeButton/SafeButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { styled, Text, View } from 'tamagui'
interface SafeButtonProps {
onPress: () => void
label: string
variant?: 'primary' | 'secondary'
}

export const StyledButtonWrapper = styled(View, {
Expand All @@ -14,11 +15,15 @@ export const StyledButtonWrapper = styled(View, {
borderRadius: 8,
})

export function SafeButton({ onPress, label }: SafeButtonProps) {
export function SafeButton({ onPress, label, variant = 'primary' }: SafeButtonProps) {
const variantStyles =
variant === 'primary'
? { backgroundColor: '$primary', fontColor: '$background' }
: { backgroundColor: 'inherit', fontColor: '$primary' }
return (
<TouchableOpacity onPress={onPress}>
<StyledButtonWrapper backgroundColor="$primary">
<Text fontSize="$4" fontWeight={600} color="$background">
<StyledButtonWrapper backgroundColor={variantStyles.backgroundColor}>
<Text fontSize="$4" fontWeight={600} color={variantStyles.fontColor}>
{label}
</Text>
</StyledButtonWrapper>
Expand Down
23 changes: 22 additions & 1 deletion apps/mobile/src/features/Assets/Assets.container.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import React from 'react'
import React, { useEffect } from 'react'

import { SafeTab } from '@/src/components/SafeTab'

import { TokensContainer } from '@/src/features/Assets/components/Tokens'
import { NFTsContainer } from '@/src/features/Assets/components/NFTs'
import { AssetsHeaderContainer } from '@/src/features/Assets/components/AssetsHeader'
import useNotifications from '@/src/hooks/useNotifications'
import { useRouter } from 'expo-router'
import { useAppDispatch } from '@/src/store/hooks'
import { updatePromptAttempts } from '@/src/store/notificationsSlice'

const tabItems = [
{
Expand All @@ -18,5 +22,22 @@ const tabItems = [
]

export function AssetsContainer() {
const { isAppNotificationEnabled, promptAttempts } = useNotifications()
const dispatch = useAppDispatch()
const router = useRouter()

/*
* If the user has not enabled notifications and has not been prompted to enable them,
* redirect to the opt-in screen
* */

const shouldShowOptIn = !isAppNotificationEnabled && !promptAttempts

useEffect(() => {
if (shouldShowOptIn) {
dispatch(updatePromptAttempts(1))
router.navigate('/notifications-opt-in')
}
}, [])
return <SafeTab items={tabItems} headerHeight={200} renderHeader={AssetsHeaderContainer} />
Comment on lines +25 to +41
Copy link
Contributor

Choose a reason for hiding this comment

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

This is weird. The AssetsContainer should not care about notifications.
Get some inspiration from here:
https://docs.expo.dev/router/reference/authentication/
https://reactnavigation.org/docs/auth-flow
I think that the _layout.tsx should be the place where we decide what first screen we show to the user.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I completely get your point here, but we don't wanna spam our users before the Onboarding, and if we use _layout, this takes place before anything. The other alternative would be calling it at HomeScreen, wdyt?

Copy link
Contributor

Choose a reason for hiding this comment

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

When the user clicks on "get started/continue" in the onboarding flow we should set a flag "onboarding_done": true. Next time the user starts we should not show the onboarding screens. So we will be deciding in the _layout.tsx which screen to show anyway.

}
28 changes: 24 additions & 4 deletions apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { selectMyAccountsMode, toggleMode } from '@/src/store/myAccountsSlice'
import { MyAccountsContainer, MyAccountsFooter } from '../MyAccounts'
import { useMyAccountsSortable } from '../MyAccounts/hooks/useMyAccountsSortable'
import { useAppDispatch, useAppSelector } from '@/src/store/hooks'
import { router } from 'expo-router'
import { selectAppNotificationStatus } from '@/src/store/notificationsSlice'

const dropdownLabelProps = {
fontSize: '$5',
Expand All @@ -24,8 +26,16 @@ export const Navbar = () => {
const dispatch = useAppDispatch()
const isEdit = useAppSelector(selectMyAccountsMode)
const activeSafe = useAppSelector(selectActiveSafe)
const isAppNotificationEnabled = useAppSelector(selectAppNotificationStatus)
const { safes, onDragEnd } = useMyAccountsSortable()

const handleNotificationAccess = () => {
if (!isAppNotificationEnabled) {
router.navigate('/notifications-opt-in')
}
// TODO: navigate to notifications list when notifications are enabled
}

const toggleEditMode = () => {
dispatch(toggleMode())
}
Expand Down Expand Up @@ -53,10 +63,14 @@ export const Navbar = () => {
)
}
/>

<TouchableOpacity>
<SafeFontIcon name="apps" />
</TouchableOpacity>
<View style={styles.rightButtonContainer}>
<TouchableOpacity onPress={handleNotificationAccess}>
<SafeFontIcon name="lightbulb" />
</TouchableOpacity>
<TouchableOpacity>
<SafeFontIcon name="apps" />
</TouchableOpacity>
</View>
</SafeAreaView>
</BlurredIdenticonBackground>
</View>
Expand All @@ -72,4 +86,10 @@ const styles = StyleSheet.create({
paddingVertical: 16,
paddingBottom: 0,
},
rightButtonContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
})
14 changes: 1 addition & 13 deletions apps/mobile/src/features/Onboarding/Onboarding.container.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,7 @@
import React from 'react'
import { OnboardingCarousel } from './components/OnboardingCarousel'
import { items } from './components/OnboardingCarousel/items'
import { useRouter } from 'expo-router'
import { SafeButton } from '@/src/components/SafeButton'

export function Onboarding() {
const router = useRouter()

const onGetStartedPress = () => {
router.navigate('/(tabs)')
}

return (
<OnboardingCarousel items={items}>
<SafeButton onPress={onGetStartedPress} label="Get started" />
</OnboardingCarousel>
)
return <OnboardingCarousel items={items} />
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,25 @@ export type CarouselItem = {
name: string
description?: string
image?: React.ReactNode
imagePosition?: 'top' | 'bottom'
}

interface CarouselItemProps {
item: CarouselItem
}

export const CarouselItem = ({ item: { title, description, image } }: CarouselItemProps) => {
export const CarouselItem = ({ item: { title, description, image, imagePosition = 'top' } }: CarouselItemProps) => {
return (
<View gap="$8" alignItems="center" justifyContent="center">
{image}

{imagePosition === 'top' && image}
<YStack gap="$8" paddingHorizontal="$5">
<YStack>{title}</YStack>

<Text textAlign="center" fontSize={'$4'}>
{description}
</Text>
</YStack>
{imagePosition === 'bottom' && image}
</View>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import type { Meta, StoryObj } from '@storybook/react'
import React from 'react'
import { OnboardingCarousel } from './OnboardingCarousel'
import { items } from './items'
import { SafeButton } from '@/src/components/SafeButton'
import { action } from '@storybook/addon-actions'

const meta: Meta<typeof OnboardingCarousel> = {
title: 'Carousel',
Expand All @@ -16,10 +14,6 @@ type Story = StoryObj<typeof OnboardingCarousel>

export const Default: Story = {
render: function Render(args) {
return (
<OnboardingCarousel {...args} items={items}>
<SafeButton label="Get started" onPress={action('onPress')} />
</OnboardingCarousel>
)
return <OnboardingCarousel {...args} items={items} />
},
}
Loading
Loading