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 and expo-notifications. 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
└─ StatusBarA few details in that file are worth knowing:
Fonts gate the render. useFonts(fontLoadMap) must settle before anything renders, and the splash screen is held until it does. A font that fails to load falls back to the system font rather than holding the splash up forever:
const [fontsLoaded, fontError] = useFonts(fontLoadMap);
const ready = fontsLoaded || !!fontError;
useEffect(() => { if (ready) SplashScreen.hideAsync(); }, [ready]);
if (!ready) return null;SnackbarHost is a touch-through overlay, never a <Modal>. On Android a react-native Modal is its own full-screen window: nothing behind it receives touches and TextInputs lose focus. The host used to be one, which froze the whole app while a snackbar showed. It now renders an absoluteFill view with pointerEvents="box-none", inside FullWindowOverlay on iOS so it stays above native modal screens. lib/snackbar.ts drops a repeat of the same message within 8 seconds, and the host keeps at most 3 queued.
Full-screen overlays and touches
Every untouchable-screen bug so far has been an invisible full-screen layer catching touches. That has been a closed bottom-sheet backdrop, a Modal left visible, and the media viewer host waiting on a measurement. The third occurrence was on a Galaxy S9+ running Android 10. Six closed sheets each left a full-screen backdrop on Home, and their touch-through state depends on a worklet → runOnJS round-trip that slower devices can lose.
The rule now is closed means unmounted. Nothing full-screen is mounted while it isn't showing:
- Bottom sheets. Use
useMountedSheet(hooks/useMountedSheet.ts). Render the sheet only whilevalueis set, spreadsheetPropsonto the<BottomSheet>(it mounts atindex={0}), and close it withclose(). It unmounts ononChange(-1), with a timer fallback in case that callback is dropped. Never mount a sheet atindex={-1}and wait forexpand(). - Backdrops. Use
renderSheetBackdropfromcomponents/common/sheet-backdrop.tsx, not an inline arrow. - Overlay hosts.
MediaViewerHostandSnackbarHostreturnnullwhile idle.
To check on a device, run adb shell uiautomator dump on Home with nothing open. It should contain no Bottom sheet backdrop nodes.
The Android navigation bar is transparent with light buttons. On 3-button navigation, Android draws a contrast scrim behind Back/Home/Recents once edge-to-edge is on. It is white in light mode, so the tab bar looked cut off. Gesture navigation never shows it. Two settings turn it off:
androidNavigationBar.enforceContrast: falseinapp.jsonremoves the scrim. This is native config, so it needs a rebuild.NavigationBar.setStyle("dark")fromexpo-navigation-barinapp/_layout.tsxgives the buttons light icons over the dark tab bar. The style names the bar, not the buttons, so"light"would mean dark icons.
The tab bar already pads by insets.bottom, so its colour fills the area behind the buttons. A <Modal> is its own window, so the scrim can still appear while a dialog is open.
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, 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-video |
| Purchases | react-native-purchases |
| Notifications | expo-notifications |
| 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.
