Skip to content

Mobile Overview ​

apps/mobile · @playpals/mobile

The product. Expo SDK 54, React Native, expo-router for navigation, TanStack Query for server state.

Running it ​

bash
pnpm --filter @playpals/mobile ios
pnpm --filter @playpals/mobile android
pnpm --filter @playpals/mobile start      # Metro only
pnpm --filter @playpals/mobile lint

You 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:

bash
EXPO_PUBLIC_ENV=development
EXPO_PUBLIC_API_URL=http://localhost:4000/api/v1
EXPO_PUBLIC_REALTIME_URL=http://localhost:4010

The 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:

ValueApp nameScheme
developmentPlayPalz Devplaypalz-dev
stagingPlayPalz Stagingplaypalz-staging
productionPlayPalzplaypalz

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
               └─ StatusBar

A 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:

ts
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 while value is set, spread sheetProps onto the <BottomSheet> (it mounts at index={0}), and close it with close(). It unmounts on onChange(-1), with a timer fallback in case that callback is dropped. Never mount a sheet at index={-1} and wait for expand().
  • Backdrops. Use renderSheetBackdrop from components/common/sheet-backdrop.tsx, not an inline arrow.
  • Overlay hosts. MediaViewerHost and SnackbarHost return null while 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: false in app.json removes the scrim. This is native config, so it needs a rebuild.
  • NavigationBar.setStyle("dark") from expo-navigation-bar in app/_layout.tsx gives 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:

ts
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 config

Two 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 ​

ConcernLibrary
Navigationexpo-router 6
Server state@tanstack/react-query 5
HTTPaxios
Realtime@playpals/socket-client
Voice / video@livekit/react-native
Lists@shopify/flash-list
Bottom sheets@gorhom/bottom-sheet
Imagesexpo-image
Videoexpo-video
Purchasesreact-native-purchases
Notificationsexpo-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.

Next ​

Internal documentation — PlayPalz platform