From 3a5f21930ed5b4eb51ba0274252aafd51ad96c99 Mon Sep 17 00:00:00 2001 From: Hyo Date: Wed, 7 Aug 2024 11:23:12 +0900 Subject: [PATCH] Add Push Notification Token Management (#8) # Add Notification Permission and Push Token Management ## Description Implemented notification permission handling using `expo-notifications`. The app now checks for and requests notification permissions on launch and when the app becomes active. Additionally, implemented push notification token handling using Supabase. Tokens are registered on login and deleted on logout. ## Changes ### Notification Permissions - Integrated `expo-notifications` to manage notification permissions. - Added permission check on app launch. - Re-checked permissions when the app becomes active using `AppState`. - Configured `LSApplicationQueriesSchemes` for `mailto:` in `app.json`. ### Push Token Management - Added functions for push token management in Supabase: - `fetchAddPushToken`: Adds or retrieves an existing push token. - `fetchDeletePushToken`: Deletes a push token. - `fetchPushTokens`: Fetches all push tokens for a user. - Integrated token registration on login and deletion on logout in the app's layout. ## How to Test 1. **Notification Permissions**: - Verify that the app requests notification permissions on launch. - Note that user should be signed-in. - Move the app to the background and back to the foreground to ensure permissions are re-checked. 2. **Push Token Management**: - **Login**: - Verify the token is added to the `push_tokens` table in Supabase. - **Logout**: - Verify the token is removed from the `push_tokens` table in Supabase. --- app.config.ts | 17 +- app/(app)/(tabs)/_layout.tsx | 24 +- app/(app)/(tabs)/profile.tsx | 61 +- app/(app)/onboarding.tsx | 40 +- app/(app)/picture.tsx | 9 +- app/(app)/post/[id]/update.tsx | 8 +- app/(app)/post/write.tsx | 8 +- app/(app)/settings/login-info.tsx | 21 +- app/(app)/settings/profile-update.tsx | 56 +- app/(auth)/sign-in.tsx | 25 +- app/_layout.tsx | 34 + assets/langs/en.json | 12 +- assets/langs/ko.json | 8 +- assets/notification-icon.png | Bin 0 -> 7541 bytes bun.lockb | Bin 905357 -> 927537 bytes config.ts | 2 +- package.json | 30 +- .../20240806182656_push_tokens/migration.sql | 16 + prisma/schema.prisma | 14 + src/apis/pushTokenQueries.ts | 76 ++ src/components/uis/ImageCarousel.tsx | 16 +- src/components/uis/ReplyItem.tsx | 8 +- src/hooks/useActionSheet.tsx | 2 - src/recoil/atoms.ts | 2 + src/types/supabase.ts | 32 + src/utils/common.ts | 25 + src/utils/constants.ts | 3 +- src/utils/notifications.ts | 75 ++ yarn.lock | 882 ++++++++++++++---- 29 files changed, 1216 insertions(+), 290 deletions(-) create mode 100644 assets/notification-icon.png create mode 100644 prisma/migrations/20240806182656_push_tokens/migration.sql create mode 100644 src/apis/pushTokenQueries.ts create mode 100644 src/utils/notifications.ts diff --git a/app.config.ts b/app.config.ts index cb6a15f..2eb2b35 100644 --- a/app.config.ts +++ b/app.config.ts @@ -39,8 +39,9 @@ export default ({config}: ConfigContext): ExpoConfig => ({ [ 'expo-build-properties', { - ios: {newArchEnabled: true}, - android: {newArchEnabled: true}, + // https://github.com/software-mansion/react-native-screens/issues/2219 + // ios: {newArchEnabled: true}, + // android: {newArchEnabled: true}, }, ], // @ts-ignore @@ -59,6 +60,14 @@ export default ({config}: ConfigContext): ExpoConfig => ({ ], }, ], + [ + 'expo-notifications', + { + icon: './assets/notification-icon.png', + color: '#ffffff', + defaultChannel: 'default', + }, + ], ], experiments: { typedRoutes: true, @@ -94,6 +103,7 @@ export default ({config}: ConfigContext): ExpoConfig => ({ entitlements: { 'com.apple.developer.applesignin': ['Default'], }, + googleServicesFile: process.env.GOOGLE_SERVICES_IOS, infoPlist: { LSApplicationQueriesSchemes: ['mailto'], CFBundleAllowMixedLocalizations: true, @@ -107,7 +117,7 @@ export default ({config}: ConfigContext): ExpoConfig => ({ }, }, android: { - googleServicesFile: process.env.GOOGLE_SERVICES_JSON, + googleServicesFile: process.env.GOOGLE_SERVICES_ANDROID, userInterfaceStyle: 'automatic', permissions: [ 'RECEIVE_BOOT_COMPLETED', @@ -117,6 +127,7 @@ export default ({config}: ConfigContext): ExpoConfig => ({ 'WRITE_EXTERNAL_STORAGE', 'NOTIFICATIONS', 'USER_FACING_NOTIFICATIONS', + 'SCHEDULE_EXACT_ALARM', ], adaptiveIcon: { foregroundImage: './assets/adaptive_icon.png', diff --git a/app/(app)/(tabs)/_layout.tsx b/app/(app)/(tabs)/_layout.tsx index 2f92323..de5e173 100644 --- a/app/(app)/(tabs)/_layout.tsx +++ b/app/(app)/(tabs)/_layout.tsx @@ -1,10 +1,12 @@ import {Pressable, View} from 'react-native'; import {Icon, useDooboo} from 'dooboo-ui'; import {Link, Redirect, Tabs, useRouter} from 'expo-router'; -import {useRecoilValue} from 'recoil'; +import {useRecoilState} from 'recoil'; import {authRecoilState} from '../../../src/recoil/atoms'; import {t} from '../../../src/STRINGS'; +import {useEffect, useRef} from 'react'; +import * as Notifications from 'expo-notifications'; function SettingsMenu(): JSX.Element { const {theme} = useDooboo(); @@ -12,7 +14,7 @@ function SettingsMenu(): JSX.Element { return ( - push('settings')}> + push('/settings')}> {({pressed}) => ( (); + + useEffect(() => { + if (!authId) return; + notificationResponseListener.current = + Notifications.addNotificationResponseReceivedListener((response) => { + console.log(JSON.stringify(response.notification.request)); + }); + + return () => { + notificationResponseListener.current && + Notifications.removeNotificationSubscription( + notificationResponseListener.current, + ); + }; + }, [authId, setAuth]); if (!authId) { return ; diff --git a/app/(app)/(tabs)/profile.tsx b/app/(app)/(tabs)/profile.tsx index be25a61..ffedf42 100644 --- a/app/(app)/(tabs)/profile.tsx +++ b/app/(app)/(tabs)/profile.tsx @@ -1,6 +1,6 @@ import styled from '@emotion/native'; import {Stack} from 'expo-router'; -import {Icon, Typography} from 'dooboo-ui'; +import {Icon, Typography, useDooboo} from 'dooboo-ui'; import {t} from '../../../src/STRINGS'; import {useRecoilValue} from 'recoil'; import {authRecoilState} from '../../../src/recoil/atoms'; @@ -81,7 +81,7 @@ const TagContainer = styled.View` `; const Tag = styled.View` - background-color: ${({theme}) => theme.role.accent}; + background-color: ${({theme}) => theme.role.link}; border-radius: 20px; padding: 6px 12px; margin-right: 8px; @@ -89,12 +89,13 @@ const Tag = styled.View` `; const TagText = styled.Text` - color: ${({theme}) => theme.text.basic}; + color: ${({theme}) => theme.text.contrast}; font-size: 14px; `; export default function Profile(): JSX.Element { const {user, tags} = useRecoilValue(authRecoilState); + const {theme} = useDooboo(); return ( @@ -109,7 +110,7 @@ export default function Profile(): JSX.Element { source={user?.avatar_url ? {uri: user?.avatar_url} : IC_ICON} /> {user?.display_name || ''} - {user?.introduction || ''} + {user?.introduction ? {user?.introduction} : null} @@ -122,7 +123,7 @@ export default function Profile(): JSX.Element { gap: 4px; `} > - + {user?.github_id || ''} @@ -131,26 +132,36 @@ export default function Profile(): JSX.Element { {user?.affiliation || ''} - - - {t('onboarding.desiredConnection')} - {user?.desired_connection || ''} - - - {t('onboarding.futureExpectations')} - {user?.future_expectations || ''} - - - - {t('onboarding.userTags')}: - - {tags?.map((tag, index) => ( - - {tag} - - ))} - - + + {user?.desired_connection || user?.future_expectations ? ( + + {user?.desired_connection ? ( + + {t('onboarding.desiredConnection')} + {user?.desired_connection || ''} + + ) : null} + {user?.future_expectations ? ( + + {t('onboarding.futureExpectations')} + {user?.future_expectations || ''} + + ) : null} + + ) : null} + + {tags?.length ? ( + + {t('onboarding.userTags')} + + {tags.map((tag, index) => ( + + {tag} + + ))} + + + ) : null} diff --git a/app/(app)/onboarding.tsx b/app/(app)/onboarding.tsx index 0193a30..63a0bcc 100644 --- a/app/(app)/onboarding.tsx +++ b/app/(app)/onboarding.tsx @@ -6,7 +6,6 @@ import * as yup from 'yup'; import {Controller, SubmitHandler, useForm} from 'react-hook-form'; import { ActivityIndicator, - Alert, KeyboardAvoidingView, Platform, Pressable, @@ -30,6 +29,7 @@ import {useRecoilState} from 'recoil'; import {authRecoilState} from '../../src/recoil/atoms'; import {ImageInsertArgs} from '../../src/types'; import FallbackComponent from '../../src/components/uis/FallbackComponent'; +import {showAlert} from '../../src/utils/alert'; const Container = styled.SafeAreaView` flex: 1; @@ -55,11 +55,11 @@ const Content = styled.View` `; const schema = yup.object().shape({ - display_name: yup.string().required(t('common.requiredField')), + display_name: yup.string().required(t('common.requiredField')).min(2).max(20), + github_id: yup.string().required(t('common.requiredField')), + affiliation: yup.string().required(t('common.requiredField')), avatar_url: yup.string(), meetup_id: yup.string(), - affiliation: yup.string(), - github_id: yup.string(), other_sns_urls: yup.array().of(yup.string()), tags: yup.array().of(yup.string()), desired_connection: yup.string(), @@ -115,7 +115,7 @@ export default function Onboarding(): JSX.Element { let image: ImageInsertArgs | undefined = {}; - if (profileImg) { + if (profileImg && !profileImg.startsWith('http')) { const destPath = `users/${authId}`; image = await uploadFileToSupabase({ @@ -148,7 +148,7 @@ export default function Onboarding(): JSX.Element { return; } - Alert.alert((error as Error)?.message || ''); + showAlert((error as Error)?.message || ''); } }; @@ -295,7 +295,7 @@ export default function Onboarding(): JSX.Element { displayNameError ? displayNameError : errors.display_name - ? errors.display_name.message + ? t('error.displayNameInvalid') : '' } /> @@ -304,9 +304,10 @@ export default function Onboarding(): JSX.Element { /> ( )} rules={{validate: (value) => !!value}} /> ( )} rules={{validate: (value) => !!value}} /> ( )} rules={{validate: (value) => !!value}} diff --git a/app/(app)/picture.tsx b/app/(app)/picture.tsx index 38d344a..5f34f11 100644 --- a/app/(app)/picture.tsx +++ b/app/(app)/picture.tsx @@ -17,7 +17,14 @@ export default function Picture(): JSX.Element { const [loading, setLoading] = useState(true); if (!imageUrl || typeof imageUrl !== 'string') { - return ; + return ( + <> + + + + ); } return ( diff --git a/app/(app)/post/[id]/update.tsx b/app/(app)/post/[id]/update.tsx index 8edb120..30bf4cc 100644 --- a/app/(app)/post/[id]/update.tsx +++ b/app/(app)/post/[id]/update.tsx @@ -9,6 +9,7 @@ import { KeyboardAvoidingView, Platform, Pressable, + View, } from 'react-native'; import ErrorFallback from '../../../../src/components/uis/FallbackComponent'; import {useRecoilValue} from 'recoil'; @@ -156,7 +157,7 @@ export default function PostUpdate(): JSX.Element { return ( + diff --git a/app/(app)/post/write.tsx b/app/(app)/post/write.tsx index 76883ea..fdcc273 100644 --- a/app/(app)/post/write.tsx +++ b/app/(app)/post/write.tsx @@ -10,6 +10,7 @@ import { KeyboardAvoidingView, Platform, Pressable, + View, } from 'react-native'; import {useRecoilValue} from 'recoil'; import {authRecoilState} from '../../../src/recoil/atoms'; @@ -131,7 +132,7 @@ export default function PostWrite(): JSX.Element { /> + diff --git a/app/(app)/settings/login-info.tsx b/app/(app)/settings/login-info.tsx index 7d88072..4cd4380 100644 --- a/app/(app)/settings/login-info.tsx +++ b/app/(app)/settings/login-info.tsx @@ -14,6 +14,7 @@ import type {User} from '../../../src/types'; import {showConfirm} from '../../../src/utils/alert'; import {AsyncStorageKey} from '../../../src/utils/constants'; import CustomLoadingIndicator from '../../../src/components/uis/CustomLoadingIndicator'; +import { fetchDeletePushToken } from '../../../src/apis/pushTokenQueries'; const Content = styled.View` flex: 1; @@ -93,10 +94,16 @@ export default function LoginInfo(): JSX.Element { const {back, replace} = useRouter(); const {theme, alertDialog} = useDooboo(); const {bottom} = useSafeAreaInsets(); - const auth = useRecoilValue(authRecoilState); + const {authId, user, pushToken} = useRecoilValue(authRecoilState); const handleSignOut = async (): Promise => { - // RNOnesignal?.logout(); + if (pushToken && authId) { + await fetchDeletePushToken({ + authId, + expoPushToken: pushToken, + }) + } + await AsyncStorage.removeItem(AsyncStorageKey.Token); supabase.auth.signOut(); back(); @@ -104,7 +111,7 @@ export default function LoginInfo(): JSX.Element { }; const handleWithdrawUser = async (): Promise => { - if (!auth) { + if (!user || !authId) { return; } @@ -120,13 +127,13 @@ export default function LoginInfo(): JSX.Element { await supabase .from('users') .update({deleted_at: new Date().toISOString()}) - .eq('id', auth); + .eq('id', authId); supabase.auth.signOut(); replace('/'); }; - if (!auth.user) { + if (!user) { return ( <> @@ -148,8 +155,8 @@ export default function LoginInfo(): JSX.Element { {t('loginInfo.loginMethod')}