Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
42 changes: 42 additions & 0 deletions frontend/README-RUN.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions frontend/scripts/check-environment.js
Original file line number Diff line number Diff line change
@@ -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.');
40 changes: 39 additions & 1 deletion frontend/src/components/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import {useNotificationListeners} from '@/hooks/useNotificationListener';
import {useVersionCheck} from '@/hooks/useVersionCheck';
import {SocketProvider} from '../contexts/SocketContext';
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,
Expand All @@ -17,7 +20,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';
Expand Down Expand Up @@ -105,6 +107,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();
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/helper/APIUtils.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -85,7 +87,7 @@ const UPLOAD_ARTICLE_TO_POCKETBASE = `${PROD_URL}/upload-pocketbase/article`;
const UPLOAD_IMPROVEMENT_TO_POCKETBASE = `${PROD_URL}/upload-pocketbase/improvement`;

/** Content Checker */
const RENDER_SUGGESTION = `${CONTENT_CHECKER_PROD}/grammar/render-suggestions`;
const GRAMMAR_SUGGESTION = `${CONTENT_CHECKER_PROD}/grammar/render-suggestions`;

/** PODCAST RELATED */
const GET_ALL_PODCASTS = `${PROD_URL}/podcast/published-podcasts`;
Expand Down Expand Up @@ -114,7 +116,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,
Expand Down Expand Up @@ -175,7 +177,7 @@ export {
UPLOAD_ARTICLE_TO_POCKETBASE,

UPLOAD_IMPROVEMENT_TO_POCKETBASE,
RENDER_SUGGESTION,
REGISTER_PUSH_TOKEN,
// PODCAST
GET_ALL_PODCASTS,
GET_PODCAST_DETAILS,
Expand Down
86 changes: 86 additions & 0 deletions frontend/src/helper/DeepLinkService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,92 @@ const resolveDeepLinkTarget = (url: string): DeepLinkTarget | null => {
return null;
};

export const resolveNotificationTarget = (
data: Record<string, any> | 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);
Expand Down
99 changes: 99 additions & 0 deletions frontend/src/helper/PushNotificationService.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> => {
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<string | null> => {
return secureRetrieveItem(SECURE_KEYS.EXPO_PUSH_TOKEN);
};

export const syncExpoPushTokenWithServer = async (
userToken?: string | null,
): Promise<void> => {
if (!userToken) {
return;
}

const token = await getStoredExpoPushToken();
if (!token) {
return;
}

try {
const headers: Record<string, string> = {};
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<void> => {
const token = await registerExpoPushTokenAsync();
if (!token) {
return;
}

if (userToken) {
await syncExpoPushTokenWithServer(userToken);
}
};
8 changes: 1 addition & 7 deletions frontend/src/helper/SecureStorageUtils.ts
Original file line number Diff line number Diff line change
@@ -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',
} as const;

export type SecureKey = (typeof SECURE_KEYS)[keyof typeof SECURE_KEYS];
Expand Down
Loading
Loading