Skip to content

Capacitor Mobile App

Loquent ships as a native iOS and Android app using Capacitor 7. The native shell is a thin WebView wrapper that loads the production web app from the server — no code is bundled into the binary.

The mobile shell runs in remote server mode. On launch the WebView navigates to the configured server URL (default https://app.loquent.io). Auth, routing, API calls, and WebSocket connections work identically to a browser session.

┌─────────────────────────────┐
│ Native Shell (Capacitor) │
│ ┌───────────────────────┐ │
│ │ WKWebView / WebView │ │
│ │ → app.loquent.io │ │
│ │ → WASM + Dioxus │ │
│ └───────────────────────┘ │
│ Plugins: Push, Haptics, │
│ Keyboard, Badge, Network │
└─────────────────────────────┘

If the server is unreachable, the WebView renders mobile/public/offline.html as a fallback.

mobile/capacitor-bridge.js exposes window.CapacitorBridge — a thin wrapper over Capacitor plugin APIs. Every method degrades to a no-op when window.Capacitor is undefined, so the same WASM bundle runs in both browser and native shell.

The bridge is inlined into the Dioxus client root:

src/bases/client/client_app.rs
include_str!("../../../mobile/capacitor-bridge.js")
MethodPluginPurpose
isNative()CoreReturns true inside Capacitor
getPlatform()Core"ios", "android", or "web"
registerPushWithListeners(onReg, onErr)PushNotificationsAtomic registration + listener attach
hapticImpact(style)HapticsTactile feedback (Light, Medium, Heavy)
setBadge(count) / clearBadge()BadgeApp icon badge count
hideSplash()SplashScreenDismiss native splash after first render
onAppStateChange(cb)AppForeground/background transitions
onAppUrlOpen(cb)AppDeep link handling

Three Rust modules in src/shared/capacitor/ consume the bridge:

ModuleRole
bridge.rsis_native(), get_platform(), hide_splash_screen() via js_sys::Reflect
lifecycle.rsWires appStateChange and appUrlOpen to reconnect signal + router
push.rsDevice registration, token POST, notification action routing

Two components mount inside PrivateLayout (post-auth only):

  • MobileLifecycleMount — listens for foreground events and bumps RealtimeContext.force_reconnect so the WebSocket reconnects within ~1s instead of waiting for the heartbeat timeout. Also handles deep links from appUrlOpen and getLaunchUrl.
  • PushRegistrationMount — generates a stable device ID (localStorage key loquent.device_id), requests permission, registers with APNs/FCM, and POSTs the token to the backend.
  1. Generate or read loquent.device_id (UUIDv4 in localStorage)
  2. PushNotifications.requestPermissions()
  3. PushNotifications.register() (atomic with listeners)
  4. POST /api/push-notifications/tokens with { token, platform, device_id }
  5. Server upserts on (user_id, device_id)

Payloads include a data.url field (FCM) or custom payload field (APNs). On tap, MobileLifecycleMount parses the path and calls nav.push(Route::from_str(&path)) to navigate to the relevant view.

Configured in the admin panel (/admin/system → Push Notifications):

  • APNs: Auth Key (.p8), Key ID, Team ID B6V2J755DQ, Bundle ID com.loquent-comm.app
  • FCM: Service Account JSON uploaded to the admin panel

The bridge attaches listeners on load:

  • keyboardWillShow → sets --kb-h CSS variable on <html> to the keyboard height
  • keyboardWillHide → resets --kb-h to 0px
  • keyboardDidShow → scrolls message feeds (#communication-feed, #assistant-messages) to bottom
  • focusin → scrolls focused inputs into view after 250ms delay

Dioxus layout components use calc(100dvh - var(--kb-h)) to shrink in sync with the keyboard.

The app handles universal links (iOS) and app links (Android). URLs matching app.loquent.io/* open directly in the native app. Configuration:

  • iOS: Associated Domains entitlement (applinks:app.loquent.io)
  • Android: intent-filter in AndroidManifest.xml with autoVerify="true" + assetlinks.json served by the backend (requires MOBILE_ANDROID_SHA256_FINGERPRINTS env var)

The server URL is read from mobile/.env (gitignored):

mobile/.env
CAPACITOR_SERVER_URL=https://app.loquent.io

Override for local dev or PR previews — set the URL and run pnpm run sync.

mobile/
├── android/ # Android Studio project (tracked)
├── ios/ # Xcode project (tracked, Pods/ ignored)
├── public/offline.html # Offline fallback
├── capacitor.config.ts # Server URL, plugin config
├── capacitor-bridge.js # JS bridge for Rust/WASM
├── package.json # Capacitor 7 + plugins
└── .env # Per-dev server URL (gitignored)
PackageVersionPurpose
@capacitor/app^7.0.0Lifecycle, deep links
@capacitor/push-notifications^7.0.0APNs + FCM
@capacitor/haptics^7.0.0Tactile feedback
@capacitor/keyboard^7.0.0Keyboard events
@capacitor/network^7.0.0Connectivity status
@capacitor/splash-screen^7.0.0Launch splash control
@capacitor/status-bar^7.0.0Status bar styling
@capawesome/capacitor-badge^7.0.0App icon badge