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: improve deep linking #3198

Merged
merged 3 commits into from
Apr 19, 2024
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
30 changes: 17 additions & 13 deletions app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { ErrorScreen } from "./screens/error-screen"
import { PersistentStateProvider } from "./store/persistent-state"
import { detectDefaultLocale } from "./utils/locale-detector"
import "./utils/logs"
import { ActionModals, ActionsProvider } from "./components/actions"

// FIXME should we only load the currently used local?
// this would help to make the app load faster
Expand All @@ -52,19 +53,22 @@ export const App = () => (
<GaloyClient>
<GaloyThemeProvider>
<FeatureFlagContextProvider>
<NavigationContainerWrapper>
<ErrorBoundary FallbackComponent={ErrorScreen}>
<RootSiblingParent>
<NotificationsProvider>
<AppStateWrapper />
<PushNotificationComponent />
<RootStack />
<NetworkErrorComponent />
</NotificationsProvider>
<GaloyToast />
</RootSiblingParent>
</ErrorBoundary>
</NavigationContainerWrapper>
<ActionsProvider>
<NavigationContainerWrapper>
<ErrorBoundary FallbackComponent={ErrorScreen}>
<RootSiblingParent>
<NotificationsProvider>
<AppStateWrapper />
<PushNotificationComponent />
<RootStack />
<NetworkErrorComponent />
<ActionModals />
</NotificationsProvider>
<GaloyToast />
</RootSiblingParent>
</ErrorBoundary>
</NavigationContainerWrapper>
</ActionsProvider>
</FeatureFlagContextProvider>
</GaloyThemeProvider>
</GaloyClient>
Expand Down
55 changes: 55 additions & 0 deletions app/components/actions/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from "react"
import { SetLightningAddressModal } from "../set-lightning-address-modal"
import { SetDefaultAccountModal } from "../set-default-account-modal"
import { UpgradeAccountModal } from "../upgrade-account-modal"

export const Action = {
SetLnAddress: "SetLnAddress",
SetDefaultAccount: "SetDefaultAccount",
UpgradeAccount: "UpgradeAccount",
} as const

export type Action = (typeof Action)[keyof typeof Action]

type ActionsContextType = {
setActiveAction: (action: Action | null) => void
activeAction: Action | null
}

const ActionsContext = React.createContext<ActionsContextType>({
setActiveAction: () => {},
activeAction: null,
})

export const ActionsProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
const [activeAction, setActiveAction] = React.useState<Action | null>(null)

return (
<ActionsContext.Provider value={{ activeAction, setActiveAction }}>
{children}
</ActionsContext.Provider>
)
}

export const ActionModals: React.FC = () => {
const { activeAction, setActiveAction } = useActionsContext()
const closeModal = () => setActiveAction(null)
return (
<>
<SetLightningAddressModal
isVisible={activeAction === Action.SetLnAddress}
toggleModal={closeModal}
/>
<SetDefaultAccountModal
isVisible={activeAction === Action.SetDefaultAccount}
toggleModal={closeModal}
/>
<UpgradeAccountModal
isVisible={activeAction === Action.UpgradeAccount}
closeModal={closeModal}
/>
</>
)
}

export const useActionsContext = () => React.useContext(ActionsContext)
62 changes: 10 additions & 52 deletions app/components/push-notification/push-notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,62 +2,26 @@ import React, { useEffect } from "react"

import { useApolloClient } from "@apollo/client"
import { useIsAuthed } from "@app/graphql/is-authed-context"
import { useAuthenticationContext } from "@app/navigation/navigation-container-wrapper"
import { addDeviceToken, hasNotificationPermission } from "@app/utils/notifications"
import messaging, { FirebaseMessagingTypes } from "@react-native-firebase/messaging"
import { useLinkTo } from "@react-navigation/native"

import { useNotifications } from "../notifications"

const circlesNotificationTypes = [
"InnerCircleGrew",
"OuterCircleGrew",
"InnerCircleThisMonthThresholdReached",
"InnerCircleAllTimeThresholdReached",
"OuterCircleThisMonthThresholdReached",
"OuterCircleAllTimeThresholdReached",
"LeaderboardThisMonthThresholdReached",
"LeaderboardAllTimeThresholdReached",
]
import { Linking } from "react-native"

export const PushNotificationComponent = (): JSX.Element => {
const client = useApolloClient()
const isAuthed = useIsAuthed()
const { notifyCard } = useNotifications()

const linkTo = useLinkTo()
const isAppLocked = useAuthenticationContext().isAppLocked

useEffect(() => {
if (isAppLocked) {
return
}

const showNotification = (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
const followNotificationLink = (
remoteMessage: FirebaseMessagingTypes.RemoteMessage,
) => {
try {
if (remoteMessage.notification?.body) {
// TODO: add notifee library to show local notifications
console.log(
remoteMessage.notification.title || "",
remoteMessage.notification.body,
)
}

const notificationType = remoteMessage.data?.notificationType ?? ""
if (
typeof notificationType === "string" &&
circlesNotificationTypes.includes(notificationType)
) {
linkTo("/people/circles")
}

const linkToScreen = remoteMessage.data?.linkTo ?? ""
if (
typeof linkToScreen === "string" &&
linkToScreen &&
linkToScreen.startsWith("/")
) {
linkTo(linkToScreen)
Linking.openURL("blink:" + linkToScreen)
}
// linkTo throws an error if the link is invalid
} catch (error) {
Expand All @@ -68,35 +32,29 @@ export const PushNotificationComponent = (): JSX.Element => {
// When the application is running, but in the background.
const unsubscribeBackground = messaging().onNotificationOpenedApp(
(remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
showNotification(remoteMessage)
followNotificationLink(remoteMessage)
},
)

const unsubscribeInApp = messaging().onMessage(async (remoteMessage) => {
notifyCard({
text: remoteMessage.notification?.body ?? "",
title: remoteMessage.notification?.title ?? "",
action: async () => {
showNotification(remoteMessage)
},
icon: "bell",
})
console.log("A new FCM message arrived!", remoteMessage)
// TODO: add notifee library to show local notifications
})

// When the application is opened from a quit state.
messaging()
.getInitialNotification()
.then((remoteMessage: FirebaseMessagingTypes.RemoteMessage | null) => {
if (remoteMessage) {
showNotification(remoteMessage)
followNotificationLink(remoteMessage)
}
})

return () => {
unsubscribeInApp()
unsubscribeBackground()
}
}, [linkTo, isAppLocked, notifyCard])
}, [])

useEffect(() => {
;(async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@ import {
} from "@app/graphql/generated"
import { getBtcWallet, getUsdWallet } from "@app/graphql/wallets-utils"
import { useI18nContext } from "@app/i18n/i18n-react"
import { RootStackParamList } from "@app/navigation/stack-param-lists"
import crashlytics from "@react-native-firebase/crashlytics"
import { useNavigation } from "@react-navigation/native"
import { StackNavigationProp } from "@react-navigation/stack"
import { makeStyles, Text, useTheme } from "@rneui/themed"

import { GaloyCurrencyBubble } from "../atomic/galoy-currency-bubble"
Expand Down Expand Up @@ -51,7 +48,6 @@ export const SetDefaultAccountModal = ({
const [usdLoading, setUsdLoading] = React.useState(false)

const [accountUpdateDefaultWallet] = useAccountUpdateDefaultWalletIdMutation()
const navigation = useNavigation<StackNavigationProp<RootStackParamList, "Primary">>()

const { data } = useSetDefaultAccountModalQuery({
fetchPolicy: "cache-only",
Expand Down Expand Up @@ -83,7 +79,6 @@ export const SetDefaultAccountModal = ({

setHasPromptedSetDefaultAccount(client)
toggleModal()
navigation.navigate("receiveBitcoin")
}

const onPressBtcAccount = async () => {
Expand All @@ -107,7 +102,6 @@ export const SetDefaultAccountModal = ({

setHasPromptedSetDefaultAccount(client)
toggleModal()
navigation.navigate("receiveBitcoin")
}

return (
Expand Down
72 changes: 52 additions & 20 deletions app/navigation/navigation-container-wrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from "react"
import { useRef } from "react"
import { useEffect, useRef } from "react"
import { Linking } from "react-native"
import RNBootSplash from "react-native-bootsplash"

Expand All @@ -16,6 +16,7 @@ import { useTheme } from "@rneui/themed"

import { useIsAuthed } from "../graphql/is-authed-context"
import { RootStackParamList } from "./stack-param-lists"
import { Action, useActionsContext } from "@app/components/actions"

export type AuthenticationContextType = {
isAppLocked: boolean
Expand All @@ -32,31 +33,48 @@ export const AuthenticationContextProvider = AuthenticationContext.Provider

export const useAuthenticationContext = () => React.useContext(AuthenticationContext)

const processLinkForAction = (url: string): Action | null => {
// grab action query param
const urlObj = new URL(url)
const action = urlObj.searchParams.get("action")

switch ((action || "").toLocaleLowerCase()) {
case "set-ln-address":
return Action.SetLnAddress
case "set-default-account":
return Action.SetDefaultAccount
case "upgrade-account":
return Action.UpgradeAccount
}
return null
}

export const NavigationContainerWrapper: React.FC<React.PropsWithChildren> = ({
children,
}) => {
const isAuthed = useIsAuthed()
const [isAppLocked, setIsAppLocked] = React.useState(true)
const [urlAfterUnlockAndAuth, setUrlAfterUnlockAndAuth] = React.useState<string | null>(
null,
)
const { setActiveAction } = useActionsContext()

const processLink = useRef<((url: string) => void) | null>(() => {
return undefined
})
useEffect(() => {
if (isAuthed && !isAppLocked && urlAfterUnlockAndAuth) {
Linking.openURL(urlAfterUnlockAndAuth)
setUrlAfterUnlockAndAuth(null)
}
}, [isAuthed, isAppLocked, urlAfterUnlockAndAuth])

const setAppUnlocked = React.useMemo(
() => async () => {
setIsAppLocked(false)
const url = await Linking.getInitialURL()

if (url && isAuthed && processLink.current) {
return processLink.current(url)
}
},
[isAuthed],
[],
)

const setAppLocked = React.useMemo(() => () => setIsAppLocked(true), [])

const [isAppLocked, setIsAppLocked] = React.useState(true)

const routeName = useRef("Initial")

const {
Expand Down Expand Up @@ -101,6 +119,18 @@ export const NavigationContainerWrapper: React.FC<React.PropsWithChildren> = ({
receiveBitcoin: "receive",
conversionDetails: "convert",
scanningQRCode: "scan-qr",
chatbot: "chat",
totpRegistrationInitiate: "settings/2fa",
currency: "settings/display-currency",
defaultWallet: "settings/default-account",
language: "settings/language",
theme: "settings/theme",
security: "settings/security",
accountScreen: "settings/account",
transactionLimitsScreen: "settings/tx-limits",
notificationSettingsScreen: "settings/notifications",
emailRegistrationInitiate: "settings/email",
settings: "settings",
transactionDetail: {
path: "transaction/:txid",
},
Expand All @@ -109,25 +139,27 @@ export const NavigationContainerWrapper: React.FC<React.PropsWithChildren> = ({
},
getInitialURL: async () => {
const url = await Linking.getInitialURL()
console.log("getInitialURL", url)
if (Boolean(url) && isAuthed && !isAppLocked) {
return url
}
setUrlAfterUnlockAndAuth(url)
return null
},
subscribe: (listener) => {
processLink.current = listener
const onReceiveURL = ({ url }: { url: string }) => {
console.log("onReceiveURL", url)
listener(url)
if (!isAppLocked && isAuthed) {
const maybeAction = processLinkForAction(url)
if (maybeAction) {
setActiveAction(maybeAction)
}
listener(url)
} else {
setUrlAfterUnlockAndAuth(url)
}
}
// Listen to incoming links from deep linking
const subscription = Linking.addEventListener("url", onReceiveURL)

return () => {
// Clean up the event listeners
subscription.remove()
processLink.current = null
}
},
}
Expand Down
5 changes: 4 additions & 1 deletion app/screens/home-screen/home-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,10 @@ export const HomeScreen: React.FC = () => {
<AppUpdate />
<SetDefaultAccountModal
isVisible={setDefaultAccountModalVisible}
toggleModal={toggleSetDefaultAccountModal}
toggleModal={() => {
toggleSetDefaultAccountModal()
navigation.navigate("receiveBitcoin")
}}
/>
</ScrollView>
</Screen>
Expand Down
Loading