diff --git a/frontend/README-RUN.md b/frontend/README-RUN.md new file mode 100644 index 00000000..c726e77f --- /dev/null +++ b/frontend/README-RUN.md @@ -0,0 +1,42 @@ +# Frontend run instructions + +Prerequisites +- Node.js (16+ recommended) +- npm or yarn +- For native builds: Android Studio / Xcode (optional for run:android/run:ios) +- For push notifications: run on a physical device; configure FCM for Android + +Quick start + +1. Install dependencies + +```bash +cd frontend +npm install +# or +# yarn +``` + +2. Run the app + +```bash +npm run start +# or +# expo start +``` + +3. Run on Android device/emulator + +```bash +npm run android +``` + +4. Run preflight check (useful if you see errors in the editor) + +```bash +node ./scripts/check-environment.js +``` + +Notes +- If your editor still shows TypeScript errors after these steps, restart the TypeScript server or reload VS Code. +- Expo push notifications require FCM setup for Android (google-services.json) and testing on a physical device for Expo push tokens. diff --git a/frontend/scripts/check-environment.js b/frontend/scripts/check-environment.js new file mode 100644 index 00000000..14a61cea --- /dev/null +++ b/frontend/scripts/check-environment.js @@ -0,0 +1,48 @@ +/* + Simple preflight environment checker for the frontend workspace. + Run: node ./scripts/check-environment.js +*/ +const {execSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +function checkCommand(cmd) { + try { + const out = execSync(`${cmd} --version`, {stdio: 'pipe'}).toString().trim(); + return out; + } catch (e) { + return null; + } +} + +console.log('== Frontend preflight check =='); + +const node = checkCommand('node'); +console.log('node:', node || 'NOT FOUND'); + +const npm = checkCommand('npm'); +console.log('npm:', npm || 'NOT FOUND'); + +const yarn = checkCommand('yarn'); +console.log('yarn:', yarn || 'NOT FOUND (optional)'); + +const expo = checkCommand('expo'); +console.log('expo-cli:', expo || 'NOT FOUND (optional for bare workflow)'); + +const pkgPath = path.resolve(__dirname, '..', 'package.json'); +const nodeModulesPath = path.resolve(__dirname, '..', 'node_modules'); + +if (!fs.existsSync(pkgPath)) { + console.error('Error: package.json not found in frontend/'); + process.exitCode = 2; +} else { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + console.log('package.json found. name:', pkg.name || '(unknown)'); +} + +console.log('node_modules:', fs.existsSync(nodeModulesPath) ? 'present' : 'missing'); + +console.log('\nNext steps:'); +console.log(' - Run `npm install` or `yarn` inside frontend/ to install dependencies.'); +console.log(' - Use `npm run start` or `expo start` to run the app.'); +console.log(' - For push notifications, run on a physical device and configure FCM on Android.'); diff --git a/frontend/src/components/AppContent.tsx b/frontend/src/components/AppContent.tsx index f092b26f..9f1565c7 100644 --- a/frontend/src/components/AppContent.tsx +++ b/frontend/src/components/AppContent.tsx @@ -5,6 +5,9 @@ import {useVersionCheck} from '@/hooks/useVersionCheck'; import {SocketProvider} from '../contexts/SocketContext'; import {PreferencesProvider} from '../contexts/PreferencesContext'; import config from '@/tamagui.config'; +import * as Notifications from 'expo-notifications'; +import {registerAndSyncPushToken} from '../helper/PushNotificationService'; +import {initDeepLinking, navigateDeepLink, resolveNotificationTarget} from '../helper/DeepLinkService'; import messaging from '@react-native-firebase/messaging'; import { NavigationContainer, @@ -18,7 +21,6 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'; import {addEventListener} from '@react-native-community/netinfo'; import {useDispatch, useSelector} from 'react-redux'; import {TamaguiProvider} from 'tamagui'; -import {initDeepLinking} from '../helper/DeepLinkService'; import StackNavigation from '../navigations/StackNavigation'; import {CustomAlertDialog} from './CustomAlert'; import UpdateModal from './UpdateModal'; @@ -121,6 +123,42 @@ export default function AppContent() { }; }, [dispatch]); + useEffect(() => { + registerAndSyncPushToken(user_token); + }, [user_token]); + + useEffect(() => { + const handleNotificationResponse = async ( + response: Notifications.NotificationResponse, + ) => { + const data = response.notification.request.content.data; + const target = resolveNotificationTarget(data); + + if (!target || !navigationRef.current) { + return; + } + + const isAuthenticated = + Boolean(tokenRes?.isValid || user_token) && !isGuest; + navigateDeepLink(navigationRef.current, target, isAuthenticated); + }; + + const responseListener = + Notifications.addNotificationResponseReceivedListener( + handleNotificationResponse, + ); + + Notifications.getLastNotificationResponseAsync().then(response => { + if (response) { + handleNotificationResponse(response); + } + }); + + return () => { + responseListener.remove(); + }; + }, [user_token, tokenRes, isGuest]); + useEffect(() => { firebaseInit(); cleanUpDownloads(); diff --git a/frontend/src/helper/APIUtils.ts b/frontend/src/helper/APIUtils.ts index 5b72fa56..7a08536b 100644 --- a/frontend/src/helper/APIUtils.ts +++ b/frontend/src/helper/APIUtils.ts @@ -1,3 +1,5 @@ + +declare const __DEV__: boolean; // API URL configuration // Values are injected at build time from environment variables via app.config.js. // To override for local development, set the following in your .env file @@ -109,7 +111,7 @@ const SEND_MESSAGE_TO_GEMINI = `${PROD_URL}/gemini/send`; /** Notification Preferences */ const GET_NOTIFICATION_PREFERENCES = `${PROD_URL}/user/notification-preferences`; const UPDATE_NOTIFICATION_PREFERENCES = `${PROD_URL}/user/notification-preferences`; - +const REGISTER_PUSH_TOKEN = `${PROD_URL}/user/register-device-token`; export { LOGIN_API, @@ -170,7 +172,7 @@ export { UPLOAD_ARTICLE_TO_POCKETBASE, UPLOAD_IMPROVEMENT_TO_POCKETBASE, - RENDER_SUGGESTION, + REGISTER_PUSH_TOKEN, // PODCAST GET_ALL_PODCASTS, GET_PODCAST_DETAILS, diff --git a/frontend/src/helper/DeepLinkService.ts b/frontend/src/helper/DeepLinkService.ts index 2c0809e2..0530ade7 100644 --- a/frontend/src/helper/DeepLinkService.ts +++ b/frontend/src/helper/DeepLinkService.ts @@ -167,6 +167,92 @@ const resolveDeepLinkTarget = (url: string): DeepLinkTarget | null => { return null; }; +export const resolveNotificationTarget = ( + data: Record | null | undefined, +): DeepLinkTarget | null => { + if (!data) { + return null; + } + + if (typeof data.url === 'string' && data.url.length > 0) { + return resolveDeepLinkTarget(data.url); + } + + if (typeof data.articleId !== 'undefined') { + return { + name: 'ArticleScreen', + params: { + articleId: Number(data.articleId), + authorId: data.authorId, + recordId: data.recordId, + }, + }; + } + + if (typeof data.trackId === 'string' && data.trackId.length > 0) { + return { + name: 'PodcastDetail', + params: { + trackId: data.trackId, + }, + }; + } + + if (typeof data.userId === 'string' && data.userId.length > 0) { + return { + name: 'UserProfileScreen', + params: { + authorId: data.userId, + userId: data.userId, + author_handle: data.author_handle, + userHandle: data.userHandle, + }, + }; + } + + if (data.route === 'NotificationPreferencesScreen') { + return { + name: 'NotificationPreferencesScreen', + requiresAuth: true, + }; + } + + if (data.route === 'NotificationScreen') { + return { + name: 'NotificationScreen', + }; + } + + return null; +}; + +export const navigateDeepLink = ( + navigation: any, + target: DeepLinkTarget, + isAuthenticated: boolean, +) => { + if (target.requiresAuth && !isAuthenticated) { + navigation.navigate('LoginScreen', { + redirectTo: { + name: target.name, + params: target.params, + }, + }); + return; + } + + if (restrictedRoutes.has(target.name) && !isAuthenticated) { + navigation.navigate('GuestPlaceholderScreen', { + title: 'Sign In Required', + description: 'Please sign in to continue to this part of the app.', + iconName: 'user-lock', + }); + return; + } + + navigation.navigate(target.name as never, target.params as never); +}; + export const initDeepLinking = (navigation: any, isAuthenticated: boolean) => { const handleUrl = (url: string) => { const target = resolveDeepLinkTarget(url); diff --git a/frontend/src/helper/PushNotificationService.ts b/frontend/src/helper/PushNotificationService.ts new file mode 100644 index 00000000..05c91a0e --- /dev/null +++ b/frontend/src/helper/PushNotificationService.ts @@ -0,0 +1,99 @@ +import {Platform} from 'react-native'; +import * as Device from 'expo-device'; +import * as Notifications from 'expo-notifications'; +import axios from 'axios'; +import { + secureRetrieveItem, + secureStoreItem, + SECURE_KEYS, +} from './SecureStorageUtils'; +import {REGISTER_PUSH_TOKEN} from './APIUtils'; + +export const registerExpoPushTokenAsync = async (): Promise => { + if (!Device.isDevice) { + console.warn('Push notifications require a physical device.'); + return null; + } + + const {status: existingStatus} = await Notifications.getPermissionsAsync(); + let finalStatus = existingStatus; + + if (existingStatus !== 'granted') { + const {status} = await Notifications.requestPermissionsAsync(); + finalStatus = status; + } + + if (finalStatus !== 'granted') { + console.warn('Notification permissions not granted.'); + return null; + } + + if (Platform.OS === 'android') { + await Notifications.setNotificationChannelAsync('default', { + name: 'default', + importance: Notifications.AndroidImportance.MAX, + vibrationPattern: [0, 250, 250, 250], + lightColor: '#FF231F7C', + }); + } + + try { + const response = await Notifications.getExpoPushTokenAsync(); + const token = response.data; + + if (token) { + await secureStoreItem(SECURE_KEYS.EXPO_PUSH_TOKEN, token); + return token; + } + + return null; + } catch (error) { + console.error('Failed to get Expo push token:', error); + return null; + } +}; + +export const getStoredExpoPushToken = async (): Promise => { + return secureRetrieveItem(SECURE_KEYS.EXPO_PUSH_TOKEN); +}; + +export const syncExpoPushTokenWithServer = async ( + userToken?: string | null, +): Promise => { + if (!userToken) { + return; + } + + const token = await getStoredExpoPushToken(); + if (!token) { + return; + } + + try { + const headers: Record = {}; + if (userToken) { + headers.Authorization = `Bearer ${userToken}`; + } + + await axios.post( + REGISTER_PUSH_TOKEN, + { expoPushToken: token }, + { headers }, + ); + } catch (error) { + console.error('Failed to register push token with server:', error); + } +}; + +export const registerAndSyncPushToken = async ( + userToken?: string | null, +): Promise => { + const token = await registerExpoPushTokenAsync(); + if (!token) { + return; + } + + if (userToken) { + await syncExpoPushTokenWithServer(userToken); + } +}; diff --git a/frontend/src/helper/SecureStorageUtils.ts b/frontend/src/helper/SecureStorageUtils.ts index 571f649e..86f3a482 100644 --- a/frontend/src/helper/SecureStorageUtils.ts +++ b/frontend/src/helper/SecureStorageUtils.ts @@ -1,14 +1,8 @@ import * as SecureStore from 'expo-secure-store'; -/** - * SecureStorageUtils.ts - * - * Abstraction layer for sensitive credential storage. - * Uses Expo SecureStore for encrypted key-value storage. - */ - export const SECURE_KEYS = { USER_TOKEN: 'SECURE_USER_TOKEN', + EXPO_PUSH_TOKEN: 'SECURE_EXPO_PUSH_TOKEN', LANGUAGE_PREFERENCES: 'SECURE_LANGUAGE_PREFERENCES', } as const; diff --git a/frontend/src/types/ambient-modules.d.ts b/frontend/src/types/ambient-modules.d.ts new file mode 100644 index 00000000..08eab051 --- /dev/null +++ b/frontend/src/types/ambient-modules.d.ts @@ -0,0 +1,5 @@ +declare module 'expo-secure-store'; +declare module 'expo-device'; +declare module 'expo-notifications'; +declare module 'axios'; +declare module 'react-native'; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index ad4eb5ac..02557ceb 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,5 +1,4 @@ { - "extends": "expo/tsconfig.base", "compilerOptions": { "jsx": "react-native", "strict": true, @@ -13,7 +12,8 @@ }, "include": [ "**/*.ts", - "**/*.tsx" + "**/*.tsx", + "**/*.d.ts" ], "exclude": [ "node_modules"