Post creation: gallery/camera picker (expo-image-picker) uploads each image straight to Supabase Storage via a presigned PUT (posts.getPresignedUrl + expo-file-system), then posts.create with the resulting storage keys — reachable via a new "+" header button on the Feed tab. No client-side blurhash (browser Canvas API, no RN equivalent) or AI-disclosure/mention support yet, matching the rest of mobile's plain-text captions. Comments: a dedicated screen (posts/[postId]/comments) for reading, posting, and deleting one's own comments, reachable by tapping the comment icon on a PostCard. Ported from web's PostDetailDialog comment panel, scoped down to just that panel since mobile's image full-view already has its own route.
PawFeed Mobile
Native Android/iOS client for PawFeed, built with React Native + Expo (Expo
Router). It talks to the exact same backend the web app uses — the
self-hosted Docker deployment described in ../docker/SCALING-ROADMAP.md —
over the existing tRPC API. No backend/Docker changes were needed to make
this possible, see "Why no backend changes" below.
Decided over Capacitor (see the now-superseded ../mobil.md) because a
native RN app gives better performance/feel and can still share real logic
with a future web-shared package, at the cost of more upfront work — see the
architecture-decision thread in this session's handoff.
Status: Phase 1 — real Feed/Explore/Notifications/Pets tabs against the live backend, not just a connectivity scaffold anymore. Post creation, comments, stories, pet profile detail, video playback, search, health, and message sending are still not built — see "What's not built yet" below. Android is the priority platform; iOS builds need a Mac + Apple Developer account and come later (same codebase, no separate source tree — see "Android-first, then iOS" below).
Architecture
- Routing: Expo Router (file-based,
app/).(auth)and(app)are route groups;app/_layout.tsxredirects between them based on Clerk's session state (useProtectedRoute), mirroring Clerk's official expo-router integration pattern. - Auth:
@clerk/expo(the current SDK —@clerk/clerk-expois deprecated, don't reinstall it). Session JWT persists in the OS keystore viaexpo-secure-store(src/lib/tokenCache.ts) instead of Clerk's in-memory default. - API:
src/api/trpc.tscreates a@trpc/clientpointed at${EXPO_PUBLIC_API_URL}/api/trpc, with the Clerk session token attached asAuthorization: Bearer <token>on every request (useAuth().getToken, called per-request so a refreshed token is always used). - Type safety:
AppRouteris imported as a type-only import directly from the web app's../src/trpc/routers/_app.ts— no code duplication, no publishing a package. This works becausemobile/is nested inside the same repo: Node's (and TypeScript's) module resolution walks up to the rootnode_modulesfor@trpc/server,zod, etc., so the type import resolves without installing those packages a second time here. If a future refactor turns this repo into an npm workspace, revisit this — it wasn't necessary for the initial scaffold.src/api/types.tscentralizes theinferRouterOutputs<AppRouter>types every screen uses (Pet,FeedPost, etc.) instead of repeating the long relative import. - Design system:
src/theme/tokens.tsmirrors the web app'sDESIGN.md("Spotlight Porch" — marigold-orange accent, porch-white canvas, Inter font, hairline rings instead of shadows).src/components/has the shared building blocks (Button,TextField,Card) plus feed-specific ports of the web equivalents (PostCard,PawButton,PetAvatar,PetIdentityBadges). - Active pet: every pet-scoped procedure (
feed.getFeed,reactions.toggle,notifications.list, ...) takes an explicitpetIdand asserts ownership server-side — there's no server-side "current pet".src/context/ActivePetContext.tsxis mobile's equivalent of web'sActivePetContext(MMKV-backed instead oflocalStorage), defaulting to the owner's first pet; the Pets tab doubles as the pet switcher. - Fast reopen: the TanStack Query cache is persisted to
react-native-mmkv(src/lib/storage.ts, wired viaPersistQueryClientProviderinapp/_layout.tsx) so the last-known feed/pets/notifications render immediately on relaunch while a background refetch reconciles them — no blank screen while waiting on the network. Images go throughexpo-imagefor the same reason (disk-cached, unlike RN's built-inImage). - Navigation:
expo-router's built-in<Tabs>(app/(app)/(tabs)/_layout.tsx) — Feed/Explore/Notifications/Pets, mirroring the web app's actual mobile bottom nav (src/components/layout/MobileNav.tsx), not an invented IA. Messages is a header icon button pushingapp/(app)/messages/index.tsx, matching web (there it's a floating corner button, not a tab).
Why no backend changes were needed
Two things had to be true for the app to reach the Docker-hosted backend as-is, and both already were:
- CORS doesn't apply. CORS is a browser same-origin mechanism; a native
app's
fetchisn't a browser context, so the "just needs to reach it" requirement was already satisfied by the API being reachable over HTTPS atpawfeed.org. - Clerk already accepts bearer tokens.
src/proxy.tsalready marks/api/trpc/(.*)as a public route at the middleware level (tRPC does its own per-procedure auth — see the comment there), andauth()insrc/trpc/init.tsrecognizes both the browser's session cookie AND anAuthorization: BearerJWT out of the box. Native/Expo apps sending a bearer token is Clerk's documented mobile auth flow, not a special case that needed enabling server-side.
Setup
Runs as a custom dev client, not Expo Go. @clerk/expo's Device Trust
flow (see below) and react-native-mmkv both need native code Expo Go
doesn't ship, so this app builds and installs its own native shell locally
— no EAS account/login needed, everything runs on-device via the Android
SDK already required for the emulator.
cd mobile
npm install
cp .env.example .env # already done locally with the live Clerk publishable key
# First time only (or after adding a native dependency / editing app.json):
npx expo prebuild --platform android
JAVA_HOME="<path to a JDK 17 install>" npx expo run:android
JAVA_HOME matters: RN 0.86's Gradle build needs JDK 17, and a JDK
that's too new (e.g. a bundled Android Studio JBR on JDK 21+) fails native
module CMake configuration with a cryptic "restricted method... System"
error. Eclipse Temurin 17 works
(winget install EclipseAdoptium.Temurin.17.JDK on Windows).
Once the native app is installed, day-to-day iteration doesn't need another
expo run:android — just npm start (→ expo start --dev-client) and
reopen the already-installed app; that command only bundles/serves JS.
Requires an Android emulator (Android Studio) or a physical device. See
https://docs.expo.dev/versions/v57.0.0/ for current Expo APIs — SDK 57
changed things vs. older Expo docs/training data, per AGENTS.md in this
folder.
Clerk dashboard: Native Applications API
Clerk's "Device Trust" feature (tied to the dashboard's Native Applications
API) makes signIn.status come back as needs_client_trust on a sign-in
from a new device — resolved via signIn.mfa.sendEmailCode() /
verifyEmailCode() in app/(auth)/sign-in.tsx, a one-time email-code check
per device, independent of any account-level MFA setting. If sign-in hangs
at "Clerk lädt…" forever with no error, or signIn.status never reaches
complete, check that the Native Applications API is enabled for this
Clerk instance.
.env
| Var | Purpose |
|---|---|
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY |
Same Clerk instance as the web app — copy from root .env.local's NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY. Safe to embed client-side (that's what "publishable" means). |
EXPO_PUBLIC_API_URL |
Backend origin. Defaults to https://pawfeed.org (the live deployment) — see the LAN-IP / Android-emulator alternatives in .env.example for local dev against npm run dev instead. |
EXPO_PUBLIC_SUPABASE_URL |
Same Supabase project as the web app — copy from root .env.local's NEXT_PUBLIC_SUPABASE_URL. Used to build public media URLs (src/lib/media-url.ts); safe client-side, it's the public storage origin, not a service key. |
Caution: the default .env points at the live production backend
and database — the same one real users are on (project_prod_launch_2026-08-10).
Reading (public stats, browsing) is harmless. Anything that writes data
while iterating on mobile features should point EXPO_PUBLIC_API_URL at a
local npm run dev instead (http://10.0.2.2:3000 from the Android
emulator, or the dev machine's LAN IP from a physical device).
Folder structure
mobile/
app/ # expo-router routes
_layout.tsx # ClerkProvider + persisted QueryClient/tRPC + ActivePetProvider + auth redirect
(auth)/
sign-in.tsx # email/password + Clerk Device Trust email-code step
(app)/
_layout.tsx # Stack hosting the (tabs) group + the messages screen
(tabs)/
_layout.tsx # bottom Tabs: Feed/Explore/Notifications/Pets + header Messages button
index.tsx # Feed
explore.tsx # pet discovery grid
notifications.tsx # notifications list
pets.tsx # owner's pets + active-pet switcher
messages/index.tsx # conversations list
src/
api/trpc.ts # tRPC client, AppRouter type import, query client factory
api/types.ts # inferRouterOutputs<AppRouter> shortcuts (Pet, FeedPost, ...)
context/ActivePetContext.tsx # MMKV-backed active-pet state
lib/env.ts # typed EXPO_PUBLIC_* access
lib/tokenCache.ts # Clerk session persistence via expo-secure-store
lib/storage.ts # MMKV instance + TanStack Query sync persister
lib/media-url.ts # Supabase public storage URL builder
lib/format.ts # pet display name + relative-time formatting
lib/sponsor.ts # active-sponsor check
theme/tokens.ts # design tokens mirroring the web app's DESIGN.md
components/ # Button, TextField, Card, PostCard, PawButton, PetAvatar, PetIdentityBadges
Android-first, then iOS
Both platforms build from this one Expo codebase (expo run:android /
expo run:ios) — there's no separate iOS source tree to scaffold
separately. "Starting with Android" means:
app.json'sandroid.package(org.pawfeed.app) is set now;ios.bundleIdentifieris set to the same reverse-DNS id but untested.- Android needs a Google Play Console account (~25 € one-time, already
budgeted in
../mobil.md) before an internal test track can be created. - iOS needs a Mac (for
expo run:ios/ EAS builds with credentials) and an Apple Developer account (99 €/year) — deferred until Android is validated. - EAS Build (
eas.json, not yet added) is the recommended path for producing signed Android/iOS binaries without a local Android Studio/Xcode toolchain; add it when a first real build is needed.
What's not built yet
Phase 1 (Feed/Explore/Notifications/Pets tabs, reactions, active-pet switching, persisted cache for fast reopen) plus a full pet profile (view/follow/edit/followers-following) are done and verified end-to-end against the live backend on a real Android emulator. Still missing, roughly in the order they'd unblock the most:
- Post creation/upload (camera/gallery → Supabase direct upload, per
posts.getPresignedUrl/posts.create) - Comments (reading + posting —
PostDetailDialogequivalent) - Repost creation (the count already renders, creating one doesn't)
- Stories (story ring, viewer, creation)
- Video playback (currently a static placeholder for
type === "VIDEO"posts) - Search (
search.pets/byHashtag/byOwner) — separate from the Explore tab - Health tracking, milestones
- Sending messages (conversations list works; no chat detail/compose screen)
- Push notifications
- No i18n — the whole app is hardcoded German text, matching the sign-in screen