diff --git a/.gitignore b/.gitignore index 46ec6948..a5704bb0 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ scripts/doppler_variables.sh env.json .vercel +.env*.local diff --git a/app/(app)/(authorized)/(tabs)/example/push-notifications-helpers.tsx b/app/(app)/(authorized)/(tabs)/example/push-notifications-helpers.tsx new file mode 100644 index 00000000..8673a537 --- /dev/null +++ b/app/(app)/(authorized)/(tabs)/example/push-notifications-helpers.tsx @@ -0,0 +1,3 @@ +import { PushNotificationsHelpersScreen } from '@baca/screens' + +export default PushNotificationsHelpersScreen diff --git a/patches/expo-router+3.4.8.patch b/patches/expo-router+3.4.8.patch new file mode 100644 index 00000000..ad70234b --- /dev/null +++ b/patches/expo-router+3.4.8.patch @@ -0,0 +1,15 @@ +diff --git a/node_modules/expo-router/build/global-state/routing.js b/node_modules/expo-router/build/global-state/routing.js +index 2fba9d1..9cce0c9 100644 +--- a/node_modules/expo-router/build/global-state/routing.js ++++ b/node_modules/expo-router/build/global-state/routing.js +@@ -173,6 +173,10 @@ function getNavigateAction(state, parentState, type = 'NAVIGATE') { + else if (type === 'REPLACE' && parentState.type === 'tab') { + type = 'JUMP_TO'; + } ++ ++ // https://github.com/expo/expo/issues/26211 ++ params.initial = false ++ + return { + type, + target: parentState.key, diff --git a/src/components/HelpersScreenComponents.tsx b/src/components/HelpersScreenComponents.tsx new file mode 100644 index 00000000..9365141e --- /dev/null +++ b/src/components/HelpersScreenComponents.tsx @@ -0,0 +1,29 @@ +import { Box, Spacer, Text } from '@baca/design-system' + +export const HelperSection = ({ + header = '', + children, +}: { + header: string + children: React.ReactNode +}) => { + return ( + + {header} + + {children} + + ) +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const HelperRenderJson = ({ children }: { children: any }) => { + if (typeof children === 'undefined') { + return null + } + return ( + + {JSON.stringify(children, null, 4)} + + ) +} diff --git a/src/components/index.ts b/src/components/index.ts index 129263d8..d211f686 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,7 @@ export * from './wrappers' export * from './AppLoading' export * from './CompanyLogo' export * from './FeaturedIcon' +export * from './HelpersScreenComponents' export * from './KeyboardAwareScrollView' export * from './LandingHeader' export * from './LanguagePicker' diff --git a/src/contexts/NotificationContext.ts b/src/contexts/NotificationContext.ts index aca04d89..1a11ae6b 100644 --- a/src/contexts/NotificationContext.ts +++ b/src/contexts/NotificationContext.ts @@ -3,13 +3,16 @@ import { PermissionStatus } from 'expo-modules-core' import * as Notifications from 'expo-notifications' import { Dispatch, SetStateAction } from 'react' +export type ReceivedNotification = + | (Notifications.Notification & { context: { [key: string]: string } }) + | null + | undefined + export type NotificationContextType = { permissionStatus?: PermissionStatus setPermissionStatus: Dispatch> - notification?: Notifications.Notification - setNotification: Dispatch> - inAppNotification?: Notifications.Notification - setInAppNotification: Dispatch> + notification: ReceivedNotification + setNotification: Dispatch> } export const [useNotificationContext, NotificationContextProvider] = diff --git a/src/hooks/usePasswordValidation.tsx b/src/hooks/usePasswordValidation.tsx index 08abe42f..c89a1c96 100644 --- a/src/hooks/usePasswordValidation.tsx +++ b/src/hooks/usePasswordValidation.tsx @@ -23,7 +23,7 @@ export const usePasswordValidation = () => { !showValidationState && setShowValidationState(true) setIsPasswordError(!!min8Chars || !!min1SpecialChar) setPasswordErrors([min8Chars, min1SpecialChar]) - return !!min8Chars || !!min1SpecialChar ? 'Error' : false + return !!min8Chars || !!min1SpecialChar ? 'Error' : undefined }, [showValidationState] ) diff --git a/src/i18n/translations/en.json b/src/i18n/translations/en.json index 22faacba..a4ef8a1e 100644 --- a/src/i18n/translations/en.json +++ b/src/i18n/translations/en.json @@ -226,7 +226,7 @@ "do_not_have_an_account": "Don't have an account?", "forgot_password": "Forgot password", "sign_in_by_google": "Log in with Google", - "sign_in": "Sign in", + "sign_in": "Log in", "sign_up": "Sign up", "welcome_back_enter_details": "Please enter your details", "welcome_back": "Welcome back" diff --git a/src/i18n/translations/pl.json b/src/i18n/translations/pl.json index f77a8cc1..69ba91f3 100644 --- a/src/i18n/translations/pl.json +++ b/src/i18n/translations/pl.json @@ -231,7 +231,7 @@ }, "sign_up_screen": { "already_have_an_account": "Masz już konto?", - "get_started": "Rozpoczynamy", + "get_started": "Rozpocznij", "log_in": "Zaloguj się", "sign_up": "Zarejestruj się", "start_free_trail": "Rozpocznij darmowy 30 dniowy okres próbny." diff --git a/src/providers/NotificationProvider.tsx b/src/providers/NotificationProvider.tsx index 7c7c8e89..95c152ff 100644 --- a/src/providers/NotificationProvider.tsx +++ b/src/providers/NotificationProvider.tsx @@ -1,17 +1,16 @@ import { ASYNC_STORAGE_KEYS } from '@baca/constants' -import { NotificationContextProvider, NotificationContextType } from '@baca/contexts' -import { useState, useMemo, useEffect, useAppStateActive } from '@baca/hooks' import { - assignPushToken, - disableAndroidBackgroundNotificationListener, - getNotificationFromStack, - getNotificationStackLength, -} from '@baca/services' + NotificationContextProvider, + NotificationContextType, + ReceivedNotification, +} from '@baca/contexts' +import { useState, useMemo, useEffect, useAppStateActive } from '@baca/hooks' +import { assignPushToken } from '@baca/services' import { store } from '@baca/store' import { isSignedInAtom } from '@baca/store/auth' import AsyncStorage from '@react-native-async-storage/async-storage' import * as Notifications from 'expo-notifications' -import { router } from 'expo-router' +import { useRootNavigationState, router } from 'expo-router' import { PropsWithChildren, FC, useCallback } from 'react' import { Alert, AlertButton } from 'react-native' @@ -28,17 +27,37 @@ const deeplinkWhenNotificationReceived = async ( // Alternatively we can prevent navigating to this routes when user is not logged in if (deeplinkPath) { - router.push(deeplinkPath) + router.navigate(deeplinkPath) } } export const NotificationProvider: FC = ({ children }) => { + // ------------------------------------------------------------- + // ----------------------- HOOKS ------------------------------- + // ------------------------------------------------------------- const [permissionStatus, setPermissionStatus] = useState() - const [notification, setNotification] = useState() - const [inAppNotification, setInAppNotification] = - useState() + const [notification, setNotification] = useState(undefined) + const backgroundNotification = Notifications.useLastNotificationResponse() + const rootNavigationState = useRootNavigationState() + + // ------------------------------------------------------------- + // ------ Navigating to screen after opening notification ------ + // ------------------------------------------------------------- + + // When initializing push notifications logic navigation is not ready yet + // We need to wait for navigation to set up and that's why there is `rootNavigationState.key` listener + // Ideally this should be added as hook to layout file as described in this tutorial: + // - https://docs.expo.dev/versions/latest/sdk/notifications/#handle-push-notifications-with-navigation + useEffect(() => { + if (notification && rootNavigationState.key) { + deeplinkWhenNotificationReceived(notification) + } + }, [rootNavigationState.key, notification]) + // ------------------------------------------------------------- + // --------------- Sending push token to backend --------------- + // ------------------------------------------------------------- const tryToRegisterPushToken = useCallback(async () => { const wasPushTokenSendStringified = await AsyncStorage.getItem( ASYNC_STORAGE_KEYS.WAS_PUSH_TOKEN_SEND @@ -69,29 +88,23 @@ export const NotificationProvider: FC = ({ children }) => { // To update immediately permission status useAppStateActive(tryToRegisterPushToken, true) - // ---------------------------------------------- - // fix notifications on android when app is killed - // ---------------------------------------------- + // ------------------------------------------------------------- + // Listener for notifications when app is killed and in background + // ------------------------------------------------------------- useEffect(() => { - while (getNotificationStackLength() > 0) { - const androidBackgroundNotification = getNotificationFromStack() - if (androidBackgroundNotification) { - setNotification(androidBackgroundNotification) - deeplinkWhenNotificationReceived(androidBackgroundNotification) - } + if (backgroundNotification) { + setNotification({ + ...backgroundNotification?.notification, + context: { + source: 'useLastNotificationResponse', + }, + }) + } else { + setNotification(undefined) } - disableAndroidBackgroundNotificationListener() - - // ------------------------------------------------------------- - // Listener for notifications when app is killed and in background - // ------------------------------------------------------------- - const notificationResponseReceived = Notifications.addNotificationResponseReceivedListener( - ({ notification }) => { - setNotification(notification) - deeplinkWhenNotificationReceived(notification) - } - ) + }, [backgroundNotification]) + useEffect(() => { // -------------------------------------------------- // listener for notifications when app is in background // -------------------------------------------------- @@ -105,7 +118,14 @@ export const NotificationProvider: FC = ({ children }) => { { text: 'Ok', style: 'default', - onPress: () => deeplinkWhenNotificationReceived(notification), + onPress: () => { + setNotification({ + ...notification, + context: { + source: 'addNotificationReceivedListener', + }, + }) + }, }, ] @@ -119,7 +139,6 @@ export const NotificationProvider: FC = ({ children }) => { }) return () => { - Notifications.removeNotificationSubscription(notificationResponseReceived) Notifications.removeNotificationSubscription(notificationReceived) } }, []) @@ -130,10 +149,8 @@ export const NotificationProvider: FC = ({ children }) => { setPermissionStatus, notification, setNotification, - inAppNotification, - setInAppNotification, }), - [inAppNotification, notification, permissionStatus] + [notification, permissionStatus] ) return {children} } diff --git a/src/screens/ApplicationInfoScreen.tsx b/src/screens/ApplicationInfoScreen.tsx index ffb81e77..6499e7ae 100644 --- a/src/screens/ApplicationInfoScreen.tsx +++ b/src/screens/ApplicationInfoScreen.tsx @@ -1,16 +1,7 @@ -import { ENV, isExpoGo } from '@baca/constants' import { Box, Button, Text } from '@baca/design-system' -import { - useCallback, - usePreventGoBack, - useSafeAreaInsets, - useScreenOptions, - useTranslation, -} from '@baca/hooks' +import { usePreventGoBack, useSafeAreaInsets, useScreenOptions, useTranslation } from '@baca/hooks' // TODO: there are tons of more interesting methods there! import * as Application from 'expo-application' -import * as Clipboard from 'expo-clipboard' -import * as Notifications from 'expo-notifications' import { useRouter } from 'expo-router' import { ScrollView, StyleSheet } from 'react-native' @@ -25,51 +16,8 @@ export const ApplicationInfoScreen = (): JSX.Element => { usePreventGoBack() - const checkNotificationPermissionStatus = useCallback(async () => { - const permissions = await Notifications.getPermissionsAsync() - - alert('Permission status' + JSON.stringify(permissions, null, 2)) - }, []) - - const handleCopyPushToken = useCallback(async () => { - try { - if (!isExpoGo && !ENV.EAS_PROJECT_ID) { - throw new Error( - 'You must set `projectId` in eas build then value will be available from Constants?.expoConfig?.extra?.eas?.projectId' - ) - } - const token = ( - await Notifications.getExpoPushTokenAsync( - !isExpoGo - ? { - projectId: ENV.EAS_PROJECT_ID, - } - : {} - ) - ).data - - console.log(token) - await Clipboard.setStringAsync(token) - alert('Copied push token to clipboard.') - } catch (error) { - console.log('error', error) - alert( - JSON.stringify({ - message: 'There was an error when copying push token', - error, - }) - ) - } - }, []) - return ( - - {t('application_info_screen.navigation_info')} {Application.applicationId} {Application.applicationName} diff --git a/src/screens/ExamplesScreen.tsx b/src/screens/ExamplesScreen.tsx index 8b493f66..caba6d66 100644 --- a/src/screens/ExamplesScreen.tsx +++ b/src/screens/ExamplesScreen.tsx @@ -16,6 +16,10 @@ export const ExamplesScreen = () => { const goToTypography = useCallback(() => push('/example/typography'), [push]) const goToCityListScreen_EXAMPLE = useCallback(() => push('/example/data-from-be'), [push]) const goToTestForm = useCallback(() => push('/example/test-form'), [push]) + const goToPushNotificationsHelpers = useCallback( + () => push('/example/push-notifications-helpers'), + [push] + ) const goToUserSession = useCallback(() => push('/example/user-session'), [push]) const goToHomeStackDetails = useCallback(() => push('/home/details'), [push]) @@ -43,6 +47,10 @@ export const ExamplesScreen = () => { + {/* TODO: Add translations */} + diff --git a/src/screens/PushNotificationsHelpersScreen.tsx b/src/screens/PushNotificationsHelpersScreen.tsx new file mode 100644 index 00000000..67af91b9 --- /dev/null +++ b/src/screens/PushNotificationsHelpersScreen.tsx @@ -0,0 +1,143 @@ +import { HelperRenderJson, HelperSection } from '@baca/components' +import { ENV, isExpoGo } from '@baca/constants' +import { useNotificationContext } from '@baca/contexts' +import { Text, Button, ScrollView } from '@baca/design-system' +import { useCallback, useEffect, useState, useTranslation } from '@baca/hooks' +import { wait } from '@baca/utils' +import * as Clipboard from 'expo-clipboard' +import * as Notifications from 'expo-notifications' + +export const PushNotificationsHelpersScreen = (): JSX.Element => { + const { t } = useTranslation() + const { notification } = useNotificationContext() + + const [notificationPermissionStatus, setNotificationPermissionStatus] = + useState() + + const [listOfScheduledNotifications, setListOfScheduledNotifications] = useState< + Notifications.NotificationRequest[] + >([]) + + const checkNotificationPermissionStatus = useCallback(async () => { + const permissions = await Notifications.getPermissionsAsync() + + setNotificationPermissionStatus(permissions) + }, []) + + useEffect(() => { + checkNotificationPermissionStatus() + }, [checkNotificationPermissionStatus]) + + const getListOfScheduledNotifications = useCallback(async () => { + const listOfScheduledNotifications = await Notifications.getAllScheduledNotificationsAsync() + + setListOfScheduledNotifications(listOfScheduledNotifications) + }, []) + + useEffect(() => { + getListOfScheduledNotifications() + }, [getListOfScheduledNotifications]) + + const handleCopyPushToken = useCallback(async () => { + try { + if (!isExpoGo && !ENV.EAS_PROJECT_ID) { + throw new Error( + 'You must set `projectId` in eas build then value will be available from Constants?.expoConfig?.extra?.eas?.projectId' + ) + } + const token = ( + await Notifications.getExpoPushTokenAsync( + !isExpoGo + ? { + projectId: ENV.EAS_PROJECT_ID, + } + : {} + ) + ).data + + console.log(token) + await Clipboard.setStringAsync(token) + alert('Copied push token to clipboard.') + } catch (error) { + console.log('error', error) + alert( + JSON.stringify({ + message: 'There was an error when copying push token', + error, + }) + ) + } + }, []) + + const scheduleNotification = useCallback(async () => { + const content = { + body: 'PUSH BODY', + title: 'PUSH TITLE', + data: { deeplink: '/example/push-notifications-helpers' }, + } + const trigger10Seconds = new Date(Date.now() + 1000 * 10) + + await Notifications.scheduleNotificationAsync({ + content, + trigger: trigger10Seconds, + }) + + await wait(200) + await getListOfScheduledNotifications() + }, [getListOfScheduledNotifications]) + + return ( + + {/* TODO: Add translations */} + + + + + {/* TODO: Add translations */} + + {/* TODO: Add translations */} + + {notificationPermissionStatus && ( + <> + {/* TODO: Add translations */} + Notification permission status + {notificationPermissionStatus} + + )} + + + {/* TODO: Add translations */} + + {notification} + {/* When there is no notification we would like to also display if notification is null or undefined */} + {!notification ? typeof notification : undefined} + + + {/* TODO: Add translations */} + + {/* TODO: Add translations */} + + + + + {/* TODO: Add translations */} + Count of scheduled notifications: {listOfScheduledNotifications.length} + + + {/* TODO: Add translations */} + List of scheduled notifications + {listOfScheduledNotifications.length ? ( + {listOfScheduledNotifications} + ) : ( + // TODO: Add translations + No scheduled notifications + )} + + + ) +} diff --git a/src/screens/UserSessionScreen.tsx b/src/screens/UserSessionScreen.tsx index 72aec0a8..2dd8157c 100644 --- a/src/screens/UserSessionScreen.tsx +++ b/src/screens/UserSessionScreen.tsx @@ -1,5 +1,6 @@ import { useAuthControllerMe } from '@baca/api/query/auth/auth' -import { Box, Button, ScrollView, Text } from '@baca/design-system' +import { HelperRenderJson, HelperSection } from '@baca/components' +import { Button, ScrollView, Text } from '@baca/design-system' import { Token, getToken } from '@baca/services' import { isRefreshingTokenAtom } from '@baca/store' import { wait } from '@baca/utils' @@ -34,28 +35,24 @@ export const UserSessionScreen = () => { }, [fetchToken]) return ( - - - User data: - - Is fetching user data: - {JSON.stringify(isInitialLoading || isRefetching, null, 10)} - - {JSON.stringify(data, null, 10)} - -