From 711aa0dce68493b377cda834f6cee73be05b7a67 Mon Sep 17 00:00:00 2001 From: daniel-safe Date: Wed, 5 Feb 2025 16:27:50 +0100 Subject: [PATCH] feat(mobile): add getting started screen feat(mobile): add terms and privacy policy links to getting started --- apps/mobile/app/_layout.tsx | 96 ++++++++++++------- apps/mobile/app/get-started.tsx | 8 ++ apps/mobile/app/index.tsx | 21 +++- apps/mobile/app/onboarding.tsx | 8 ++ .../src/components/SafeButton/SafeButton.tsx | 9 ++ .../Card/TxTokenCard/TxTokenCard.tsx | 2 +- apps/mobile/src/config/constants.ts | 6 ++ .../AccountItem/hooks/useEditAccountItem.ts | 2 +- .../MyAccounts/MyAccounts.container.tsx | 5 +- .../src/features/Assets/Assets.container.tsx | 23 +---- .../components/Balance/Balance.container.tsx | 4 +- .../Assets/components/NFTs/NFTs.container.tsx | 5 +- .../components/Tokens/Tokens.container.tsx | 17 ++-- .../src/features/GetStarted/GetStarted.tsx | 67 +++++++++++++ apps/mobile/src/features/GetStarted/index.tsx | 1 + .../LoadingImport/LoadingImport.container.tsx | 28 +++++- .../AddSignersForm.container.tsx | 12 ++- .../NetworksSheet/NetworksSheet.container.tsx | 5 +- .../Onboarding/Onboarding.container.test.tsx | 2 +- .../OnboardingCarousel/OnboardingCarousel.tsx | 7 +- .../features/Settings/Settings.container.tsx | 5 +- .../AppSettings/AppSettings.container.tsx | 3 + .../components/Navbar/SettingsMenu.tsx | 4 +- .../Signers/hooks/useSignersGroupService.ts | 4 +- .../TxHistory/TxHistory.container.tsx | 5 +- apps/mobile/src/hooks/usePendingTxs/index.ts | 5 +- .../src/navigation/NavigationGuardHOC.tsx | 70 ++++++++++++++ apps/mobile/src/store/activeSafeSlice.ts | 17 ++-- apps/mobile/src/store/chains/index.ts | 3 + apps/mobile/src/store/hooks/activeSafe.ts | 11 +++ apps/mobile/src/store/index.ts | 2 + apps/mobile/src/store/safesSlice.ts | 20 +--- apps/mobile/src/store/settingsSlice.ts | 29 ++++++ 33 files changed, 374 insertions(+), 132 deletions(-) create mode 100644 apps/mobile/app/get-started.tsx create mode 100644 apps/mobile/app/onboarding.tsx create mode 100644 apps/mobile/src/features/GetStarted/GetStarted.tsx create mode 100644 apps/mobile/src/features/GetStarted/index.tsx create mode 100644 apps/mobile/src/navigation/NavigationGuardHOC.tsx create mode 100644 apps/mobile/src/store/hooks/activeSafe.ts create mode 100644 apps/mobile/src/store/settingsSlice.ts diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 707d9d57a0..89701fad46 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -15,6 +15,7 @@ import { configureReanimatedLogger, ReanimatedLogLevel } from 'react-native-rean import { OnboardingHeader } from '@/src/features/Onboarding/components/OnboardingHeader' import { install } from 'react-native-quick-crypto' import { getDefaultScreenOptions } from '@/src/navigation/hooks/utils' +import { NavigationGuardHOC } from '@/src/navigation/NavigationGuardHOC' install() @@ -35,45 +36,66 @@ function RootLayout() { - ({ - ...getDefaultScreenOptions(navigation.goBack), - })} - > - - - - - + + ({ + ...getDefaultScreenOptions(navigation.goBack), + })} + > + {/**/} + + + + + + - - + + - - - - - - + + + + + + + diff --git a/apps/mobile/app/get-started.tsx b/apps/mobile/app/get-started.tsx new file mode 100644 index 0000000000..4fb87a164e --- /dev/null +++ b/apps/mobile/app/get-started.tsx @@ -0,0 +1,8 @@ +import React from 'react' +import { GetStarted } from '@/src/features/GetStarted' + +function getStartedScreen() { + return +} + +export default getStartedScreen diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index 5d869fe8b2..2c667dff09 100644 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -1,8 +1,21 @@ -import { Onboarding } from '@/src/features/Onboarding' import React from 'react' +import { View } from 'tamagui' +import { ActivityIndicator } from 'react-native' -function OnboardingPage() { - return +/** + * This is a dummy screen. Expo automatically renders it when it constructs the app. + * If we don't have an index file it will pick whatever it sees fit. This is a placeholder. + * + * The actual navigation to either onboarding flow or a safe happens inside the NavigationGuardHOC + * + * @constructor + */ +function IndexScreen() { + return ( + + + + ) } -export default OnboardingPage +export default IndexScreen diff --git a/apps/mobile/app/onboarding.tsx b/apps/mobile/app/onboarding.tsx new file mode 100644 index 0000000000..5d869fe8b2 --- /dev/null +++ b/apps/mobile/app/onboarding.tsx @@ -0,0 +1,8 @@ +import { Onboarding } from '@/src/features/Onboarding' +import React from 'react' + +function OnboardingPage() { + return +} + +export default OnboardingPage diff --git a/apps/mobile/src/components/SafeButton/SafeButton.tsx b/apps/mobile/src/components/SafeButton/SafeButton.tsx index 64b875cef1..1c86141244 100644 --- a/apps/mobile/src/components/SafeButton/SafeButton.tsx +++ b/apps/mobile/src/components/SafeButton/SafeButton.tsx @@ -45,6 +45,15 @@ export const SafeButton = styled(Button, { }, }, + outlined: { + true: { + backgroundColor: 'transparent', + borderWidth: 2, + borderColor: '$color', + color: '$color', + }, + }, + text: { true: { backgroundColor: 'transparent', diff --git a/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx index c66158c3dd..5bc79e301e 100644 --- a/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx +++ b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx @@ -36,7 +36,7 @@ const getTokenDetails = (txInfo: TransferTransactionInfo): tokenDetails => { const unnamedToken = 'Unnamed token' const nativeCurrency = useAppSelector(selectActiveChainCurrency) - if (isNativeTokenTransfer(transfer)) { + if (isNativeTokenTransfer(transfer) && nativeCurrency) { return { value: formatValue(transfer.value || '0', nativeCurrency.decimals), // take it from the native currency slice diff --git a/apps/mobile/src/config/constants.ts b/apps/mobile/src/config/constants.ts index 158774b6b7..a9a1b075a3 100644 --- a/apps/mobile/src/config/constants.ts +++ b/apps/mobile/src/config/constants.ts @@ -13,3 +13,9 @@ export const GATEWAY_URL_PRODUCTION = process.env.NEXT_PUBLIC_GATEWAY_URL_PRODUCTION || 'https://safe-client.safe.global' export const GATEWAY_URL_STAGING = process.env.NEXT_PUBLIC_GATEWAY_URL_STAGING || 'https://safe-client.staging.5afe.dev' export const GATEWAY_URL = isProduction ? GATEWAY_URL_PRODUCTION : GATEWAY_URL_STAGING + +/** + * The version of the onboarding flow. + * If we change it and need all users to see it again, we can bump the version here. + */ +export const ONBOARDING_VERSION = 'v1' diff --git a/apps/mobile/src/features/AccountsSheet/AccountItem/hooks/useEditAccountItem.ts b/apps/mobile/src/features/AccountsSheet/AccountItem/hooks/useEditAccountItem.ts index 759ba32a06..dd99ab50fd 100644 --- a/apps/mobile/src/features/AccountsSheet/AccountItem/hooks/useEditAccountItem.ts +++ b/apps/mobile/src/features/AccountsSheet/AccountItem/hooks/useEditAccountItem.ts @@ -13,7 +13,7 @@ export const useEditAccountItem = () => { const onSafeDeleted = useCallback( (address: Address) => () => { - if (activeSafe.address === address) { + if (activeSafe?.address === address) { const safe = Object.values(safes).find((item) => item.SafeInfo.address.value !== address) if (safe) { diff --git a/apps/mobile/src/features/AccountsSheet/MyAccounts/MyAccounts.container.tsx b/apps/mobile/src/features/AccountsSheet/MyAccounts/MyAccounts.container.tsx index 55106d81da..03c2951529 100644 --- a/apps/mobile/src/features/AccountsSheet/MyAccounts/MyAccounts.container.tsx +++ b/apps/mobile/src/features/AccountsSheet/MyAccounts/MyAccounts.container.tsx @@ -4,10 +4,11 @@ import { AccountItem } from '../AccountItem' import { SafesSliceItem } from '@/src/store/safesSlice' import { Address } from '@/src/types/address' import { useDispatch, useSelector } from 'react-redux' -import { selectActiveSafe, setActiveSafe } from '@/src/store/activeSafeSlice' +import { setActiveSafe } from '@/src/store/activeSafeSlice' import { getChainsByIds } from '@/src/store/chains' import { RootState } from '@/src/store' import { useMyAccounts } from './hooks/useMyAccounts' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' interface MyAccountsContainerProps { item: SafesSliceItem @@ -20,7 +21,7 @@ export function MyAccountsContainer({ item, isDragging, drag, onClose }: MyAccou useMyAccounts(item) const dispatch = useDispatch() - const activeSafe = useSelector(selectActiveSafe) + const activeSafe = useDefinedActiveSafe() const filteredChains = useSelector((state: RootState) => getChainsByIds(state, item.chains)) const handleAccountSelected = () => { diff --git a/apps/mobile/src/features/Assets/Assets.container.tsx b/apps/mobile/src/features/Assets/Assets.container.tsx index 0cbfcecfb2..11b4f3db78 100644 --- a/apps/mobile/src/features/Assets/Assets.container.tsx +++ b/apps/mobile/src/features/Assets/Assets.container.tsx @@ -1,14 +1,10 @@ -import React, { useEffect } from 'react' +import React 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 = [ { @@ -22,22 +18,5 @@ 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 } diff --git a/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx b/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx index 5908c87100..8e21f04fd1 100644 --- a/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx +++ b/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx @@ -1,4 +1,3 @@ -import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { SafeOverviewResult } from '@safe-global/store/gateway/types' import { POLLING_INTERVAL } from '@/src/config/constants' import { getChainsByIds, selectAllChains, selectChainById } from '@/src/store/chains' @@ -10,10 +9,11 @@ import { useAppSelector } from '@/src/store/hooks' import { useSafesGetOverviewForManyQuery } from '@safe-global/store/gateway/safes' import React from 'react' import { useSelector } from 'react-redux' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' export function BalanceContainer() { const chains = useAppSelector(selectAllChains) - const activeSafe = useAppSelector(selectActiveSafe) + const activeSafe = useDefinedActiveSafe() const activeSafeInfo = useAppSelector((state: RootState) => selectSafeInfo(state, activeSafe.address)) const activeSafeChains = useAppSelector((state: RootState) => getChainsByIds(state, activeSafeInfo.chains)) const { data, isLoading } = useSafesGetOverviewForManyQuery( diff --git a/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx index 10c88689ab..9c18e08af8 100644 --- a/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx +++ b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx @@ -1,10 +1,8 @@ import { safelyDecodeURIComponent } from 'expo-router/build/fork/getStateFromPath-forks' import React, { useState } from 'react' -import { useSelector } from 'react-redux' import { SafeTab } from '@/src/components/SafeTab' import { POLLING_INTERVAL } from '@/src/config/constants' -import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { Collectible, CollectiblePage, @@ -14,9 +12,10 @@ import { import { Fallback } from '../Fallback' import { NFTItem } from './NFTItem' import { useInfiniteScroll } from '@/src/hooks/useInfiniteScroll' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' export function NFTsContainer() { - const activeSafe = useSelector(selectActiveSafe) + const activeSafe = useDefinedActiveSafe() const [pageUrl, setPageUrl] = useState() const { data, isFetching, error, refetch } = useCollectiblesGetCollectiblesV2Query( diff --git a/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx index 2f3a671f35..a315f289fd 100644 --- a/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx +++ b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx @@ -11,18 +11,21 @@ import { Balance, useBalancesGetBalancesV1Query } from '@safe-global/store/gatew import { formatValue } from '@/src/utils/formatters' import { Fallback } from '../Fallback' +import { skipToken } from '@reduxjs/toolkit/query' export function TokensContainer() { const activeSafe = useSelector(selectActiveSafe) const { data, isFetching, error } = useBalancesGetBalancesV1Query( - { - chainId: activeSafe.chainId, - fiatCode: 'USD', - safeAddress: activeSafe.address, - excludeSpam: false, - trusted: true, - }, + !activeSafe + ? skipToken + : { + chainId: activeSafe.chainId, + fiatCode: 'USD', + safeAddress: activeSafe.address, + excludeSpam: false, + trusted: true, + }, { pollingInterval: POLLING_INTERVAL, }, diff --git a/apps/mobile/src/features/GetStarted/GetStarted.tsx b/apps/mobile/src/features/GetStarted/GetStarted.tsx new file mode 100644 index 0000000000..e57cd83c19 --- /dev/null +++ b/apps/mobile/src/features/GetStarted/GetStarted.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { Link, useRouter } from 'expo-router' +import { View, Text, YStack } from 'tamagui' +import { SafeButton } from '@/src/components/SafeButton' +import { SafeFontIcon } from '@/src/components/SafeFontIcon' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { BlurView } from 'expo-blur' + +export const GetStarted = () => { + const router = useRouter() + const insets = useSafeAreaInsets() + return ( + + + { + router.back() + }} + > + + + + How would you like to continue? + + }> + Join Account + + + }> + Add account + + + + By continuing, you agree to our{' '} + + + User Terms + + {' '} + and{' '} + + + Privacy Policy + + + . + + + + ) +} diff --git a/apps/mobile/src/features/GetStarted/index.tsx b/apps/mobile/src/features/GetStarted/index.tsx new file mode 100644 index 0000000000..a5a07a7f13 --- /dev/null +++ b/apps/mobile/src/features/GetStarted/index.tsx @@ -0,0 +1 @@ +export { GetStarted } from './GetStarted' diff --git a/apps/mobile/src/features/ImportPrivateKey/components/LoadingImport/LoadingImport.container.tsx b/apps/mobile/src/features/ImportPrivateKey/components/LoadingImport/LoadingImport.container.tsx index 52ac8a4dd7..919bc98b68 100644 --- a/apps/mobile/src/features/ImportPrivateKey/components/LoadingImport/LoadingImport.container.tsx +++ b/apps/mobile/src/features/ImportPrivateKey/components/LoadingImport/LoadingImport.container.tsx @@ -1,22 +1,40 @@ -import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { useAppDispatch, useAppSelector } from '@/src/store/hooks' import { useSafesGetSafeV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import { useCallback, useEffect } from 'react' import { LoadingImportComponent } from './LoadingImport' import { useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router' import { addSigner } from '@/src/store/signersSlice' +import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import { skipToken } from '@reduxjs/toolkit/query' export function LoadingImport() { const { address } = useLocalSearchParams() const dispatch = useAppDispatch() const router = useRouter() const glob = useGlobalSearchParams<{ safeAddress?: string; chainId?: string }>() + // we use this screen on the "getting started" and there we don't have an active safe const activeSafe = useAppSelector(selectActiveSafe) - const { data, error } = useSafesGetSafeV1Query({ - safeAddress: glob.safeAddress || activeSafe.address, - chainId: glob.chainId || activeSafe.chainId, - }) + let safeAddress = glob.safeAddress + let chainId = glob.chainId + if (activeSafe) { + if (!safeAddress) { + safeAddress = activeSafe.address + } + + if (!chainId) { + chainId = activeSafe.chainId + } + } + + const { data, error } = useSafesGetSafeV1Query( + safeAddress && chainId + ? { + safeAddress, + chainId, + } + : skipToken, + ) const redirectToError = useCallback(() => { router.replace({ diff --git a/apps/mobile/src/features/ImportReadOnly/AddSignersForm.container.tsx b/apps/mobile/src/features/ImportReadOnly/AddSignersForm.container.tsx index c1a5190afb..428f0d647d 100644 --- a/apps/mobile/src/features/ImportReadOnly/AddSignersForm.container.tsx +++ b/apps/mobile/src/features/ImportReadOnly/AddSignersForm.container.tsx @@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from '@/src/store/hooks' import { selectAllChainsIds } from '@/src/store/chains' import { useSafesGetOverviewForManyQuery } from '@safe-global/store/gateway/safes' import { addSafe } from '@/src/store/safesSlice' -import { setActiveSafe } from '@/src/store/activeSafeSlice' +import { selectActiveSafe, setActiveSafe } from '@/src/store/activeSafeSlice' import { Address } from '@/src/types/address' import { groupSigners } from '@/src/features/Signers/hooks/useSignersGroupService' import { selectSigners } from '@/src/store/signersSlice' @@ -18,6 +18,7 @@ export const AddSignersFormContainer = () => { const dispatch = useAppDispatch() const chainIds = useAppSelector(selectAllChainsIds) const appSigners = useAppSelector(selectSigners) + const activeSafe = useAppSelector(selectActiveSafe) const { currentData, isFetching } = useSafesGetOverviewForManyQuery({ safes: chainIds.map((chainId: string) => makeSafeId(chainId, params.safeAddress)), currency: 'usd', @@ -39,6 +40,7 @@ export const AddSignersFormContainer = () => { if (!currentData) { return } + const hasActiveSafe = !!activeSafe dispatch(addSafe({ SafeInfo: currentData[0], chains: safeAvailableOnChains })) dispatch( setActiveSafe({ @@ -50,8 +52,12 @@ export const AddSignersFormContainer = () => { router.dismissAll() // closes first screen in stack router.back() - // closes the "my accounts" screen modal - router.back() + if (!hasActiveSafe) { + router.replace('/(tabs)') + } else { + // closes the "my accounts" screen modal + router.back() + } } return ( diff --git a/apps/mobile/src/features/NetworksSheet/NetworksSheet.container.tsx b/apps/mobile/src/features/NetworksSheet/NetworksSheet.container.tsx index 849edd3cb5..56f9819450 100644 --- a/apps/mobile/src/features/NetworksSheet/NetworksSheet.container.tsx +++ b/apps/mobile/src/features/NetworksSheet/NetworksSheet.container.tsx @@ -3,17 +3,18 @@ import React from 'react' import { useAppDispatch, useAppSelector } from '@/src/store/hooks' import { RootState } from '@/src/store' import { selectAllChains, selectChainById } from '@/src/store/chains' -import { selectActiveSafe, switchActiveChain } from '@/src/store/activeSafeSlice' +import { switchActiveChain } from '@/src/store/activeSafeSlice' import { ChainItems } from '../Assets/components/Balance/ChainItems' import { useSafesGetOverviewForManyQuery } from '@safe-global/store/gateway/safes' import { SafeOverviewResult } from '@safe-global/store/gateway/types' import { makeSafeId } from '@/src/utils/formatters' import { POLLING_INTERVAL } from '@/src/config/constants' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' export const NetworksSheetContainer = () => { const dispatch = useAppDispatch() const chains = useAppSelector(selectAllChains) - const activeSafe = useAppSelector(selectActiveSafe) + const activeSafe = useDefinedActiveSafe() const activeChain = useAppSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) const { data } = useSafesGetOverviewForManyQuery( { diff --git a/apps/mobile/src/features/Onboarding/Onboarding.container.test.tsx b/apps/mobile/src/features/Onboarding/Onboarding.container.test.tsx index 0c4fe03678..3bc95142a5 100644 --- a/apps/mobile/src/features/Onboarding/Onboarding.container.test.tsx +++ b/apps/mobile/src/features/Onboarding/Onboarding.container.test.tsx @@ -21,6 +21,6 @@ describe('Onboarding Component', () => { const button = getByText('Get started') fireEvent.press(button) - expect(mockNavigate).toHaveBeenCalledWith('/(tabs)') + expect(mockNavigate).toHaveBeenCalledWith('/get-started') }) }) diff --git a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.tsx b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.tsx index 9be14e746c..67e0f084d5 100644 --- a/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.tsx +++ b/apps/mobile/src/features/Onboarding/components/OnboardingCarousel/OnboardingCarousel.tsx @@ -6,6 +6,9 @@ import { Tabs } from 'react-native-collapsible-tab-view' import { CarouselFeedback } from './CarouselFeedback' import { useRouter } from 'expo-router' +import { useAppDispatch } from '@/src/store/hooks' +import { updateSettings } from '@/src/store/settingsSlice' +import { ONBOARDING_VERSION } from '@/src/config/constants' interface OnboardingCarouselProps { items: CarouselItem[] @@ -13,10 +16,12 @@ interface OnboardingCarouselProps { export function OnboardingCarousel({ items }: OnboardingCarouselProps) { const [activeTab, setActiveTab] = useState(items[0].name) + const dispatch = useAppDispatch() const router = useRouter() const onGetStartedPress = () => { - router.navigate('/(tabs)') + dispatch(updateSettings({ onboardingVersionSeen: ONBOARDING_VERSION })) + router.navigate('/get-started') } return ( diff --git a/apps/mobile/src/features/Settings/Settings.container.tsx b/apps/mobile/src/features/Settings/Settings.container.tsx index f5eb3930d5..58f0923fd6 100644 --- a/apps/mobile/src/features/Settings/Settings.container.tsx +++ b/apps/mobile/src/features/Settings/Settings.container.tsx @@ -1,11 +1,10 @@ import { useGetSafeQuery } from '@safe-global/store/gateway' import { SafeState } from '@safe-global/store/gateway/AUTO_GENERATED/safes' -import { useSelector } from 'react-redux' -import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { Settings } from './Settings' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' export const SettingsContainer = () => { - const { chainId, address } = useSelector(selectActiveSafe) + const { chainId, address } = useDefinedActiveSafe() const { data = {} as SafeState } = useGetSafeQuery({ chainId: chainId, safeAddress: address, diff --git a/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx b/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx index c0eec6e4d0..3c82d64451 100644 --- a/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx +++ b/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx @@ -10,6 +10,9 @@ export const AppSettingsContainer = () => { const [safeAddress, setSafeAddress] = useState('') const handleSubmit = () => { + if (!activeSafe) { + return + } dispatch( setActiveSafe({ chainId: activeSafe.chainId, diff --git a/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx b/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx index db7e12d200..8714c738f6 100644 --- a/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx +++ b/apps/mobile/src/features/Settings/components/Navbar/SettingsMenu.tsx @@ -6,17 +6,17 @@ import React from 'react' import { getExplorerLink } from '@/src/utils/gateway' import { useCopyAndDispatchToast } from '@/src/hooks/useCopyAndDispatchToast' import { useToastController } from '@tamagui/toast' -import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { selectChainById } from '@/src/store/chains' import { RootState } from '@/src/store' import { useAppSelector } from '@/src/store/hooks' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' type Props = { safeAddress: string | undefined } export const SettingsMenu = ({ safeAddress }: Props) => { const toast = useToastController() - const activeSafe = useAppSelector(selectActiveSafe) + const activeSafe = useDefinedActiveSafe() const activeChain = useAppSelector((state: RootState) => selectChainById(state, activeSafe.chainId)) const copyAndDispatchToast = useCopyAndDispatchToast() const theme = useTheme() diff --git a/apps/mobile/src/features/Signers/hooks/useSignersGroupService.ts b/apps/mobile/src/features/Signers/hooks/useSignersGroupService.ts index 1c41f0d660..7725f9b2e2 100644 --- a/apps/mobile/src/features/Signers/hooks/useSignersGroupService.ts +++ b/apps/mobile/src/features/Signers/hooks/useSignersGroupService.ts @@ -2,13 +2,13 @@ import { useMemo } from 'react' import { AddressInfo, useSafesGetSafeV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/safes' import { useAppSelector } from '@/src/store/hooks' -import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { groupedSigners } from '../constants' import { selectSigners } from '@/src/store/signersSlice' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' export const useSignersGroupService = () => { - const activeSafe = useAppSelector(selectActiveSafe) + const activeSafe = useDefinedActiveSafe() const appSigners = useAppSelector(selectSigners) const { data, isFetching } = useSafesGetSafeV1Query({ safeAddress: activeSafe.address, diff --git a/apps/mobile/src/features/TxHistory/TxHistory.container.tsx b/apps/mobile/src/features/TxHistory/TxHistory.container.tsx index 1adb86a82d..77aeef4215 100644 --- a/apps/mobile/src/features/TxHistory/TxHistory.container.tsx +++ b/apps/mobile/src/features/TxHistory/TxHistory.container.tsx @@ -1,16 +1,15 @@ import React, { useEffect, useState } from 'react' -import { useSelector } from 'react-redux' import { safelyDecodeURIComponent } from 'expo-router/build/fork/getStateFromPath-forks' import { useGetTxsHistoryQuery } from '@safe-global/store/gateway' import type { TransactionItemPage } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' -import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { TxHistoryList } from '@/src/features/TxHistory/components/TxHistoryList' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' export function TxHistoryContainer() { const [pageUrl, setPageUrl] = useState() const [list, setList] = useState([]) - const activeSafe = useSelector(selectActiveSafe) + const activeSafe = useDefinedActiveSafe() const { data, refetch, isFetching, isUninitialized } = useGetTxsHistoryQuery({ chainId: activeSafe.chainId, safeAddress: activeSafe.address, diff --git a/apps/mobile/src/hooks/usePendingTxs/index.ts b/apps/mobile/src/hooks/usePendingTxs/index.ts index 16ac8c0d78..8be08a3d9a 100644 --- a/apps/mobile/src/hooks/usePendingTxs/index.ts +++ b/apps/mobile/src/hooks/usePendingTxs/index.ts @@ -1,6 +1,5 @@ import { useGetPendingTxsQuery } from '@safe-global/store/gateway' import { useMemo, useState } from 'react' -import { useSelector } from 'react-redux' import { ConflictHeaderQueuedItem, LabelQueuedItem, @@ -8,12 +7,12 @@ import { TransactionQueuedItem, } from '@safe-global/store/gateway/AUTO_GENERATED/transactions' import { groupPendingTxs } from '@/src/features/PendingTx/utils' -import { selectActiveSafe } from '@/src/store/activeSafeSlice' import { safelyDecodeURIComponent } from 'expo-router/build/fork/getStateFromPath-forks' import { useInfiniteScroll } from '../useInfiniteScroll' +import { useDefinedActiveSafe } from '@/src/store/hooks/activeSafe' const usePendingTxs = () => { - const activeSafe = useSelector(selectActiveSafe) + const activeSafe = useDefinedActiveSafe() const [pageUrl, setPageUrl] = useState() const { data, isLoading, isFetching, refetch, isUninitialized } = useGetPendingTxsQuery( diff --git a/apps/mobile/src/navigation/NavigationGuardHOC.tsx b/apps/mobile/src/navigation/NavigationGuardHOC.tsx new file mode 100644 index 0000000000..f4ef50b020 --- /dev/null +++ b/apps/mobile/src/navigation/NavigationGuardHOC.tsx @@ -0,0 +1,70 @@ +import { useRouter, useSegments } from 'expo-router' +import React, { useEffect } from 'react' +import { useAppDispatch, useAppSelector } from '@/src/store/hooks' +import { selectSettings } from '@/src/store/settingsSlice' +import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import useNotifications from '@/src/hooks/useNotifications' +import { updatePromptAttempts } from '@/src/store/notificationsSlice' +import { ONBOARDING_VERSION } from '@/src/config/constants' + +let navigated = false + +function useInitialNavigationScreen() { + const onboardingVersionSeen = useAppSelector((state) => selectSettings(state, 'onboardingVersionSeen')) + const activeSafe = useAppSelector(selectActiveSafe) + const { isAppNotificationEnabled, promptAttempts } = useNotifications() + const dispatch = useAppDispatch() + const router = useRouter() + const segments = useSegments() + + /* + * If the user has not enabled notifications and has not been prompted to enable them, + * show him the opt-in screen, but only if he is in a navigator that has (tabs) as the first screen + * */ + const shouldShowOptIn = !isAppNotificationEnabled && !promptAttempts && segments[0] === '(tabs)' + + useEffect(() => { + if (shouldShowOptIn) { + dispatch(updatePromptAttempts(1)) + // The user most probably just navigated to the (tabs) screen + // wait a bit before showing the popup + setTimeout(() => { + router.navigate('/notifications-opt-in') + }, 500) + } + }, [shouldShowOptIn]) + + React.useEffect(() => { + // We will navigate only on startup. Any other navigation should not happen here + if (navigated) { + return + } + + // We first check whether the user has seen the current version of the onboarding + if (onboardingVersionSeen !== ONBOARDING_VERSION) { + router.replace('/onboarding') + } else { + // If the user has seen the onboarding, we check if they have an active safe + // and redirect him to it + if (activeSafe) { + router.replace('/(tabs)') + } else { + // if the user doesn't have an active safe what he most probably did is to close + // the app on the onboarding screen and started it again. In this case, we show him + // again the onboarding, but also on top of it open the "get started" screen + router.replace('/onboarding') + // It makes it a bit nicer if we wait a bit before navigating to the get started screen + setTimeout(() => { + router.push('/get-started') + }, 500) + } + } + + navigated = true + }, [onboardingVersionSeen, activeSafe]) +} + +export function NavigationGuardHOC({ children }: { children: React.ReactNode }) { + useInitialNavigationScreen() + return children +} diff --git a/apps/mobile/src/store/activeSafeSlice.ts b/apps/mobile/src/store/activeSafeSlice.ts index 6a1bc19cbc..160b6012df 100644 --- a/apps/mobile/src/store/activeSafeSlice.ts +++ b/apps/mobile/src/store/activeSafeSlice.ts @@ -1,28 +1,27 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { RootState } from '.' -import { mockedActiveAccount } from './constants' import { SafeInfo } from '../types/address' -const initialState: SafeInfo = { - address: mockedActiveAccount.address, - chainId: mockedActiveAccount.chainId, -} +const initialState = null as SafeInfo | null const activeSafeSlice = createSlice({ name: 'activeSafe', initialState, reducers: { - setActiveSafe: (state, action: PayloadAction) => { + setActiveSafe: (state, action: PayloadAction) => { return action.payload }, clearActiveSafe: () => { return initialState }, switchActiveChain: (state, action: PayloadAction<{ chainId: string }>) => { - return { - ...state, - chainId: action.payload.chainId, + if (state !== null) { + return { + ...state, + chainId: action.payload.chainId, + } } + return state }, }, }) diff --git a/apps/mobile/src/store/chains/index.ts b/apps/mobile/src/store/chains/index.ts index 43f2c5a467..90068abdc6 100644 --- a/apps/mobile/src/store/chains/index.ts +++ b/apps/mobile/src/store/chains/index.ts @@ -19,6 +19,9 @@ export const selectAllChainsIds = createSelector([selectAllChains], (chains: Cha export const selectActiveChainCurrency = createSelector( [selectActiveSafe, (state: RootState) => state], (activeSafe, state) => { + if (!activeSafe) { + return null + } const chain = selectChainById(state, activeSafe.chainId) return chain?.nativeCurrency }, diff --git a/apps/mobile/src/store/hooks/activeSafe.ts b/apps/mobile/src/store/hooks/activeSafe.ts new file mode 100644 index 0000000000..7cbdd286a2 --- /dev/null +++ b/apps/mobile/src/store/hooks/activeSafe.ts @@ -0,0 +1,11 @@ +import { selectActiveSafe } from '@/src/store/activeSafeSlice' +import { useAppSelector } from '@/src/store/hooks/index' + +export const useDefinedActiveSafe = () => { + const activeSafe = useAppSelector(selectActiveSafe) + + if (activeSafe === null) { + throw new Error('No active safe selected') + } + return activeSafe +} diff --git a/apps/mobile/src/store/index.ts b/apps/mobile/src/store/index.ts index 0535bf24a7..3798df032f 100644 --- a/apps/mobile/src/store/index.ts +++ b/apps/mobile/src/store/index.ts @@ -6,6 +6,7 @@ import activeSafe from './activeSafeSlice' import signers from './signersSlice' import myAccounts from './myAccountsSlice' import notifications from './notificationsSlice' +import settings from './settingsSlice' import safes from './safesSlice' import { cgwClient, setBaseUrl } from '@safe-global/store/gateway/cgwClient' import devToolsEnhancer from 'redux-devtools-expo-dev-plugin' @@ -25,6 +26,7 @@ export const rootReducer = combineReducers({ notifications, myAccounts, signers, + settings, [cgwClient.reducerPath]: cgwClient.reducer, }) diff --git a/apps/mobile/src/store/safesSlice.ts b/apps/mobile/src/store/safesSlice.ts index 80eb3a67c7..d4f524e8b1 100644 --- a/apps/mobile/src/store/safesSlice.ts +++ b/apps/mobile/src/store/safesSlice.ts @@ -1,6 +1,5 @@ import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit' import { RootState } from '.' -import { mockedAccounts, mockedActiveAccount, mockedActiveSafeInfo } from './constants' import { Address } from '@/src/types/address' import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes' @@ -11,24 +10,7 @@ export type SafesSliceItem = { export type SafesSlice = Record -const initialState: SafesSlice = { - [mockedActiveAccount.address]: { - SafeInfo: mockedActiveSafeInfo, - chains: [mockedActiveAccount.chainId], - }, - [mockedAccounts[1].address.value]: { - SafeInfo: mockedAccounts[1], - chains: [mockedAccounts[1].chainId], - }, - [mockedAccounts[2].address.value]: { - SafeInfo: mockedAccounts[2], - chains: [mockedAccounts[2].chainId], - }, - [mockedAccounts[3].address.value]: { - SafeInfo: mockedAccounts[3], - chains: [mockedAccounts[3].chainId], - }, -} +const initialState: SafesSlice = {} const activeSafeSlice = createSlice({ name: 'safes', diff --git a/apps/mobile/src/store/settingsSlice.ts b/apps/mobile/src/store/settingsSlice.ts new file mode 100644 index 0000000000..c59a15e1cb --- /dev/null +++ b/apps/mobile/src/store/settingsSlice.ts @@ -0,0 +1,29 @@ +// src/store/settingsSlice.ts +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { RootState } from '.' + +export interface SettingsState { + onboardingVersionSeen: string +} + +const initialState: SettingsState = { + onboardingVersionSeen: '', +} + +const settingsSlice = createSlice({ + name: 'settings', + initialState, + reducers: { + updateSettings(state, action: PayloadAction>) { + return { ...state, ...action.payload } + }, + resetSettings() { + return initialState + }, + }, +}) + +export const selectSettings = (state: RootState, setting: keyof SettingsState) => state.settings[setting] + +export const { updateSettings, resetSettings } = settingsSlice.actions +export default settingsSlice.reducer