An offline-first iOS and Android app for support agents, built with React Native, Expo and TypeScript.
OpsDesk Mobile is the companion app for OpsDesk, a multi-tenant support-ticket SaaS. The web app and REST API live in ranazaid/opsdesk-web. With this app, agents can triage their queue, reply to customers and change ticket status from their phone, including on a train with no signal. Writes made offline are queued, shown straight away and replayed in order when the connection returns.
Point EXPO_PUBLIC_API_URL at an opsdesk-web instance and the app talks to its /api/v1 REST API. Leave it unset and the app runs in demo mode against an in-memory backend that mirrors that API, with the same demo accounts, RBAC rules and error codes as the opsdesk-web seed. That way you can explore every role in Expo Go without a server. A contract test runs the real HTTP adapter against a live opsdesk-web to keep the two repos in step.
Portfolio reference implementation: patterns from production apps I've shipped to the App Store and Play Store, rebuilt from scratch with demo data.
| Sign in (demo mode) | Ticket queue | Ticket + AI summary | Offline reply (dark) | Role & settings (dark) |
|---|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
I captured these screenshots from the web target (npx expo start --web) at phone width in demo mode. The iOS and Android builds render the same React Native UI.
- Ticket queue: debounced search (subject, customer,
#number), status filter chips (open, pending, resolved, closed), page-based infinite scroll and pull-to-refresh. Pull-to-refresh also flushes the outbox. - Ticket detail:
- The full thread, with customer messages, team replies and internal notes styled differently.
- A reply composer.
- Status, priority and assignee controls, each shown or hidden by the RBAC matrix shared with opsdesk-web.
- AI summary card: calls
POST /ai/summaryfor thesummaryandreplytasks in parallel. It shows a short summary plus a suggested reply, and Use as reply drops the suggestion into the composer. - Create ticket: form validated with zod, with clear messaging when you're offline.
- Profile & settings: your role and what it allows, a System/Light/Dark theme picker, sync status, push status, and sign out.
- Offline-first:
- Comments and status/priority/assignee changes go through a persisted outbox.
- The UI updates optimistically.
- The outbox replays on reconnect, on app foreground and when backoff expires.
- The query cache is persisted, so the queue opens instantly, even with no connection.
- Auth:
- The JWT is stored in
expo-secure-store(Keychain/Keystore). - The session is restored on launch and revalidated with
GET /me. - Any
401signs the user out automatically.
- The JWT is stored in
- Role-aware UI: a single permission matrix shared with the web app.
- Push notifications: handles permissions, sets up the Android channel and registers an Expo push token with the backend. Tapping a notification opens the ticket.
- Datadog RUM: errors, resources, interactions and route-based views. It only switches on when credentials are configured.
- Light and dark themes: built from semantic design tokens, with accessibility roles, labels and states on interactive components.
flowchart LR
subgraph Device["OpsDesk Mobile (iOS / Android, Expo)"]
UI["Screens<br/>expo-router"] --> Hooks["TanStack Query hooks"]
UI --> Sync["SyncProvider"]
Sync --> Outbox[("Outbox<br/>AsyncStorage")]
Hooks --> Cache[("Query cache<br/>AsyncStorage")]
Hooks --> Client["Typed API client<br/>fetch + timeout + zod"]
Sync --> Client
Auth["AuthProvider"] --> Secure[("expo-secure-store<br/>JWT")]
Client -. "401" .-> Auth
Net["NetInfo"] --> Sync
Push["expo-notifications"] -->|deep link| UI
end
Client -->|"HTTPS /api/v1<br/>Bearer JWT"| API["OpsDesk API<br/>Next.js (opsdesk-web)"]
API --> PG[("Postgres<br/>multi-tenant")]
API --> Stripe["Stripe<br/>billing"]
API --> LLM["LLM provider<br/>/ai/summary"]
API -.->|"Expo Push<br/>(sender not built yet)"| Push
Device -->|"RUM: views, errors,<br/>resources, actions"| DD["Datadog RUM"]
If EXPO_PUBLIC_API_URL is empty, src/api/index.ts swaps the HTTP client for createMockApi(). Both implement the same OpsDeskApi interface and validate responses against the same zod schemas, so the rest of the app can't tell them apart.
src/
app/ expo-router routes (thin: they render feature screens)
api/ zod schemas, HTTP client, OpsDeskApi, mock backend + seed data
auth/ AuthProvider, secure token storage, session + 401 signal
offline/ Outbox, SyncProvider, optimistic overlay, NetInfo status
features/ tickets (list/detail/create + hooks), auth (login), settings
permissions/ shared role permission matrix
notifications/ push registration + notification deep links
monitoring/ monitoring.ts: Datadog RUM wrapper
components/ UI kit + ticket components
theme/ design tokens, ThemeProvider (System/Light/Dark)
| Area | Choice |
|---|---|
| Framework | Expo SDK 57, React Native 0.86, React 19 |
| Language | TypeScript (strict, noUncheckedIndexedAccess) |
| Navigation | expo-router (file-based, Stack.Protected auth gating, opsdesk:// deep links) |
| Server state | TanStack Query v5 + AsyncStorage persister |
| Validation | zod 4 (API responses and form input) |
| Offline | Custom outbox on AsyncStorage + @react-native-community/netinfo |
| Auth storage | expo-secure-store |
| Push | expo-notifications, expo-device |
| Monitoring | @datadog/mobile-react-native (RUM) |
| Testing | Jest (jest-expo) + React Native Testing Library 14 |
| Tooling | ESLint (eslint-config-expo), tsc --noEmit, GitHub Actions, Snyk, Dependabot, EAS Build |
The app consumes the REST API that opsdesk-web serves under ${EXPO_PUBLIC_API_URL}/api/v1. Every request except login sends Authorization: Bearer <accessToken>.
Two files own the mapping between the server's wire format and the app's domain types:
src/api/schemas.tsholds the zod wire and domain schemas.src/api/httpApi.tsis the adapter.
Nothing else in the app knows about response envelopes.
| Method | Path | Response | Used for |
|---|---|---|---|
POST |
/auth/login |
{ accessToken, tokenType, expiresIn, user, organization: { id, name, plan, role } } |
Sign-in (mapped to the session user) |
GET |
/me |
{ user, organization, role, permissions } |
Revalidating a restored session |
GET |
/tickets?status=&q=&page=&pageSize= |
{ items, total, page, pageSize } |
Queue, search, filters, infinite scroll |
GET |
/tickets/:id |
{ ticket } |
Ticket detail (fetched in parallel with the thread) |
GET |
/tickets/:id/comments |
{ comments } |
Thread, including internal notes |
POST |
/tickets |
{ ticket } (201) |
Create ticket |
PATCH |
/tickets/:id |
{ ticket } |
Status, priority, assignee (outbox) |
POST |
/tickets/:id/comments |
{ comment } (201), body { body, isInternal: false } |
Replies (outbox) |
POST |
/ai/summary |
{ task, text, provider, model }, body { ticketId, task } |
AI card (summary + reply) |
GET |
/members |
{ members: [{ userId, name, email, role, joinedAt }] } |
Assignee picker |
POST |
/devices/push-token |
{ ok: true }, body { token, platform } |
Push registration (upsert) |
The enums match opsdesk-web's src/lib/domain.ts:
- roles:
owner | admin | agent | viewer - statuses:
open | pending | resolved | closed - priorities:
low | normal | high | urgent
Errors. Errors from the server arrive as { error: { code, message, details? } }, with these statuses: 401 unauthorized, 403 forbidden, 404 not found, 422 validation_failed, 402 plan limit and 429 rate limited. The client normalises them into a single ApiError with one of five kinds: network, timeout, unauthorized, http or validation. Each error also carries a retryable flag, which both the TanStack retry policy and the outbox use. Any 401 outside login signs the user out.
Contract test. src/api/__tests__/opsdeskWeb.e2e.test.ts drives the real HTTP adapter against a running opsdesk-web through this sequence:
- login →
/me - paged list → detail + thread
- reply → status/priority change
- AI summary
- members → reassign
- push-token registration → ticket creation
- 401 and 403 handling
See Testing.
The app is outbox-first: comments and ticket updates always go through the queue, whether you're online or offline. That keeps a single code path for both cases.
user action ──► outbox.enqueue(op) ──► persisted to AsyncStorage
│
├─► UI renders server data + pending ops (optimistic overlay)
│
└─► online? replay now : wait for NetInfo / foreground / backoff timer
│
executor(op) ──► API ──► write confirmed entity into query cache ──► dequeue
- Strict FIFO and single-flight:
- The head entry must finish before the next one is sent, so the server sees writes in the order the user made them.
- If a NetInfo event and a pull-to-refresh both trigger a replay, they share one run.
- Retryable failures (network, timeout, 408/429/5xx):
- The entry stays at the head of the queue.
attemptsis incremented and capped exponential backoff is scheduled.- After
maxAttempts, the entry is dropped and reported. - When connectivity returns, the backoff timer is skipped and the queue flushes immediately.
- Permanent failures (400/403/404/409/validation), for example a role change while offline:
- The entry is dropped immediately, so it can't block the queue.
- The user sees a dismissible banner.
- The error is sent to Datadog.
- Coalescing: repeated updates to the same ticket are merged into the queued entry (last write wins per field). An entry that is in flight is never changed.
- At-least-once delivery: an entry leaves the queue only after the server confirms it. Each comment carries a client-generated id that keys its optimistic copy in the UI until the server's version replaces it.
- Optimistic overlay, not cache surgery: screens render
server data + pending ops(src/offline/pending.ts), so a refetch or an app restart can't wipe pending changes. - Durability:
- Each change is persisted before it is acknowledged, and storage writes are serialised.
- Corrupt persisted data is discarded instead of crashing the app.
- On a cold start, the queue is hydrated before replay.
- The queue is cleared on sign-out.
- Online-only actions: creating a ticket (the server assigns the ticket number) and AI summaries are disabled while offline, with an explanation.
In demo mode, Profile → Simulate offline makes the mock API unreachable. This exercises the same queue path as a real outage, without toggling airplane mode.
src/permissions/matrix.ts mirrors opsdesk-web's single source of truth, src/lib/permissions.ts. It uses the same permission names (ticket:update, ticket:assign, comment:create, ai:use, ...) and the same role sets, and its tests pin the same table as the web app's rbac.test.ts. The server enforces the matrix on every request, the app uses it to decide which controls to show, and the demo backend enforces it too.
| Capability (permission) | Owner | Admin | Agent | Viewer |
|---|---|---|---|---|
View tickets and threads (ticket:read) |
✅ | ✅ | ✅ | ✅ |
Create tickets (ticket:create) |
✅ | ✅ | ✅ | ❌ |
Reply (comment:create) |
✅ | ✅ | ✅ | ❌ |
Change status / priority (ticket:update) |
✅ | ✅ | ✅ | ❌ |
Assign / reassign (ticket:assign) |
✅ | ✅ | ✅ | ❌ |
AI summary and draft (ai:use) |
✅ | ✅ | ✅ | ❌ |
See team members (member:read) |
✅ | ✅ | ✅ | ✅ |
Member management (owner and admin) and billing (owner manages, admin reads) live in the web app only.
Viewers get a read-only ticket view with no composer and no controls. The assignee picker only offers roles that can work tickets, because the server rejects assigning to viewers.
Requirements: Node 22.13 or newer, and npm.
npm i
npx expo startThen scan the QR code with Expo Go (iOS or Android), or press i / a / w for the iOS simulator, an Android emulator or the web.
Demo mode is the default, because EXPO_PUBLIC_API_URL is unset:
- Tap Owner / Admin / Agent / Viewer on the sign-in screen, or sign in with
aisha@northwind.test/opsdesk-demo. These are the same accounts as the opsdesk-web seed. - Try the role-aware UI.
- Turn on Profile → Simulate offline, reply to a ticket and change its status, then turn it off and watch the changes sync.
To run against a real OpsDesk backend, start opsdesk-web locally. It uses an embedded PGlite database, so it needs no Postgres or Docker:
# in opsdesk-web
npm i && npm run db:seed && npm run dev # http://localhost:3000
# in opsdesk-mobile
cp .env.example .env
# EXPO_PUBLIC_API_URL=http://<your-LAN-IP>:3000 (a phone can't reach "localhost" on your computer)
npx expo start --clearAll variables are documented in .env.example.
Datadog RUM and remote push need a native build. Neither is available in Expo Go: Expo removed Android remote push from Expo Go in SDK 53, and the Datadog native SDK isn't bundled with Expo Go.
npx eas-cli@latest init # sets the EAS project id (use as EAS_PROJECT_ID)
npx eas-cli@latest build --profile development # internal debug build (iOS simulator / Android APK)
npx eas-cli@latest build --profile preview # internal release build for testers
npx eas-cli@latest build --profile production # store build, auto-incremented versionapp.config.ts reads APP_VARIANT from the profile, so the dev, preview and production builds get separate bundle IDs and app names and can be installed side by side.
After sign-in on a physical device, the app:
- Asks for notification permission.
- Creates the Android
ticket-updateschannel. - Gets an Expo push token.
- Registers the token with
POST /api/v1/devices/push-token, sending{ token: "ExponentPushToken[...]", platform: "ios" | "android" }.
To deep-link to a ticket, the server sends either data: { ticketId: "tkt_123" } or data: { url: "opsdesk://tickets/tkt_123" }. Taps are routed to /tickets/:id, including the tap that launched the app from a killed state. Payloads that don't match a strict ticket-id pattern are ignored. Sending pushes is server-side work and out of scope for this repo.
To turn on monitoring, set EXPO_PUBLIC_DD_CLIENT_TOKEN and EXPO_PUBLIC_DD_APPLICATION_ID, and optionally EXPO_PUBLIC_DD_SITE and EXPO_PUBLIC_DD_ENV. Monitoring then initialises with:
- JS error and native crash tracking
- resource and interaction tracking
- trace-header propagation to the API host
- user context: id, role and org
Views are tracked from the expo-router pathname, with dynamic segments collapsed (for example, /tickets/[id]).
All of this sits behind src/monitoring/monitoring.ts. Without credentials, on web, or in Expo Go, every call is a no-op.
npm test # jest (jest-expo preset)
npm run test:ci # with coverage
npm run lint # eslint, zero warnings allowed
npm run typecheck # tsc --noEmit
npm run verify # all of the aboveJest runs 98 tests in 10 suites, plus 3 contract tests against a live opsdesk-web that are skipped unless you point them at a server:
| Suite | What it proves |
|---|---|
permissions/matrix |
The matrix is identical to opsdesk-web's (same lists per role), strictly hierarchical, viewers read-only, only non-viewers assignable, PATCH-level guards |
offline/outbox |
Persistence across "restarts", corrupt-data recovery, FIFO replay, stop-on-retryable with backoff, max attempts, dropping permanent failures, single-flight, coalescing (never into the in-flight entry), optimistic overlay |
api/httpClient |
Bearer header, 401 → unauthorized + logout hook (but not for login), zod validation failure → validation error, error envelopes, network/timeout mapping, and the opsdesk-web adapter: login mapping, { ticket }/{ comment }/{ members } unwrapping, page/pageSize query, detail + thread merge, AI summary + reply |
api/mockApi |
Same demo accounts as the web seed, 401s, page/pageSize/total pagination, filters and search, 403/422 like the server, customer/internal thread, ticket creation, AI summary, members, simulated offline |
auth/AuthProvider |
Sign-in stores the token in secure storage, session restore, automatic logout on 401 that clears storage and per-user data, manual sign-out |
TicketListScreen |
Renders tickets, filters by status chip, debounced search plus empty state, infinite scroll, navigation, an offline status change leaving the filtered list |
TicketDetailScreen |
Role-aware UI (viewer read-only, agent reassigns, admin unassigns); offline reply shown as pending and synced on reconnect; optimistic status change; AI summary used as a reply; replay of a queue persisted before an app restart |
notifications/deepLink, monitoring, utils/time |
Push payload → route (rejects unsafe ids and foreign URLs), monitoring no-ops when disabled, relative time formatting |
api/opsdeskWeb.e2e (opt-in) |
The real HTTP adapter against a running opsdesk-web: the full agent flow, viewer 403, bad-token 401, bad-credentials login |
Screen tests render the real provider stack (theme, API, query client, auth, sync) against the mock backend. They don't use snapshots.
To run the contract tests against a live server:
# in opsdesk-web: npm run db:seed && npm run dev
OPSDESK_E2E_URL=http://localhost:3000 npm run test:e2eci.ymlruns on every push and PR. It doesnpm ci, then lint, typecheck and Jest with coverage, then a web bundle smoke test (expo export --platform web).security.ymlruns a Snyk dependency scan (snyk/actions/node) on every push and PR, and weekly. It needs theSNYK_TOKENrepository secret and is report-only (continue-on-error: true) until a baseline has been triaged.- Dependabot opens weekly npm and GitHub Actions updates. Expo SDK packages are grouped so they move together, and major Expo upgrades are left for a human to do.
- Creating tickets needs a connection. Ticket numbers come from the server, so offline creation would need temporary IDs and remapping. That's deliberately out of scope.
- Demo mode data is in-memory. It resets on reload. The outbox and query cache are still persisted, so the offline flows behave the same.
- On web, the session uses
sessionStorage.expo-secure-storehas no web implementation. The web target is only used for previews and screenshots; iOS and Android use the Keychain/Keystore. - Comment replays are at-least-once.
POST /tickets/:id/commentsdoesn't take an idempotency key yet. If the server stores a reply but the response is lost, the replay can post it twice. - Push sending is server-side and not built. The app registers tokens with opsdesk-web, but the sender isn't part of either repo yet.
MIT © 2026 Zaid Mubbashar




