Mobile Overview
apps/mobile · @playpals/mobile
The product. Expo SDK 54, React Native, expo-router for navigation, TanStack Query for server state.
Running it
pnpm --filter @playpals/mobile ios
pnpm --filter @playpals/mobile android
pnpm --filter @playpals/mobile start # Metro only
pnpm --filter @playpals/mobile lintYou need a development build, not Expo Go
The app depends on native modules Expo Go does not bundle — @livekit/react-native-webrtc, expo-notifications, @bugsnag/expo. Install a development client on your simulator first. See Builds & Releases.
Environment
apps/mobile/.env, read through lib/env.ts:
EXPO_PUBLIC_ENV=development
EXPO_PUBLIC_API_URL=http://localhost:4000/api/v1
EXPO_PUBLIC_REALTIME_URL=http://localhost:4010The app logs an error at startup if apiUrl or realtimeUrl is missing. EXPO_PUBLIC_API_URL must include /api/v1 — it is the axios baseURL verbatim.
app.config.ts derives identity from EXPO_PUBLIC_ENV:
| Value | App name | Scheme |
|---|---|---|
development | PlayPalz Dev | playpalz-dev |
staging | PlayPalz Staging | playpalz-staging |
production | PlayPalz | playpalz |
Provider stack
app/_layout.tsx composes the root:
AuthProvider auth state, token, API base config
└─ RevenueCatProvider purchases and entitlements
└─ GestureHandlerRootView
└─ ThemeProvider design tokens, colour scheme
└─ CommentSheetProvider
└─ CreatePostSheetProvider
├─ <Slot /> the routed screen
├─ SnackbarHost
└─ StatusBarTwo details in that file are worth knowing:
Fonts gate the render. useFonts(fontLoadMap) must resolve before anything renders, and the splash screen is held until it does:
SplashScreen.preventAutoHideAsync();
useEffect(() => { if (fontsLoaded) SplashScreen.hideAsync(); }, [fontsLoaded]);
if (!fontsLoaded) return null;Bugsnag is required lazily in non-development builds:
if (process?.env.EXPO_PUBLIC_ENV !== "development") {
const Bugsnag = require("@bugsnag/expo").default;
Bugsnag.start();
}The comment explains why — Bugsnag depends on @react-native-community/netinfo, whose native module is absent in development, and a static import would throw before the module's export default is reached.
LiveKit globals are registered at module load, wrapped in a try/catch so a failure warns rather than crashes.
Auth gate
app/(app)/_layout.tsx guards the entire authenticated tree:
if (isLoading) return <Spinner />;
if (!user) return <Redirect href="/(auth)/login" />;
if (!user.onboarded) return <Redirect href="/(onboarding)" />;It also mounts three app-wide hooks: usePushNotifications, useRealtimeNotifications, and useRealtimeMediaProcessing. Because they live here rather than on a screen, they stay active for the whole authenticated session.
Directory layout
apps/mobile/
├── app/ expo-router screens — the file tree IS the route table
├── api/ one axios module per domain (auth, feed, post, …)
├── hooks/ TanStack Query hooks, grouped by domain, each with keys.ts
├── components/ shared UI, grouped by feature
├── context/ auth, chat UI
├── lib/ axios, env, query-client, navigation, revenueCat, snackbar, media
├── theme/ colors, typography, tokens, ThemeContext
├── constants/ colors, spacing, typography — older; prefer theme/
├── plugins/ Expo config plugins
├── utils/
└── app.config.ts dynamic Expo configTwo styling sources
constants/colors.ts and theme/colors.ts both exist, and files import from both — sometimes in the same file (app/(app)/(tabs)/_layout.tsx uses Colors from constants and colors from theme). theme/ is the newer, fuller system. Prefer it, and migrate constants/ usages when you touch them.
Key libraries
| Concern | Library |
|---|---|
| Navigation | expo-router 6 |
| Server state | @tanstack/react-query 5 |
| HTTP | axios |
| Realtime | @playpals/socket-client |
| Voice / video | @livekit/react-native |
| Lists | @shopify/flash-list |
| Bottom sheets | @gorhom/bottom-sheet |
| Images | expo-image |
| Video | expo-av |
| Purchases | react-native-purchases |
| Notifications | expo-notifications |
| Crash reporting | @bugsnag/expo |
| Storage | @react-native-async-storage/async-storage |
Note babel-plugin-react-compiler is enabled — the React Compiler handles memoisation, so manual useMemo / useCallback are usually unnecessary.
